From 5ba7ef8459724310ad1d79b263c1453cf32933d5 Mon Sep 17 00:00:00 2001 From: ankibatr Date: Tue, 25 May 2021 01:27:32 +0530 Subject: [PATCH 1/2] migrate-v2-to-v3 --- README.md | 2 +- app/.gitignore | 1 + app/build.gradle | 86 + app/proguard-rules.pro | 21 + app/release/output-metadata.json | 18 + .../kitchensink/HomeActivityTest.kt | 105 + .../androidsdk/kitchensink/KitchenSinkTest.kt | 288 +++ .../kitchensink/SearchActivityTest.kt | 43 + .../kitchensink/auth/LoginActivityTest.kt | 18 + .../kitchensink/calling/DialFragmentTest.kt | 85 + .../kitchensink/messaging/PostMessageTest.kt | 168 ++ .../members/MembershipFragmentTest.kt | 38 + .../search/SearchPeopleFragmentTest.kt | 41 + .../search/SpaceMessageDetailsFragmentTest.kt | 47 + .../messaging/spaces/SpacesFragmentTest.kt | 52 + .../teams/membership/TeamFragmentTest.kt | 95 + .../membership/TeamMembershipFragmentTest.kt | 88 + .../kitchensink/utils/RecyclerViewMatchers.kt | 87 + .../androidsdk/kitchensink/utils/WaitUtils.kt | 71 + .../kitchensink/webhooks/WebhookTest.kt | 118 ++ app/src/main/AndroidManifest.xml | 180 ++ app/src/main/assets/cisco.png | Bin 0 -> 80569 bytes .../androidsdk/kitchensink/AppModule.kt | 9 + .../androidsdk/kitchensink/BaseActivity.kt | 34 + .../androidsdk/kitchensink/BaseViewModel.kt | 17 + .../androidsdk/kitchensink/HomeActivity.kt | 211 ++ .../androidsdk/kitchensink/JWTWebexModule.kt | 14 + .../androidsdk/kitchensink/KitchenSinkApp.kt | 86 + .../kitchensink/OAuthWebexModule.kt | 23 + .../androidsdk/kitchensink/WebexModule.kt | 14 + .../androidsdk/kitchensink/WebexRepository.kt | 296 +++ .../androidsdk/kitchensink/WebexViewModel.kt | 752 ++++++++ .../kitchensink/auth/JWTLoginActivity.kt | 82 + .../kitchensink/auth/LoginActivity.kt | 114 ++ .../kitchensink/auth/LoginModule.kt | 11 + .../kitchensink/auth/LoginRepository.kt | 48 + .../kitchensink/auth/LoginViewModel.kt | 52 + .../kitchensink/auth/OAuthWebLoginActivity.kt | 84 + .../kitchensink/calling/CallActivity.kt | 108 ++ .../calling/CallBottomSheetFragment.kt | 137 ++ .../calling/CallControlsFragment.kt | 1717 +++++++++++++++++ .../kitchensink/calling/CallModule.kt | 10 + .../calling/CallObserverInterface.kt | 21 + .../kitchensink/calling/CallViewModel.kt | 6 + .../kitchensink/calling/DialFragment.kt | 173 ++ .../kitchensink/calling/DialerActivity.kt | 56 + .../calling/IncomingCallInfoModel.kt | 7 + .../kitchensink/calling/MeetingInfoModel.kt | 38 + .../calling/OneToOneIncomingCallModel.kt | 5 + .../kitchensink/calling/RingerManager.kt | 254 +++ .../calling/SpaceIncomingCallModel.kt | 5 + .../participants/ParticipantsAdapter.kt | 91 + .../participants/ParticipantsFragment.kt | 146 ++ .../kitchensink/cucm/UCLoginActivity.kt | 192 ++ .../kitchensink/extras/ExtrasActivity.kt | 49 + .../kitchensink/extras/ExtrasModule.kt | 10 + .../kitchensink/extras/ExtrasRepository.kt | 50 + .../kitchensink/extras/ExtrasViewModel.kt | 34 + .../androidsdk/kitchensink/firebase/Data.kt | 15 + .../kitchensink/firebase/FCMPushModel.kt | 20 + .../firebase/KitchenSinkFCMService.kt | 244 +++ .../firebase/PushRestPayloadModel.kt | 13 + .../firebase/RegisterTokenService.kt | 66 + .../messaging/BaseDialogFragment.kt | 14 + .../messaging/MessagingActivity.kt | 69 + .../kitchensink/messaging/MessagingModule.kt | 49 + .../messaging/MessagingRepository.kt | 149 ++ .../kitchensink/messaging/RemoteModel.kt | 83 + .../composer/MentionsAutoCompleteEditText.kt | 356 ++++ .../composer/MessageComposerActivity.kt | 422 ++++ .../composer/MessageComposerRepository.kt | 77 + .../composer/MessageComposerViewModel.kt | 95 + .../search/MessagingSearchActivity.kt | 23 + .../messaging/search/SearchPeopleFragment.kt | 163 ++ .../messaging/search/SearchPeopleModule.kt | 9 + .../messaging/search/SearchPeopleViewModel.kt | 33 + .../spaces/AddPersonBottomSheetFragment.kt | 35 + .../messaging/spaces/ReplyMessageModel.kt | 58 + .../spaces/SpaceActionBottomSheetFragment.kt | 58 + .../messaging/spaces/SpaceMeetingInfo.kt | 39 + .../messaging/spaces/SpaceMessageModel.kt | 26 + .../messaging/spaces/SpaceModel.kt | 45 + .../messaging/spaces/SpaceReadStatusModel.kt | 37 + .../messaging/spaces/SpacesFragment.kt | 313 +++ .../messaging/spaces/SpacesRepository.kt | 139 ++ .../messaging/spaces/SpacesViewModel.kt | 130 ++ .../adapters/SpaceReadStatusClientAdapter.kt | 37 + .../spaces/adapters/SpacesClientAdapter.kt | 77 + .../spaces/detail/FileViewerActivity.kt | 146 ++ .../MessageActionBottomSheetFragment.kt | 62 + .../detail/MessageDetailsDialogFragment.kt | 131 ++ .../spaces/detail/MessageViewModel.kt | 72 + .../spaces/detail/SpaceDetailActivity.kt | 282 +++ .../spaces/detail/SpaceDetailViewModel.kt | 91 + .../spaces/members/MembershipActivity.kt | 42 + .../spaces/members/MembershipFragment.kt | 210 ++ .../spaces/members/MembershipModel.kt | 45 + .../spaces/members/MembershipRepository.kt | 98 + .../spaces/members/MembershipViewModel.kt | 59 + ...paceMembershipActionBottomSheetFragment.kt | 50 + .../MembershipReadStatusActivity.kt | 41 + .../MembershipReadStatusFragment.kt | 104 + .../MembershipReadStatusModel.kt | 37 + .../MembershipReadStatusViewModel.kt | 36 + .../SpaceReadStatusDetailActivity.kt | 50 + .../SpaceReadStatusDetailViewModel.kt | 19 + .../teams/TeamActionBottomSheetFragment.kt | 47 + .../kitchensink/messaging/teams/TeamModel.kt | 26 + .../messaging/teams/TeamsFragment.kt | 254 +++ .../messaging/teams/TeamsRepository.kt | 78 + .../messaging/teams/TeamsViewModel.kt | 68 + .../teams/detail/TeamDetailActivity.kt | 52 + .../teams/detail/TeamDetailViewModel.kt | 19 + ...TeamMembershipActionBottomSheetFragment.kt | 45 + .../membership/TeamMembershipActivity.kt | 42 + .../membership/TeamMembershipFragment.kt | 171 ++ .../teams/membership/TeamMembershipModel.kt | 41 + .../membership/TeamMembershipRepository.kt | 85 + .../membership/TeamMembershipViewModel.kt | 44 + .../person/PeopleActionBottomSheetFragment.kt | 42 + .../kitchensink/person/PeopleFragment.kt | 128 ++ .../person/PersonDialogFragment.kt | 65 + .../kitchensink/person/PersonModel.kt | 50 + .../kitchensink/person/PersonModule.kt | 10 + .../kitchensink/person/PersonRepository.kt | 94 + .../kitchensink/person/PersonViewModel.kt | 34 + .../kitchensink/search/SearchActivity.kt | 73 + .../search/SearchCommonFragment.kt | 234 +++ .../kitchensink/search/SearchModule.kt | 11 + .../kitchensink/search/SearchRepository.kt | 35 + .../kitchensink/search/SearchViewModel.kt | 78 + .../kitchensink/setup/SetupActivity.kt | 196 ++ .../kitchensink/utils/AudioManagerUtils.kt | 29 + .../kitchensink/utils/Base64Utils.kt | 17 + .../kitchensink/utils/BindingAdapters.kt | 10 + .../kitchensink/utils/CallObjectStorage.kt | 46 + .../androidsdk/kitchensink/utils/Constants.kt | 36 + .../androidsdk/kitchensink/utils/Crypto.kt | 39 + .../kitchensink/utils/DialogUtils.kt | 51 + .../androidsdk/kitchensink/utils/FileUtils.kt | 127 ++ .../utils/HorizontalFlipTransformation.kt | 29 + .../kitchensink/utils/PermissionsHelper.kt | 67 + .../kitchensink/utils/SharedPrefUtils.kt | 52 + .../utils/extensions/StringExtension.kt | 13 + .../utils/extensions/ViewExtension.kt | 19 + .../WebhookActionBottomSheetFragment.kt | 42 + .../kitchensink/webhooks/WebhooksActivity.kt | 216 +++ .../kitchensink/webhooks/WebhooksModule.kt | 13 + .../webhooks/WebhooksRepository.kt | 91 + .../kitchensink/webhooks/WebhooksViewModel.kt | 60 + app/src/main/res/anim/calling_animation.xml | 19 + app/src/main/res/anim/cycle_animation.xml | 3 + .../drawable-v24/ic_launcher_foreground.xml | 30 + .../res/drawable/app_notification_icon.png | Bin 0 -> 2704 bytes app/src/main/res/drawable/audio_button.xml | 14 + .../main/res/drawable/audio_mute_active.xml | 9 + .../main/res/drawable/audio_mute_default.xml | 9 + .../main/res/drawable/audio_mute_disable.xml | 9 + app/src/main/res/drawable/bg_gray_border.xml | 12 + .../main/res/drawable/circle_filled_blue.xml | 24 + .../res/drawable/circle_filled_dark_gray.xml | 23 + .../drawable/circle_filled_deep_yellow.xml | 24 + .../main/res/drawable/circle_filled_gray.xml | 24 + .../main/res/drawable/circle_filled_green.xml | 24 + .../main/res/drawable/circle_filled_red.xml | 24 + .../res/drawable/circle_filled_yellow.xml | 24 + app/src/main/res/drawable/dialog_bg.xml | 12 + app/src/main/res/drawable/edit_text_bg.xml | 6 + .../res/drawable/google_contacts_android.png | Bin 0 -> 32790 bytes app/src/main/res/drawable/ic_add.xml | 10 + app/src/main/res/drawable/ic_arrow_24.xml | 5 + .../res/drawable/ic_attachment_cancel.xml | 10 + app/src/main/res/drawable/ic_backspace.xml | 5 + .../main/res/drawable/ic_baseline_add_24.xml | 10 + .../res/drawable/ic_baseline_close_24.xml | 5 + .../res/drawable/ic_btn_camera_swap_40.xml | 10 + app/src/main/res/drawable/ic_call.xml | 10 + app/src/main/res/drawable/ic_call_hold.xml | 10 + .../main/res/drawable/ic_call_merge_24.xml | 5 + .../main/res/drawable/ic_call_transfer.xml | 10 + app/src/main/res/drawable/ic_call_waiting.xml | 12 + app/src/main/res/drawable/ic_call_white.xml | 10 + app/src/main/res/drawable/ic_camera_24.xml | 10 + app/src/main/res/drawable/ic_cancel.xml | 10 + .../main/res/drawable/ic_cisco_gray_logo.png | Bin 0 -> 8933 bytes app/src/main/res/drawable/ic_dialpad.xml | 10 + app/src/main/res/drawable/ic_feeback.xml | 10 + .../drawable/ic_incoming_call_legacy_36.xml | 12 + app/src/main/res/drawable/ic_keyboard.xml | 10 + .../res/drawable/ic_launcher_background.xml | 170 ++ app/src/main/res/drawable/ic_login.xml | 10 + app/src/main/res/drawable/ic_logout.xml | 10 + app/src/main/res/drawable/ic_menu.xml | 10 + app/src/main/res/drawable/ic_message.xml | 12 + app/src/main/res/drawable/ic_mic_24.xml | 5 + app/src/main/res/drawable/ic_mic_off_24.xml | 5 + app/src/main/res/drawable/ic_more.xml | 10 + .../main/res/drawable/ic_outgoing_call.xml | 10 + .../main/res/drawable/ic_participant_add.xml | 10 + .../main/res/drawable/ic_participant_list.xml | 10 + app/src/main/res/drawable/ic_people_24.xml | 5 + app/src/main/res/drawable/ic_person.xml | 5 + app/src/main/res/drawable/ic_remote_end.xml | 10 + app/src/main/res/drawable/ic_remote_mute.xml | 6 + app/src/main/res/drawable/ic_reply.xml | 10 + app/src/main/res/drawable/ic_screen_share.xml | 9 + .../main/res/drawable/ic_screen_sharing.xml | 9 + app/src/main/res/drawable/ic_speaker.xml | 10 + .../main/res/drawable/ic_turn_off_video.xml | 9 + .../res/drawable/remote_video_view_border.xml | 9 + .../res/drawable/screen_sharing_active.xml | 9 + .../res/drawable/screen_sharing_default.xml | 9 + .../res/drawable/speaker_button_selector.xml | 14 + .../main/res/drawable/surfaceview_border.xml | 17 + .../surfaceview_transparent_border.xml | 15 + .../res/drawable/turn_off_video_active.xml | 9 + .../res/drawable/turn_on_video_default.xml | 9 + .../main/res/drawable/webex_teams_logo.png | Bin 0 -> 20935 bytes app/src/main/res/layout/activity_call.xml | 20 + .../main/res/layout/activity_cucm_login.xml | 63 + app/src/main/res/layout/activity_dialer.xml | 34 + app/src/main/res/layout/activity_extras.xml | 78 + .../main/res/layout/activity_file_viewer.xml | 67 + app/src/main/res/layout/activity_home.xml | 438 +++++ app/src/main/res/layout/activity_login.xml | 107 + .../res/layout/activity_login_with_token.xml | 107 + .../main/res/layout/activity_membership.xml | 15 + .../activity_membership_read_status.xml | 14 + .../res/layout/activity_message_composer.xml | 164 ++ .../main/res/layout/activity_messaging.xml | 51 + app/src/main/res/layout/activity_oauth.xml | 90 + app/src/main/res/layout/activity_search.xml | 27 + .../res/layout/activity_search_messaging.xml | 14 + app/src/main/res/layout/activity_setup.xml | 270 +++ .../main/res/layout/activity_space_detail.xml | 212 ++ .../activity_space_read_status_detail.xml | 156 ++ .../main/res/layout/activity_team_detail.xml | 117 ++ app/src/main/res/layout/activity_webhooks.xml | 93 + .../bottom_sheet_add_member_options.xml | 67 + .../res/layout/bottom_sheet_call_options.xml | 131 ++ .../layout/bottom_sheet_message_options.xml | 106 + .../layout/bottom_sheet_people_options.xml | 111 ++ .../bottom_sheet_space_member_options.xml | 155 ++ .../res/layout/bottom_sheet_space_options.xml | 174 ++ .../bottom_sheet_team_member_options.xml | 133 ++ .../res/layout/bottom_sheet_team_options.xml | 132 ++ .../layout/bottom_sheet_webhook_action.xml | 111 ++ .../res/layout/common_fragment_item_list.xml | 63 + .../main/res/layout/dialog_create_space.xml | 51 + .../res/layout/dialog_enter_meeting_pin.xml | 31 + .../res/layout/dialog_membership_details.xml | 241 +++ .../res/layout/dialog_message_details.xml | 256 +++ .../layout/dialog_post_message_handler.xml | 219 +++ .../layout/dialog_team_membership_details.xml | 199 ++ .../main/res/layout/dialog_uclogin_nonsso.xml | 42 + .../res/layout/dialog_uclogin_settings.xml | 42 + .../main/res/layout/dialog_webhook_create.xml | 100 + .../main/res/layout/dialog_webhook_update.xml | 80 + app/src/main/res/layout/fragment_call.xml | 290 +++ .../res/layout/fragment_call_controls.xml | 368 ++++ app/src/main/res/layout/fragment_common.xml | 55 + .../res/layout/fragment_dialog_person.xml | 296 +++ .../fragment_dialog_webhook_details.xml | 318 +++ .../main/res/layout/fragment_membership.xml | 58 + .../fragment_membership_read_status.xml | 59 + .../main/res/layout/fragment_participants.xml | 69 + app/src/main/res/layout/fragment_person.xml | 38 + app/src/main/res/layout/fragment_spaces.xml | 64 + app/src/main/res/layout/fragment_teams.xml | 38 + .../main/res/layout/list_item_attachments.xml | 33 + .../res/layout/list_item_call_meeting.xml | 116 ++ .../layout/list_item_membership_client.xml | 163 ++ .../list_item_membership_read_status.xml | 187 ++ app/src/main/res/layout/list_item_mention.xml | 36 + app/src/main/res/layout/list_item_person.xml | 112 ++ app/src/main/res/layout/list_item_persons.xml | 37 + .../res/layout/list_item_space_message.xml | 110 ++ .../res/layout/list_item_spaces_client.xml | 100 + .../layout/list_item_spaces_read_client.xml | 112 ++ .../list_item_team_membership_client.xml | 117 ++ .../res/layout/list_item_teams_client.xml | 87 + .../layout/list_item_upload_attachment.xml | 54 + app/src/main/res/layout/list_item_webhook.xml | 38 + .../res/layout/participants_header_item.xml | 28 + .../res/layout/participants_list_item.xml | 57 + app/src/main/res/layout/remote_video_view.xml | 53 + app/src/main/res/menu/messaging_menu.xml | 9 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5829 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5829 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3335 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3335 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 8114 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 8114 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 15343 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 15343 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 20701 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 20701 bytes .../main/res/raw/notification_oneone_call.mp3 | Bin 0 -> 201120 bytes app/src/main/res/raw/ring_back.mp3 | Bin 0 -> 163200 bytes app/src/main/res/values/colors.xml | 39 + app/src/main/res/values/dimens.xml | 38 + app/src/main/res/values/integers.xml | 10 + app/src/main/res/values/strings.xml | 293 +++ app/src/main/res/values/styles.xml | 30 + app/src/main/res/xml/file_paths.xml | 4 + .../androidsdk/kitchensink/ExampleUnitTest.kt | 17 + build.gradle | 37 + buildSrc/.gitignore | 1 + buildSrc/build.gradle.kts | 9 + gradle.properties | 25 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 172 ++ gradlew.bat | 84 + settings.gradle | 2 + 315 files changed, 23395 insertions(+), 1 deletion(-) create mode 100644 app/.gitignore create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/release/output-metadata.json create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/HomeActivityTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/SearchActivityTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivityTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragmentTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/PostMessageTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/members/MembershipFragmentTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragmentTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SpaceMessageDetailsFragmentTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragmentTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamFragmentTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamMembershipFragmentTest.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/RecyclerViewMatchers.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/WaitUtils.kt create mode 100644 app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/assets/cisco.png create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/AppModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/HomeActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/JWTWebexModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkApp.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/OAuthWebexModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/JWTLoginActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/OAuthWebLoginActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialerActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/IncomingCallInfoModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/MeetingInfoModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/OneToOneIncomingCallModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/RingerManager.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/SpaceIncomingCallModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsAdapter.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/cucm/UCLoginActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/Data.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/FCMPushModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/KitchenSinkFCMService.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/PushRestPayloadModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/RegisterTokenService.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/BaseDialogFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/RemoteModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MentionsAutoCompleteEditText.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/MessagingSearchActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/AddPersonBottomSheetFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/ReplyMessageModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceActionBottomSheetFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMeetingInfo.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMessageModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceReadStatusModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpaceReadStatusClientAdapter.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpacesClientAdapter.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/FileViewerActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageActionBottomSheetFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageDetailsDialogFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/SpaceMembershipActionBottomSheetFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamActionBottomSheetFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActionBottomSheetFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleActionBottomSheetFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonDialogFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchCommonFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchViewModel.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/setup/SetupActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/AudioManagerUtils.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Base64Utils.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/BindingAdapters.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/CallObjectStorage.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Constants.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Crypto.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/DialogUtils.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/FileUtils.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/HorizontalFlipTransformation.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/PermissionsHelper.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/SharedPrefUtils.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/StringExtension.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/ViewExtension.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookActionBottomSheetFragment.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksActivity.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksModule.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksRepository.kt create mode 100644 app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksViewModel.kt create mode 100644 app/src/main/res/anim/calling_animation.xml create mode 100644 app/src/main/res/anim/cycle_animation.xml create mode 100644 app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/app_notification_icon.png create mode 100644 app/src/main/res/drawable/audio_button.xml create mode 100644 app/src/main/res/drawable/audio_mute_active.xml create mode 100644 app/src/main/res/drawable/audio_mute_default.xml create mode 100644 app/src/main/res/drawable/audio_mute_disable.xml create mode 100644 app/src/main/res/drawable/bg_gray_border.xml create mode 100644 app/src/main/res/drawable/circle_filled_blue.xml create mode 100644 app/src/main/res/drawable/circle_filled_dark_gray.xml create mode 100644 app/src/main/res/drawable/circle_filled_deep_yellow.xml create mode 100644 app/src/main/res/drawable/circle_filled_gray.xml create mode 100644 app/src/main/res/drawable/circle_filled_green.xml create mode 100644 app/src/main/res/drawable/circle_filled_red.xml create mode 100644 app/src/main/res/drawable/circle_filled_yellow.xml create mode 100644 app/src/main/res/drawable/dialog_bg.xml create mode 100644 app/src/main/res/drawable/edit_text_bg.xml create mode 100644 app/src/main/res/drawable/google_contacts_android.png create mode 100644 app/src/main/res/drawable/ic_add.xml create mode 100644 app/src/main/res/drawable/ic_arrow_24.xml create mode 100644 app/src/main/res/drawable/ic_attachment_cancel.xml create mode 100644 app/src/main/res/drawable/ic_backspace.xml create mode 100644 app/src/main/res/drawable/ic_baseline_add_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_close_24.xml create mode 100644 app/src/main/res/drawable/ic_btn_camera_swap_40.xml create mode 100644 app/src/main/res/drawable/ic_call.xml create mode 100644 app/src/main/res/drawable/ic_call_hold.xml create mode 100644 app/src/main/res/drawable/ic_call_merge_24.xml create mode 100644 app/src/main/res/drawable/ic_call_transfer.xml create mode 100644 app/src/main/res/drawable/ic_call_waiting.xml create mode 100644 app/src/main/res/drawable/ic_call_white.xml create mode 100644 app/src/main/res/drawable/ic_camera_24.xml create mode 100644 app/src/main/res/drawable/ic_cancel.xml create mode 100644 app/src/main/res/drawable/ic_cisco_gray_logo.png create mode 100644 app/src/main/res/drawable/ic_dialpad.xml create mode 100644 app/src/main/res/drawable/ic_feeback.xml create mode 100644 app/src/main/res/drawable/ic_incoming_call_legacy_36.xml create mode 100644 app/src/main/res/drawable/ic_keyboard.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_login.xml create mode 100644 app/src/main/res/drawable/ic_logout.xml create mode 100644 app/src/main/res/drawable/ic_menu.xml create mode 100644 app/src/main/res/drawable/ic_message.xml create mode 100644 app/src/main/res/drawable/ic_mic_24.xml create mode 100644 app/src/main/res/drawable/ic_mic_off_24.xml create mode 100644 app/src/main/res/drawable/ic_more.xml create mode 100644 app/src/main/res/drawable/ic_outgoing_call.xml create mode 100644 app/src/main/res/drawable/ic_participant_add.xml create mode 100644 app/src/main/res/drawable/ic_participant_list.xml create mode 100644 app/src/main/res/drawable/ic_people_24.xml create mode 100644 app/src/main/res/drawable/ic_person.xml create mode 100644 app/src/main/res/drawable/ic_remote_end.xml create mode 100644 app/src/main/res/drawable/ic_remote_mute.xml create mode 100644 app/src/main/res/drawable/ic_reply.xml create mode 100644 app/src/main/res/drawable/ic_screen_share.xml create mode 100644 app/src/main/res/drawable/ic_screen_sharing.xml create mode 100644 app/src/main/res/drawable/ic_speaker.xml create mode 100644 app/src/main/res/drawable/ic_turn_off_video.xml create mode 100644 app/src/main/res/drawable/remote_video_view_border.xml create mode 100644 app/src/main/res/drawable/screen_sharing_active.xml create mode 100644 app/src/main/res/drawable/screen_sharing_default.xml create mode 100644 app/src/main/res/drawable/speaker_button_selector.xml create mode 100644 app/src/main/res/drawable/surfaceview_border.xml create mode 100644 app/src/main/res/drawable/surfaceview_transparent_border.xml create mode 100644 app/src/main/res/drawable/turn_off_video_active.xml create mode 100644 app/src/main/res/drawable/turn_on_video_default.xml create mode 100644 app/src/main/res/drawable/webex_teams_logo.png create mode 100644 app/src/main/res/layout/activity_call.xml create mode 100644 app/src/main/res/layout/activity_cucm_login.xml create mode 100644 app/src/main/res/layout/activity_dialer.xml create mode 100644 app/src/main/res/layout/activity_extras.xml create mode 100644 app/src/main/res/layout/activity_file_viewer.xml create mode 100644 app/src/main/res/layout/activity_home.xml create mode 100644 app/src/main/res/layout/activity_login.xml create mode 100644 app/src/main/res/layout/activity_login_with_token.xml create mode 100644 app/src/main/res/layout/activity_membership.xml create mode 100644 app/src/main/res/layout/activity_membership_read_status.xml create mode 100644 app/src/main/res/layout/activity_message_composer.xml create mode 100644 app/src/main/res/layout/activity_messaging.xml create mode 100644 app/src/main/res/layout/activity_oauth.xml create mode 100644 app/src/main/res/layout/activity_search.xml create mode 100644 app/src/main/res/layout/activity_search_messaging.xml create mode 100644 app/src/main/res/layout/activity_setup.xml create mode 100644 app/src/main/res/layout/activity_space_detail.xml create mode 100644 app/src/main/res/layout/activity_space_read_status_detail.xml create mode 100644 app/src/main/res/layout/activity_team_detail.xml create mode 100644 app/src/main/res/layout/activity_webhooks.xml create mode 100644 app/src/main/res/layout/bottom_sheet_add_member_options.xml create mode 100644 app/src/main/res/layout/bottom_sheet_call_options.xml create mode 100644 app/src/main/res/layout/bottom_sheet_message_options.xml create mode 100644 app/src/main/res/layout/bottom_sheet_people_options.xml create mode 100644 app/src/main/res/layout/bottom_sheet_space_member_options.xml create mode 100644 app/src/main/res/layout/bottom_sheet_space_options.xml create mode 100644 app/src/main/res/layout/bottom_sheet_team_member_options.xml create mode 100644 app/src/main/res/layout/bottom_sheet_team_options.xml create mode 100644 app/src/main/res/layout/bottom_sheet_webhook_action.xml create mode 100644 app/src/main/res/layout/common_fragment_item_list.xml create mode 100644 app/src/main/res/layout/dialog_create_space.xml create mode 100644 app/src/main/res/layout/dialog_enter_meeting_pin.xml create mode 100644 app/src/main/res/layout/dialog_membership_details.xml create mode 100644 app/src/main/res/layout/dialog_message_details.xml create mode 100644 app/src/main/res/layout/dialog_post_message_handler.xml create mode 100644 app/src/main/res/layout/dialog_team_membership_details.xml create mode 100644 app/src/main/res/layout/dialog_uclogin_nonsso.xml create mode 100644 app/src/main/res/layout/dialog_uclogin_settings.xml create mode 100644 app/src/main/res/layout/dialog_webhook_create.xml create mode 100644 app/src/main/res/layout/dialog_webhook_update.xml create mode 100644 app/src/main/res/layout/fragment_call.xml create mode 100644 app/src/main/res/layout/fragment_call_controls.xml create mode 100644 app/src/main/res/layout/fragment_common.xml create mode 100644 app/src/main/res/layout/fragment_dialog_person.xml create mode 100644 app/src/main/res/layout/fragment_dialog_webhook_details.xml create mode 100644 app/src/main/res/layout/fragment_membership.xml create mode 100644 app/src/main/res/layout/fragment_membership_read_status.xml create mode 100644 app/src/main/res/layout/fragment_participants.xml create mode 100644 app/src/main/res/layout/fragment_person.xml create mode 100644 app/src/main/res/layout/fragment_spaces.xml create mode 100644 app/src/main/res/layout/fragment_teams.xml create mode 100644 app/src/main/res/layout/list_item_attachments.xml create mode 100644 app/src/main/res/layout/list_item_call_meeting.xml create mode 100644 app/src/main/res/layout/list_item_membership_client.xml create mode 100644 app/src/main/res/layout/list_item_membership_read_status.xml create mode 100644 app/src/main/res/layout/list_item_mention.xml create mode 100644 app/src/main/res/layout/list_item_person.xml create mode 100644 app/src/main/res/layout/list_item_persons.xml create mode 100644 app/src/main/res/layout/list_item_space_message.xml create mode 100644 app/src/main/res/layout/list_item_spaces_client.xml create mode 100644 app/src/main/res/layout/list_item_spaces_read_client.xml create mode 100644 app/src/main/res/layout/list_item_team_membership_client.xml create mode 100644 app/src/main/res/layout/list_item_teams_client.xml create mode 100644 app/src/main/res/layout/list_item_upload_attachment.xml create mode 100644 app/src/main/res/layout/list_item_webhook.xml create mode 100644 app/src/main/res/layout/participants_header_item.xml create mode 100644 app/src/main/res/layout/participants_list_item.xml create mode 100644 app/src/main/res/layout/remote_video_view.xml create mode 100644 app/src/main/res/menu/messaging_menu.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/raw/notification_oneone_call.mp3 create mode 100644 app/src/main/res/raw/ring_back.mp3 create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/integers.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/java/com/ciscowebex/androidsdk/kitchensink/ExampleUnitTest.kt create mode 100644 build.gradle create mode 100644 buildSrc/.gitignore create mode 100644 buildSrc/build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/README.md b/README.md index 265608b..b843c19 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ This demo support Android device with **Android 7.0** or later ## Usage -For example see [README.md](https://github.com/webex/webex-android-sdk/README.md) +For example see [README](https://github.com/webex/webex-android-sdk/blob/master/README.md) ## Note diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..1500b40 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,86 @@ +import com.ciscowebex.androidsdk.build.* + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.gms.google-services' // Google Services plugin +apply plugin: 'com.google.firebase.crashlytics' // Crashlytics Gradle plugin + +android { + compileSdkVersion Versions.compileSdk + buildToolsVersion Versions.buildTools + ndkVersion project.hasProperty("ndkVersion") ? project.property('ndkVersion') : Versions.ndkVersion + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + defaultConfig { + applicationId "com.cisco.sdk_android" + minSdkVersion Versions.minSdk + targetSdkVersion Versions.targetSdk + versionCode 30001 + versionName "3.0.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + buildConfigField "String", "CLIENT_ID", "${CLIENT_ID}" + buildConfigField "String", "CLIENT_SECRET", "${CLIENT_SECRET}" + buildConfigField "String", "REDIRECT_URI", "${REDIRECT_URI}" + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } + buildFeatures { + dataBinding true + } +} + +dependencies { + implementation 'com.ciscowebex:androidsdk:3.0.0@aar' + + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation Dependencies.kotlinStdLib + implementation Dependencies.coreKtx + implementation Dependencies.appCompat + implementation Dependencies.constraintLayout + implementation Dependencies.material + implementation Dependencies.recyclerview + implementation Dependencies.cardview + implementation Dependencies.viewpager2 + implementation Dependencies.koin + implementation Dependencies.koinViewModel + implementation Dependencies.swiperefresh + implementation Dependencies.media + implementation Dependencies.nimbusJosh + + // RXJAVA + implementation Dependencies.rxjava + implementation Dependencies.rxandroid + implementation Dependencies.rxkotlin + + testImplementation Dependencies.Test.junit + androidTestImplementation Dependencies.Test.androidxJunit + androidTestImplementation Dependencies.Test.espressoCore + androidTestImplementation Dependencies.Test.espressoContrib + androidTestImplementation Dependencies.Test.espressoWeb + androidTestImplementation Dependencies.Test.espressoIntents + androidTestImplementation Dependencies.Test.rules + androidTestImplementation Dependencies.Test.testExt + debugImplementation (Dependencies.Test.fragmentScenerio) { + exclude group: 'androidx.test', module: 'monitor' + } + implementation platform(Dependencies.firebaseBom) + implementation Dependencies.firebaseMessaging + implementation Dependencies.firebaseAnalytics + implementation Dependencies.firebaseCrashlytics + implementation Dependencies.gson + +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..706addc --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,18 @@ +{ + "version": 2, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.cisco.sdk_android", + "variantName": "processReleaseResources", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "versionCode": 30001, + "versionName": "3.0.0", + "outputFile": "app-release.apk" + } + ] +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/HomeActivityTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/HomeActivityTest.kt new file mode 100644 index 0000000..ba4565a --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/HomeActivityTest.kt @@ -0,0 +1,105 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.content.Intent +import android.net.Uri +import android.view.View +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.* +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.rule.GrantPermissionRule +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import com.ciscowebex.androidsdk.kitchensink.search.SearchActivity +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.hamcrest.Matchers.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.TimeUnit + + +class HomeActivityTest : KitchenSinkTest() { + + @Rule @JvmField + val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.CAMERA, + android.Manifest.permission.RECORD_AUDIO, + android.Manifest.permission.READ_PHONE_STATE + ) + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testInitiateCallButton_homeActivity() { + clickOnView(R.id.iv_startCall) + intended(hasComponent(SearchActivity::class.java.name)) + } + + @Test + fun testWaitingCallButton_homeActivity() { + clickOnView(R.id.iv_waitingCall) + intended(hasComponent(CallActivity::class.java.name)) + } + + @Test + fun testFeedbackButton_homeActivity() { + clickOnView(R.id.iv_feedback) + val subject = targetContext.getString(R.string.feedbackLogsSubject) + + val expectedIntent = allOf( + hasAction(Intent.ACTION_CHOOSER), + hasExtra( + equalTo(Intent.EXTRA_INTENT), + allOf( + hasAction(Intent.ACTION_SENDTO), + hasData(Uri.parse("mailto:")), + hasExtra( + `is`(Intent.EXTRA_SUBJECT), + `is`(subject) + ) + ) + ) + ) + + intended(expectedIntent) + + } + + @Test + fun testLogoutButton_homeActivity() { + clickOnView(R.id.iv_logout) + assertViewDisplayed(R.id.progressLayout) + WaitUtils.waitForCondition( + 60, TimeUnit.SECONDS, + { + val rootLoginActivity = getActivity()?.findViewById(R.id.rootLoginActivity) + rootLoginActivity != null + }, + { + "$TAG:: Not able to find rootLoginActivity" + } + ) + } + + @Test + fun testMessageButton_homeActivity() { + clickOnView(R.id.iv_messaging) + intended(hasComponent(MessagingActivity::class.java.name)) + } + + @Test + fun testGetMeButton_homeActivity() { + clickOnView(R.id.iv_getMe) + onView(withId(R.id.dialogOk)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt new file mode 100644 index 0000000..2f25e77 --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt @@ -0,0 +1,288 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.app.Activity +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.annotation.IdRes +import androidx.annotation.NonNull +import androidx.annotation.StringRes +import androidx.lifecycle.Lifecycle +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.PerformException +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.espresso.web.sugar.Web +import androidx.test.espresso.web.webdriver.DriverAtoms +import androidx.test.espresso.web.webdriver.Locator +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ciscowebex.androidsdk.kitchensink.auth.LoginActivity +import com.ciscowebex.androidsdk.kitchensink.utils.RecyclerViewMatchers.withRecyclerView +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils.waitForCondition +import com.google.android.material.tabs.TabLayout +import org.hamcrest.Matcher +import org.hamcrest.Matchers.* +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.rules.Timeout +import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +abstract class KitchenSinkTest { + + val TAG = KitchenSinkTest::class.java.simpleName + val TIME_1_SEC: Long = 1000 + + val targetContext: Context by lazy { + InstrumentationRegistry.getInstrumentation().targetContext as Context + } + + @get: Rule + val activityRule = ActivityScenarioRule(LoginActivity::class.java) + + @Rule + @JvmField + var teamsTestTimeout: Timeout = Timeout(5, TimeUnit.MINUTES) + + @Before + open fun initTests() { + Intents.init() + } + + @After + fun releaseIntents(){ + Intents.release() + } + + fun setUpLogin() { + val testEmail = "xeiotulvlhijlxtrmw@awdrt.com" + val testPassword = "Test1234" + + activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + + assertViewDisplayed(R.id.btn_oauth_login) + clickOnView(R.id.btn_oauth_login) + + WaitUtils.sleep(2000) + + if (getActivity() is LoginActivity) { + assertViewDisplayed(R.id.loginWebview) + + Web.onWebView().forceJavascriptEnabled() + + WaitUtils.waitElementToAppear("IDToken1") + Web.onWebView(withId(R.id.loginWebview)).withElement(DriverAtoms.findElement(Locator.ID, "IDToken1")) + + // Clear previous input + .perform(DriverAtoms.clearElement()) + // Enter text into the input element + .perform(DriverAtoms.webKeys(testEmail)) + WaitUtils.waitElementToAppear("IDButton2") + Web.onWebView(withId(R.id.loginWebview)).withElement(DriverAtoms.findElement(Locator.ID, "IDButton2")).perform(DriverAtoms.webClick()) + WaitUtils.waitElementToAppear("IDToken2") + Web.onWebView(withId(R.id.loginWebview)).withElement(DriverAtoms.findElement(Locator.ID, "IDToken2")) + .perform(DriverAtoms.clearElement()) + .perform(DriverAtoms.webKeys(testPassword)) + + WaitUtils.waitElementToAppear("Button1") + Web.onWebView(withId(R.id.loginWebview)).withElement(DriverAtoms.findElement(Locator.ID, "Button1")).perform(DriverAtoms.webClick()) + } + + waitForCondition( + 60, TimeUnit.SECONDS, + { + val homeActivityRootView = getActivity()?.findViewById(R.id.rootHomeActivity) + homeActivityRootView != null + }, + { + "$TAG:: Not able to find homeActivityRootView" + } + ) + + activityRule.scenario.close() + } + + protected fun typeTextAction(@IdRes viewId: Int, textToType: String = "Summer is good") { + onView(withId(viewId)) + .check(matches(isDisplayed())) + .perform(typeText(textToType)) + closeSoftKeyboard() + } + + protected fun assertView(@StringRes textOnView: Int) { + onView(withText(textOnView)).check(matches(isDisplayed())) + } + + protected fun assertView(@IdRes viewId: Int, @StringRes textOnView: Int) { + onView(allOf(withId(viewId), withText(textOnView))).check(matches(isDisplayed())) + } + + protected fun assertViewDisplayed(@IdRes viewId: Int) { + onView(withId(viewId)).check(matches(isDisplayed())) + } + + protected fun assertViewNotDisplayed(@IdRes viewId: Int) { + onView(withId(viewId)).check(matches(not(isDisplayed()))) + } + + protected fun assertViewWithContentDescription(@IdRes viewId: Int, @StringRes stringId: Int) { + onView(allOf(withId(viewId), withContentDescription(stringId))) + } + + protected fun clickOnView(@IdRes viewId: Int) { + onView(withId(viewId)).perform(click()) + } + + protected fun clickOnViewWithText(@StringRes stringId: Int) { + onView(withText(stringId)).perform(click()) + } + + protected fun longClickOnView(@IdRes viewId: Int) { + onView(withId(viewId)).perform(longClick()) + } + + protected fun assertViewExists(@IdRes viewId: Int) { + onView(allOf(withId(viewId))).check(matches(isDisplayed())) + } + + protected fun assertViewWithText(@IdRes viewId: Int, @NonNull textOnView: String) { + onView(allOf(withId(viewId), withText(containsString(textOnView)))).check(matches(isDisplayed())) + } + + protected fun assertViewWithText(@IdRes viewId: Int, @NonNull @StringRes stringId: Int) { + onView(allOf(withId(viewId), withText(stringId))).check(matches(isDisplayed())) + } + + protected fun getResourceName(@IdRes id: Int): String { + return targetContext.resources.getResourceName(id) + } + + protected fun selectTab(tabIndex: Int): ViewAction { + return object : ViewAction { + override fun getDescription() = "with tab at index $tabIndex" + + override fun getConstraints() = allOf(isDisplayed(), isAssignableFrom(TabLayout::class.java)) + + override fun perform(uiController: UiController, view: View) { + val tabLayout = view as TabLayout + val tabAtIndex: TabLayout.Tab = tabLayout.getTabAt(tabIndex) + ?: throw PerformException.Builder() + .withCause(Throwable("No tab at index $tabIndex")) + .build() + + tabAtIndex.select() + } + } + } + + fun getActivity(): Activity? { + val activity = arrayOfNulls(1) + onView(isRoot()).check { view, _ -> + var checkedView = view + while (checkedView is ViewGroup && checkedView.childCount > 0) { + checkedView = checkedView.getChildAt(0) + if (checkedView.context is Activity) { + activity[0] = checkedView.context as Activity + break + } + } + } + return activity[0] + } + + protected fun longClickOnListItem(@IdRes viewId: Int, itemPosition: Int, @IdRes targetViewId: Int? = null) { + if (targetViewId == null) { + onView(withRecyclerView(viewId).atPosition(itemPosition)) + } else { + onView(withRecyclerView(viewId).atPositionOnView(itemPosition, targetViewId)) + }.check(matches(isDisplayed())).perform(longClick()) + } + + protected fun clickChildViewWithId(id: Int): ViewAction? { + return object : ViewAction { + override fun getConstraints(): Matcher { + return isDisplayed() + } + + override fun getDescription(): String { + return "Click on a child view with specified id." + } + + override fun perform(uiController: UiController, view: View) { + val v = view.findViewById(id) + v.performClick() + } + } + } + + protected fun longclickChildViewWithId(id: Int): ViewAction? { + return object : ViewAction { + override fun getConstraints(): Matcher { + return isDisplayed() + } + + override fun getDescription(): String { + return "Click on a child view with specified id." + } + + override fun perform(uiController: UiController, view: View) { + val v = view.findViewById(id) + v.performLongClick() + } + } + } + + protected fun withCustomConstraints(action: ViewAction, constraints: Matcher): ViewAction? { + return object : ViewAction { + override fun getConstraints(): Matcher { + return constraints + } + + override fun getDescription(): String { + return action.description + } + + override fun perform(uiController: UiController, view: View) { + action.perform(uiController, view) + } + } + } + + protected fun ScrollToBottomAction(): ViewAction? { + return object : ViewAction { + override fun getDescription(): String { + return "scroll RecyclerView to bottom" + } + + override fun getConstraints(): Matcher { + return allOf(isAssignableFrom(RecyclerView::class.java), isDisplayed()) + } + + override fun perform(uiController: UiController?, view: View?) { + val recyclerView = view as RecyclerView + val itemCount = recyclerView.adapter?.itemCount + val position = itemCount?.minus(1) ?: 0 + recyclerView.scrollToPosition(position) + uiController?.loopMainThreadUntilIdle() + } + } + } + + protected fun clickOnItemInRecyclerView(@IdRes viewId: Int, itemPosition: Int, @IdRes targetViewId: Int? = null) { + if (targetViewId == null) { + onView(withRecyclerView(viewId).atPosition(itemPosition)) + } else { + onView(withRecyclerView(viewId).atPositionOnView(itemPosition, targetViewId)) + }.check(matches(isDisplayed())).perform(click()) + } +} diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/SearchActivityTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/SearchActivityTest.kt new file mode 100644 index 0000000..b2d8d7e --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/SearchActivityTest.kt @@ -0,0 +1,43 @@ +package com.ciscowebex.androidsdk.kitchensink + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers +import com.ciscowebex.androidsdk.kitchensink.search.SearchCommonFragment +import org.junit.Before +import org.junit.Test + + +class SearchActivityTest : KitchenSinkTest() { + + companion object{ + const val CALL_HISTORY_TAB_INDEX = 2 + const val SPACES_TAB_INDEX = 3 + } + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testCallHistory_searchActivity() { + intended(IntentMatchers.hasComponent(HomeActivity::class.java.name)) + clickOnView(R.id.iv_startCall) + selectTab(CALL_HISTORY_TAB_INDEX) + launchFragmentInContainer() + assertViewDisplayed(R.id.recycler_view) + } + + @Test + fun testSpaces_searchActivity(){ + intended(IntentMatchers.hasComponent(HomeActivity::class.java.name)) + clickOnView(R.id.iv_startCall) + selectTab(SPACES_TAB_INDEX) + launchFragmentInContainer() + assertViewDisplayed(R.id.recycler_view) + } + + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivityTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivityTest.kt new file mode 100644 index 0000000..ab98a0f --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivityTest.kt @@ -0,0 +1,18 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import androidx.test.filters.LargeTest +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import org.junit.Test +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4ClassRunner::class) +@LargeTest +open class LoginActivityTest : KitchenSinkTest() { + + @Test + fun testWebExLogin_LoginActivity() { + setUpLogin() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragmentTest.kt new file mode 100644 index 0000000..fb7f40f --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragmentTest.kt @@ -0,0 +1,85 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.hamcrest.CoreMatchers.allOf +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DialFragmentTest : KitchenSinkTest() { + + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testDialKeys() { + launchFragmentInContainer() + // Dial "0123456789" + clickOnView(R.id.tv_number_0) + clickOnView(R.id.tv_number_1) + clickOnView(R.id.tv_number_2) + clickOnView(R.id.tv_number_3) + clickOnView(R.id.tv_number_4) + clickOnView(R.id.tv_number_5) + clickOnView(R.id.tv_number_6) + clickOnView(R.id.tv_number_7) + clickOnView(R.id.tv_number_8) + clickOnView(R.id.tv_number_9) + longClickOnView(R.id.tv_number_0) + clickOnView(R.id.tv_number_star) + clickOnView(R.id.tv_number_hash) + // Check the dialed value + assertViewWithText(R.id.et_dial_input, "0123456789+*#") + } + + @Test + fun testBackPress() { + launchFragmentInContainer() + for (i in 1..5) { + clickOnView(R.id.tv_number_0) + } + assertViewWithText(R.id.et_dial_input, "00000") + clickOnView(R.id.ib_backspace) + assertViewWithText(R.id.et_dial_input, "0000") + longClickOnView(R.id.ib_backspace) + assertViewWithText(R.id.et_dial_input, "") + } + + @Test + fun testToggleKeypadAndDialpad() { + launchFragmentInContainer() + assertViewDisplayed(R.id.ib_keypad_toggle) + clickOnView(R.id.ib_keypad_toggle) + + assertViewNotDisplayed(R.id.ib_keypad_toggle) + assertViewDisplayed(R.id.ib_numpad_toggle) + assertViewNotDisplayed(R.id.dial_buttons_container) + + clickOnView(R.id.ib_numpad_toggle) + + assertViewNotDisplayed(R.id.ib_numpad_toggle) + assertViewDisplayed(R.id.ib_keypad_toggle) + assertViewDisplayed(R.id.dial_buttons_container) + } + + @Test + fun testCallButton() { + launchFragmentInContainer() + for (i in 1..5) { + clickOnView(R.id.tv_number_1) + } + clickOnView(R.id.ib_startCall) + intended(allOf(hasComponent(CallActivity::class.java.name), + hasExtra(Constants.Intent.OUTGOING_CALL_CALLER_ID, "11111"))) + + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/PostMessageTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/PostMessageTest.kt new file mode 100644 index 0000000..46d388a --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/PostMessageTest.kt @@ -0,0 +1,168 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + + +import android.view.View +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.BoundedMatcher +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.hasMinimumChildCount +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.adapters.SpacesClientViewHolder +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail.SpaceDetailActivity +import com.ciscowebex.androidsdk.kitchensink.person.PeopleClientViewHolder +import com.ciscowebex.androidsdk.kitchensink.person.PeopleFragment +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.IOException +import java.util.Random + + +@RunWith(AndroidJUnit4ClassRunner::class) +class PostMessageTest : KitchenSinkTest() { + var min = 0 + var max = 100 + + var random = Random() + private var testMessage = "Hello Test Message " + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + val number = random.nextInt(max - min + 1) + min + testMessage += number + } + + @Test + fun postMessageBySpaceId() { + launchFragmentInContainer(null, R.style.AppTheme) + assertViewDisplayed(R.id.spacesRecyclerView) + onView(withId(R.id.spacesRecyclerView)).check(matches(hasMinimumChildCount(1))) + onView(allOf(withId(R.id.spacesRecyclerView))).perform(RecyclerViewActions.actionOnItemAtPosition(0, clickChildViewWithId(R.id.spaceTitleTextView))) + Intents.intended(IntentMatchers.hasComponent(SpaceDetailActivity::class.java.name)) + onView(withId(R.id.postMessageFAB)).check(matches(isDisplayed())).perform(clickChildViewWithId(R.id.postMessageFAB)) + WaitUtils.sleep(1000) + Intents.intended(IntentMatchers.hasComponent(MessageComposerActivity::class.java.name)) + onView(withId(R.id.message)).check(matches(isDisplayed())).perform(typeText(testMessage), closeSoftKeyboard()) + assertViewDisplayed(R.id.sendButton) + clickOnView(R.id.sendButton) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootPostMessageDetailDialog) + } + + private fun peopleFragmentBottomSheet() { + launchFragmentInContainer(null, R.style.AppTheme) + WaitUtils.sleep(2000) + assertViewDisplayed(R.id.recycler_view) + assertViewDisplayed(R.id.search_view) + onView(withId(R.id.recycler_view)).check(matches(hasMinimumChildCount(1))) + onView(allOf(withId(R.id.recycler_view))).perform(RecyclerViewActions.actionOnItemAtPosition(0, longclickChildViewWithId(R.id.personClientLayout))) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.peopleOptionsBottomSheet) + } + + @Test + fun fetchPersonDetailByID() { + peopleFragmentBottomSheet() + assertViewDisplayed(R.id.fetchPersonByID) + clickOnView(R.id.fetchPersonByID) + WaitUtils.sleep(2000) + assertViewDisplayed(R.id.rootPersonDetailDialog) + } + + @Test + fun postMessageByPersonID() { + peopleFragmentBottomSheet() + assertViewDisplayed(R.id.postMessageByID) + clickOnView(R.id.postMessageByID) + WaitUtils.sleep(1000) + Intents.intended(IntentMatchers.hasComponent(MessageComposerActivity::class.java.name)) + onView(withId(R.id.message)).check(matches(isDisplayed())).perform(typeText(testMessage), closeSoftKeyboard()) + assertViewDisplayed(R.id.sendButton) + clickOnView(R.id.sendButton) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootPostMessageDetailDialog) + } + + @Test + fun postMessageByPersonEmail() { + peopleFragmentBottomSheet() + assertViewDisplayed(R.id.postMessageByEmail) + clickOnView(R.id.postMessageByEmail) + WaitUtils.sleep(1000) + Intents.intended(IntentMatchers.hasComponent(MessageComposerActivity::class.java.name)) + onView(withId(R.id.message)).check(matches(isDisplayed())).perform(typeText(testMessage), closeSoftKeyboard()) + assertViewDisplayed(R.id.sendButton) + clickOnView(R.id.sendButton) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootPostMessageDetailDialog) + } + + @Throws(IOException::class) + private fun getFileFromAssets(fileName: String): File = File(targetContext.cacheDir, fileName) + .also { + if (!it.exists()) { + it.outputStream().use { cache -> + targetContext.resources.assets.open(fileName).use { inputStream -> + inputStream.copyTo(cache) + } + } + } + } + + private fun addItemToRecyclerView(file: File): Matcher { + return object : BoundedMatcher(RecyclerView::class.java) { + override fun describeTo(description: Description?) { + } + + override fun matchesSafely(item: RecyclerView?): Boolean { + item?.adapter?.let { + val adapter = it as MessageComposerActivity.UploadAttachmentsAdapter + adapter.attachedFiles.add(file) + adapter.notifyDataSetChanged() + return true + } + + return false + } + } + } + + @Test + fun sendContentByPersonEmail() { + peopleFragmentBottomSheet() + assertViewDisplayed(R.id.postMessageByEmail) + clickOnView(R.id.postMessageByEmail) + WaitUtils.sleep(1000) + Intents.intended(IntentMatchers.hasComponent(MessageComposerActivity::class.java.name)) + onView(withId(R.id.message)).check(matches(isDisplayed())).perform(typeText(testMessage), closeSoftKeyboard()) + val filePath = getFileFromAssets("cisco.png").absolutePath + val file = File(filePath) + val exist = File(filePath).exists() + onView(withId(R.id.attachment_recycler_view)).check(matches(addItemToRecyclerView(file))) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.sendButton) + clickOnView(R.id.sendButton) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootPostMessageDetailDialog) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/members/MembershipFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/members/MembershipFragmentTest.kt new file mode 100644 index 0000000..d5916e1 --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/members/MembershipFragmentTest.kt @@ -0,0 +1,38 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.members + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.swipeLeft +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4ClassRunner::class) +class MembershipFragmentTest: KitchenSinkTest() { + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun membershipTest_membershipFragmentTest(){ + clickOnView(R.id.iv_messaging) + Intents.intended(IntentMatchers.hasComponent(MessagingActivity::class.java.name)) + + onView(withId(R.id.view_pager)).perform(swipeLeft()) + onView(withId(R.id.view_pager)).perform(swipeLeft()) + onView(withId(R.id.view_pager)).perform(swipeLeft()) + + assertViewDisplayed(R.id.membershipsRecyclerView) + onView(withId(R.id.membershipsRecyclerView)).check(matches(hasDescendant(withId(R.id.membershipPersonDisplayNameTextView)))) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragmentTest.kt new file mode 100644 index 0000000..746da7c --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragmentTest.kt @@ -0,0 +1,41 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4ClassRunner::class) +class SearchPeopleFragmentTest : KitchenSinkTest() { + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testSearchPeopleByName() { + launchFragmentInContainer() + assertViewDisplayed(R.id.search_view) + typeTextAction(R.id.search_view, "rohit sharma") + onView(withId(R.id.recycler_view)) + .check(matches(hasDescendant(withText("Rohit Sharma")))) + } + + + @Test + fun testSearchPeopleByEmailId() { + launchFragmentInContainer() + assertViewDisplayed(R.id.search_view) + typeTextAction(R.id.search_view, "webextestac@gmail.com") + onView(withId(R.id.recycler_view)) + .check(matches(hasDescendant(withText("Rohit Sharma")))) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SpaceMessageDetailsFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SpaceMessageDetailsFragmentTest.kt new file mode 100644 index 0000000..c2589c6 --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SpaceMessageDetailsFragmentTest.kt @@ -0,0 +1,47 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.swipeDown +import androidx.test.espresso.action.ViewActions.swipeLeft +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers.* +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.junit.Before +import org.junit.Test + + +class SpaceMessageDetailsFragmentTest : KitchenSinkTest() { + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testTeamMembershipList_spaceMessageDetailsFragment() { + gotoSpaces() + + clickOnItemInRecyclerView(R.id.spacesRecyclerView, 1) + + WaitUtils.sleep(1000) + onView(withId(R.id.spaceMessageRecyclerView)) + .perform(swipeDown()) + WaitUtils.sleep(1000) + clickOnItemInRecyclerView(R.id.spaceMessageRecyclerView, 0) + + assertViewDisplayed(R.id.msgIdTextView) + } + + private fun gotoSpaces(){ + clickOnView(R.id.iv_messaging) + intended(hasComponent(MessagingActivity::class.java.name)) + + onView(withId(R.id.view_pager)) + .perform(swipeLeft()) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragmentTest.kt new file mode 100644 index 0000000..d0ee44d --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragmentTest.kt @@ -0,0 +1,52 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus.MembershipReadStatusActivity +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.junit.Before +import org.junit.Test + +class SpacesFragmentTest : KitchenSinkTest() { + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testDeleteSpace() { + launchFragmentInContainer(themeResId = R.style.Theme_AppCompat) + longClickOnListItem(R.id.spacesRecyclerView, 0) + WaitUtils.sleep(TIME_1_SEC) + onView(withId(R.id.deleteSpace)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + clickOnView(R.id.deleteSpace) + WaitUtils.sleep(TIME_1_SEC) + onView(withText(R.string.delete_space)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } + + @Test + fun testShowSpaceMembersWithReadStatus() { + launchFragmentInContainer(themeResId = R.style.Theme_AppCompat) + longClickOnListItem(R.id.spacesRecyclerView, 0) + WaitUtils.sleep(TIME_1_SEC) + onView(withId(R.id.showSpaceMembersWithReadStatus)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + clickOnView(R.id.showSpaceMembersWithReadStatus) + intended(hasComponent(MembershipReadStatusActivity::class.java.name)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamFragmentTest.kt new file mode 100644 index 0000000..f8ce36a --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamFragmentTest.kt @@ -0,0 +1,95 @@ +package com.ciscowebex.androidsdk.kitchensink.teams.membership + +import androidx.test.espresso.Espresso.closeSoftKeyboard +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withHint +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.search.MessagingSearchActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsClientViewHolder +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipActivity +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.random.Random + + +@RunWith(AndroidJUnit4ClassRunner::class) +class TeamFragmentTest : KitchenSinkTest() { + + val testTeamName = "Test Team" + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testTeamMembershipList_teamFragment() { + goToMessagingActivity() + longClickOnListItem(R.id.teamsRecyclerView, 0) + WaitUtils.sleep(TIME_1_SEC) + assertViewDisplayed(R.id.teamOptionsLabel) + clickOnView(R.id.getMembers) + + intended(hasComponent(TeamMembershipActivity::class.java.name)) + assertViewDisplayed(R.id.membershipsRecyclerView) + } + + private fun goToMessagingActivity() { + clickOnView(R.id.iv_messaging) + intended(hasComponent(MessagingActivity::class.java.name)) + } + + @Test + fun addTeamMember_teamFragment() { + goToMessagingActivity() + clickOnView(R.id.addTeamsFAB) + onView(withText(R.string.add_team)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + val testTeam = testTeamName + Random.nextInt(0, 1000) + onView(withHint(R.string.team_name_hint)).check(matches(isDisplayed())) + .perform(ViewActions.typeText(testTeam)) + closeSoftKeyboard() + clickOnViewWithText(android.R.string.ok) + + onView(withId(R.id.teamsRecyclerView)) + .perform(ViewActions.swipeDown()) + WaitUtils.sleep(TIME_1_SEC) + onView(withId(R.id.teamsRecyclerView)).check(matches(hasDescendant(withText(testTeam)))) + } + + @Test + fun addPersonToTeam_teamFragment(){ + goToMessagingActivity() + onView(withId(R.id.teamsRecyclerView)).perform(RecyclerViewActions.actionOnItemAtPosition(0, clickChildViewWithId(R.id.iv_add_to_team))) + WaitUtils.sleep(TIME_1_SEC) + intended(hasComponent(MessagingSearchActivity::class.java.name)) + } + + @Test + fun testBottomSheetOptions_teamsFragment(){ + goToMessagingActivity() + longClickOnListItem(R.id.teamsRecyclerView, 0) + assertViewDisplayed(R.id.getMembers) + assertViewDisplayed(R.id.editTeamName) + assertViewDisplayed(R.id.addSpaceFromTeam) + assertViewDisplayed(R.id.deleteTeam) + assertViewDisplayed(R.id.cancel) + } +} diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamMembershipFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamMembershipFragmentTest.kt new file mode 100644 index 0000000..91136bb --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamMembershipFragmentTest.kt @@ -0,0 +1,88 @@ +package com.ciscowebex.androidsdk.kitchensink.teams.membership + +import android.os.Bundle +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipFragment +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.junit.Before +import org.junit.Test + + +class TeamMembershipFragmentTest : KitchenSinkTest() { + var teamId = "1d3a4ec0-15b0-11eb-b0f2-7bf0c59106a7" + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testTeamMembershipList_homeActivity() { + clickOnView(R.id.iv_messaging) + + intended(hasComponent(MessagingActivity::class.java.name)) + longClickOnListItem(R.id.teamsRecyclerView, 0) + WaitUtils.sleep(1000) + + assertViewDisplayed(R.id.teamOptionsLabel) + clickOnView(R.id.getMembers) + + intended(hasComponent(TeamMembershipActivity::class.java.name)) + assertViewDisplayed(R.id.membershipsRecyclerView) + } + + @Test + fun testTeamMembershipDetails() { + clickOnView(R.id.iv_messaging) + + intended(hasComponent(MessagingActivity::class.java.name)) + longClickOnListItem(R.id.teamsRecyclerView, 0) + WaitUtils.sleep(1000) + + assertViewDisplayed(R.id.teamOptionsLabel) + clickOnView(R.id.getMembers) + + intended(hasComponent(TeamMembershipActivity::class.java.name)) + assertViewDisplayed(R.id.membershipsRecyclerView) + + longClickOnListItem(R.id.membershipsRecyclerView, 0) + WaitUtils.sleep(1000) + + + assertViewDisplayed(R.id.teamMemberActionOptionsLabel) + clickOnView(R.id.getMembershipDetails) + + assertViewDisplayed(R.id.rootMemberDetailsDialog) + + } + + @Test + fun testDeleteTeamMembership() { + val bundle = Bundle().apply { + putString(Constants.Bundle.TEAM_ID, teamId) + } + launchFragmentInContainer(bundle, R.style.Theme_AppCompat) + WaitUtils.sleep(1000) + longClickOnListItem(R.id.membershipsRecyclerView, 0) + assertViewDisplayed(R.id.deleteMembership) + WaitUtils.sleep(1000) + clickOnView(R.id.deleteMembership) + onView(withText(R.string.confirm_delete_membership_action)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/RecyclerViewMatchers.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/RecyclerViewMatchers.kt new file mode 100644 index 0000000..849c832 --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/RecyclerViewMatchers.kt @@ -0,0 +1,87 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.content.res.Resources +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.ViewAssertion +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher +import org.hamcrest.core.Is.`is` +import org.junit.Assert.assertThat + +object RecyclerViewMatchers { + + fun withRecyclerView(recyclerViewId: Int): RecyclerViewMatcher { + return RecyclerViewMatcher(recyclerViewId) + } + + fun havingItemCount(itemCount: Int): RecyclerViewItemCountAssertion { + return RecyclerViewItemCountAssertion(itemCount) + } + + class RecyclerViewMatcher(private val recyclerViewId: Int) { + + fun atPosition(position: Int): Matcher { + return atPositionOnView(position, View.NO_ID) + } + + fun atPositionOnView(position: Int, targetViewId: Int): Matcher { + + return object : TypeSafeMatcher() { + var resources: Resources? = null + var childView: View? = null + + override fun describeTo(description: Description) { + var idDescription = Integer.toString(recyclerViewId) + if (this.resources != null) { + idDescription = try { + this.resources!!.getResourceName(recyclerViewId) + } catch (var4: Resources.NotFoundException) { + String.format("%s (resource name not found)", recyclerViewId) + } + } + description.appendText("RecyclerView with id: $idDescription at position: $position on child view with id $targetViewId") + } + + public override fun matchesSafely(view: View): Boolean { + + this.resources = view.resources + + if (childView == null) { + val recyclerView: RecyclerView? = view.rootView.findViewById(recyclerViewId) as RecyclerView + if (recyclerView != null && recyclerView.id == recyclerViewId) { + val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) + if (viewHolder != null) { + childView = viewHolder.itemView + } + } else { + return false + } + } + + return if (targetViewId == View.NO_ID) { + view === childView + } else { + val targetView = childView?.findViewById(targetViewId) + view === targetView + } + } + } + } + } + + class RecyclerViewItemCountAssertion(private val expectedCount: Int) : ViewAssertion { + + override fun check(view: View, noViewFoundException: NoMatchingViewException?) { + if (noViewFoundException != null) { + throw noViewFoundException + } + + val recyclerView = view as RecyclerView + val adapter = recyclerView.adapter + assertThat(adapter!!.itemCount, `is`(expectedCount)) + } + } +} diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/WaitUtils.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/WaitUtils.kt new file mode 100644 index 0000000..c7db3e6 --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/WaitUtils.kt @@ -0,0 +1,71 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.os.SystemClock +import android.util.Log +import androidx.test.espresso.web.model.Atom +import androidx.test.espresso.web.model.ElementReference +import androidx.test.espresso.web.webdriver.DriverAtoms +import androidx.test.espresso.web.webdriver.Locator +import junit.framework.Assert.fail +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +object WaitUtils { + private const val TAG = "WaitUtils" + internal fun sleep(millis: Long) { + try { + Thread.sleep(millis) + } catch (ignored: InterruptedException) { + } + } + + fun waitElementToAppear(id: String) { + var maxTries = 0 + while (maxTries != 60) { + Thread.sleep(1000) + try { + if(DriverAtoms.findElement(Locator.ID, id) != null){ + break + } + }catch (e: TimeoutException){ } + + maxTries++ + } + } + + fun waitForCondition(time: Long, unit: TimeUnit, condition: () -> Boolean, failureMessage: () -> String, warnAfter: Long = 0): Long { + return waitForCondition(unit.toMillis(time), condition, failureMessage, warnAfter) + } + + fun waitForCondition(timeout: Long, condition: () -> Boolean, failureMessage: () -> String, warnAfter: Long = 0): Long { + val startTime = SystemClock.uptimeMillis() + val endTime = startTime + timeout + + while (true) { + val timedOut = SystemClock.uptimeMillis() > endTime + + if (timedOut) { + val format = "Condition not satisified after $timeout ms" + + Log.i(TAG, "waitForCondition timedOut, $format - ${failureMessage()}") + + fail(failureMessage()) + } + + sleep(50) + + if (condition()) { + val duration = SystemClock.uptimeMillis() - startTime + + if ((warnAfter > 0) && (duration > warnAfter)) { + Log.i(TAG, "Condition took $duration ms, longer than $warnAfter ms") + } else { + Log.i(TAG, "Condition took $duration ms") + } + + return duration + } + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookTest.kt new file mode 100644 index 0000000..626208a --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookTest.kt @@ -0,0 +1,118 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.swipeDown +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast +import androidx.test.espresso.matcher.ViewMatchers.hasMinimumChildCount +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.hamcrest.CoreMatchers.allOf +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Random + + +@RunWith(AndroidJUnit4ClassRunner::class) +class WebhookTest : KitchenSinkTest() { + var min = 0 + var max = 100 + + var random = Random() + private var webhookName = "TestingWebhook " + private var webhookUpdateName = "TestingUpdateWebhook " + private var webhookURL = "https://webhook.site/6d71f999-00a9-43d9-ac9d-7ea29d1538f9" + private var resource = "memberships" + private var event = "created" + private var secret = "secret100" + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + val number = random.nextInt(max - min + 1) + min + webhookName += number + webhookUpdateName += number + clickOnView(R.id.iv_webhook) + Intents.intended(IntentMatchers.hasComponent(WebhooksActivity::class.java.name)) + WaitUtils.sleep(2000) + } + + @Test + fun webhook0_Create() { + assertViewDisplayed(R.id.addWebhookButton) + clickOnView(R.id.addWebhookButton) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.rootWebhookCreateDialog) + onView(withId(R.id.nameEditText)).check(matches(isDisplayed())).perform(typeText(webhookName), closeSoftKeyboard()) + onView(withId(R.id.targetUrlEditText)).check(matches(isDisplayed())).perform(typeText(webhookURL), closeSoftKeyboard()) + onView(withId(R.id.resourceEditText)).check(matches(isDisplayed())).perform(typeText(resource), closeSoftKeyboard()) + onView(withId(R.id.eventEditText)).check(matches(isDisplayed())).perform(typeText(event), closeSoftKeyboard()) + onView(withId(R.id.secretEditText)).check(matches(isDisplayed())).perform(typeText(secret), closeSoftKeyboard()) + onView(withText("CREATE")).inRoot(isDialog()) + .check(matches(isDisplayed())) + .perform(click()) + WaitUtils.sleep(5000) + onView(withId(R.id.webhook_recycler_view)).check(matches(hasDescendant(withText(webhookName)))) + } + + @Test + fun webhook1_Get() { + onView(withId(R.id.webhook_recycler_view)).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(85))) + WaitUtils.sleep(5000) + onView(withId(R.id.webhook_recycler_view)).check(matches(hasMinimumChildCount(1))) + onView(allOf(withId(R.id.webhook_recycler_view))).perform(RecyclerViewActions.actionOnItemAtPosition(0, longclickChildViewWithId(R.id.rootListItemLayout))) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.webhookOptionsBottomSheet) + assertViewDisplayed(R.id.webhookGetDetails) + clickOnView(R.id.webhookGetDetails) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootWebHookDetailDialog) + } + + @Test + fun webhook2_Delete() { + onView(withId(R.id.webhook_recycler_view)).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(85))) + WaitUtils.sleep(5000) + onView(withId(R.id.webhook_recycler_view)).check(matches(hasMinimumChildCount(1))) + onView(allOf(withId(R.id.webhook_recycler_view))).perform(RecyclerViewActions.actionOnItemAtPosition(0, longclickChildViewWithId(R.id.rootListItemLayout))) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.webhookOptionsBottomSheet) + assertViewDisplayed(R.id.webhookDelete) + clickOnView(R.id.webhookDelete) + } + + @Test + fun webhook3_Update() { + onView(withId(R.id.webhook_recycler_view)).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(85))) + WaitUtils.sleep(5000) + onView(withId(R.id.webhook_recycler_view)).check(matches(hasMinimumChildCount(1))) + onView(allOf(withId(R.id.webhook_recycler_view))).perform(RecyclerViewActions.actionOnItemAtPosition(0, longclickChildViewWithId(R.id.rootListItemLayout))) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.webhookOptionsBottomSheet) + assertViewDisplayed(R.id.webhookUpdate) + clickOnView(R.id.webhookUpdate) + assertViewDisplayed(R.id.rootWebhookUpdateDialog) + onView(withId(R.id.nameEditText)).check(matches(isDisplayed())).perform(replaceText(webhookUpdateName), closeSoftKeyboard()) + onView(withText("UPDATE")).inRoot(isDialog()) + .check(matches(isDisplayed())) + .perform(click()) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootWebHookDetailDialog) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1ea0c40 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/cisco.png b/app/src/main/assets/cisco.png new file mode 100644 index 0000000000000000000000000000000000000000..f362be92231528384777b8d467cc9322303b3bfa GIT binary patch literal 80569 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 EA3sWT`2YX_ literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/AppModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/AppModule.kt new file mode 100644 index 0000000..049e793 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/AppModule.kt @@ -0,0 +1,9 @@ +package com.ciscowebex.androidsdk.kitchensink + +import com.ciscowebex.androidsdk.kitchensink.utils.PermissionsHelper +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val mainAppModule = module { + single { PermissionsHelper(androidContext()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseActivity.kt new file mode 100644 index 0000000..7d84dc1 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseActivity.kt @@ -0,0 +1,34 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.search.SearchActivity +import com.ciscowebex.androidsdk.kitchensink.utils.PermissionsHelper +import org.koin.android.ext.android.inject +import org.koin.android.viewmodel.ext.android.viewModel + +open class BaseActivity : AppCompatActivity() { + var tag = "BaseActivity" + private val permissionsHelper: PermissionsHelper by inject() + val webexViewModel: WebexViewModel by viewModel() + + fun showErrorDialog(errorMessage: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + + builder.setTitle(R.string.error_occurred) + val message = TextView(this) + message.setPadding(10, 10, 10, 10) + message.text = errorMessage + + builder.setView(message) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseViewModel.kt new file mode 100644 index 0000000..64700f6 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseViewModel.kt @@ -0,0 +1,17 @@ +package com.ciscowebex.androidsdk.kitchensink + +import androidx.lifecycle.ViewModel +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable + +abstract class BaseViewModel() : ViewModel() { + private val compositeDisposable = CompositeDisposable() + + override fun onCleared() { + super.onCleared() + compositeDisposable.clear() + } + + + fun Disposable.autoDispose() = compositeDisposable.add(this) +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/HomeActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/HomeActivity.kt new file mode 100644 index 0000000..8a4fc94 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/HomeActivity.kt @@ -0,0 +1,211 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.util.Log +import android.view.View +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.auth.OAuthWebViewAuthenticator +import com.ciscowebex.androidsdk.kitchensink.auth.LoginActivity +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityHomeBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import com.ciscowebex.androidsdk.kitchensink.cucm.UCLoginActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail.MessageDetailsDialogFragment +import com.ciscowebex.androidsdk.kitchensink.person.PersonDialogFragment +import com.ciscowebex.androidsdk.kitchensink.person.PersonViewModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.clearLoginTypePref +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.saveLoginTypePref +import com.ciscowebex.androidsdk.kitchensink.webhooks.WebhooksActivity +import com.ciscowebex.androidsdk.auth.UCLoginServerConnectionStatus +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.extras.ExtrasActivity +import com.ciscowebex.androidsdk.kitchensink.search.SearchActivity +import com.ciscowebex.androidsdk.kitchensink.setup.SetupActivity +import org.koin.android.ext.android.inject + +class HomeActivity : BaseActivity() { + + lateinit var binding: ActivityHomeBinding + private val personViewModel : PersonViewModel by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "HomeActivity" + + val authenticator = webexViewModel.webex.authenticator + + webexViewModel.enableBackgroundConnection(webexViewModel.enableBgConnectiontoggle) + webexViewModel.setLogLevel(webexViewModel.logFilter) + webexViewModel.enableConsoleLogger(webexViewModel.isConsoleLoggerEnabled) + + authenticator?.let { + when (it) { + is OAuthWebViewAuthenticator -> { + saveLoginTypePref(this, LoginActivity.LoginType.OAuth) + } + else -> { + saveLoginTypePref(this, LoginActivity.LoginType.JWT) + } + } + } + + webexViewModel.signOutListenerLiveData.observe(this@HomeActivity, Observer { + it?.let { + if (it) { + clearLoginTypePref(this) + (application as KitchenSinkApp).unloadKoinModules() + finish() + } + else { + binding.progressLayout.visibility = View.GONE + } + } + }) + + + webexViewModel.cucmLiveData.observe(this@HomeActivity, Observer { + if (it != null) { + when (WebexRepository.CucmEvent.valueOf(it.first.name)) { + WebexRepository.CucmEvent.OnUCServerConnectionStateChanged -> { + updateUCData() + } + else -> {} + } + } + }) + + DataBindingUtil.setContentView(this, R.layout.activity_home) + .also { binding = it } + .apply { + + ivStartCall.setOnClickListener { + startActivity(Intent(this@HomeActivity, SearchActivity::class.java)) + } + + ivWaitingCall.setOnClickListener { + startActivity(CallActivity.getIncomingIntent(this@HomeActivity)) + } + + ivMessaging.setOnClickListener { + startActivity(Intent(this@HomeActivity, MessagingActivity::class.java)) + } + + ivUcLogin.setOnClickListener { + startActivity(Intent(this@HomeActivity, UCLoginActivity::class.java)) + } + + ivWebhook.setOnClickListener { + startActivity(Intent(this@HomeActivity, WebhooksActivity::class.java)) + } + + ivLogout.setOnClickListener { + progressLayout.visibility = View.VISIBLE + webexViewModel.signOut() + } + + ivGetMe.setOnClickListener { + PersonDialogFragment().show(supportFragmentManager, getString(R.string.person_detail)) + } + + ivFeedback.setOnClickListener { + val fileUri = webexViewModel.getlogFileUri(false) + val recipient = "webex-mobile-sdk@cisco.com" + val subject = resources.getString(R.string.feedbackLogsSubject) + + val emailIntent = Intent().apply { + action = Intent.ACTION_SEND + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + type = "text/plain" +// data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(recipient)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_STREAM, fileUri) + } + + try { + startActivity(Intent.createChooser(emailIntent, "Send mail...")) + } + catch (e: Exception) { + Log.e(tag, "Send mail exception: $e") + } + } + + ivSetup.setOnClickListener { + startActivity(Intent(this@HomeActivity, SetupActivity::class.java)) + } + + ivExtras.setOnClickListener { + startActivity(Intent(this@HomeActivity, ExtrasActivity::class.java)) + } + } + + //used some delay because sometimes it gives empty stuff in personDetails + Handler().postDelayed(Runnable { + personViewModel.getMe() + }, 1000) + observeData() + showMessageIfCameFromNotification() + webexViewModel.setSpaceObserver() + webexViewModel.setMembershipObserver() + webexViewModel.setMessageObserver() + } + + override fun onBackPressed() { + (application as KitchenSinkApp).closeApplication() + } + + private fun showMessageIfCameFromNotification() { + + if("ACTION" == intent?.action){ + val messageId = intent?.getStringExtra(Constants.Bundle.MESSAGE_ID) + MessageDetailsDialogFragment.newInstance(messageId.orEmpty()).show(supportFragmentManager, "MessageDetailsDialogFragment") + } + } + + override fun onNewIntent(intent: Intent?) { + val messageId = intent?.getStringExtra(Constants.Bundle.MESSAGE_ID) + MessageDetailsDialogFragment.newInstance(messageId.orEmpty()).show(supportFragmentManager, "MessageDetailsDialogFragment") + super.onNewIntent(intent) + } + + private fun observeData() { + personViewModel.person.observe(this, Observer { person -> + person?.let { + webexViewModel.getFCMToken(it) + } + }) + } + + override fun onResume() { + super.onResume() + updateUCData() + webexViewModel.setGlobalIncomingListener() + } + + private fun updateUCData() { + Log.d(tag, "updateUCData isCUCMServerLoggedIn: ${webexViewModel.repository.isCUCMServerLoggedIn} ucServerConnectionStatus: ${webexViewModel.repository.ucServerConnectionStatus}") + if (webexViewModel.isCUCMServerLoggedIn) { + binding.ucLoginStatusTextView.visibility = View.VISIBLE + } else { + binding.ucLoginStatusTextView.visibility = View.GONE + } + + when (webexViewModel.ucServerConnectionStatus) { + UCLoginServerConnectionStatus.Connected -> { + binding.ucServerConnectionStatusTextView.text = resources.getString(R.string.phone_service_connected) + binding.ucServerConnectionStatusTextView.visibility = View.VISIBLE + } + UCLoginServerConnectionStatus.Failed -> { + val text = resources.getString(R.string.phone_service_failed) + " " + webexViewModel.ucServerConnectionFailureReason + binding.ucServerConnectionStatusTextView.text = text + binding.ucServerConnectionStatusTextView.visibility = View.VISIBLE + } + else -> { + binding.ucServerConnectionStatusTextView.visibility = View.GONE + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/JWTWebexModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/JWTWebexModule.kt new file mode 100644 index 0000000..2327f97 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/JWTWebexModule.kt @@ -0,0 +1,14 @@ +package com.ciscowebex.androidsdk.kitchensink + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.auth.JWTAuthenticator +import org.koin.android.ext.koin.androidApplication +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val JWTWebexModule = module { + + factory { + Webex(androidApplication(), JWTAuthenticator()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkApp.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkApp.kt new file mode 100644 index 0000000..d5bc594 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkApp.kt @@ -0,0 +1,86 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.app.Application +import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.ProcessLifecycleOwner +import com.ciscowebex.androidsdk.kitchensink.auth.LoginActivity +import com.ciscowebex.androidsdk.kitchensink.auth.loginModule +import com.ciscowebex.androidsdk.kitchensink.calling.callModule +import com.ciscowebex.androidsdk.kitchensink.extras.extrasModule +import com.ciscowebex.androidsdk.kitchensink.messaging.messagingModule +import com.ciscowebex.androidsdk.kitchensink.messaging.search.searchPeopleModule +import com.ciscowebex.androidsdk.kitchensink.person.personModule +import com.ciscowebex.androidsdk.kitchensink.search.searchModule +import com.ciscowebex.androidsdk.kitchensink.webhooks.webhooksModule +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.loadKoinModules +import org.koin.core.context.startKoin +import org.koin.core.context.unloadKoinModules + + +class KitchenSinkApp : Application(), LifecycleObserver { + + companion object { + lateinit var instance: KitchenSinkApp + private set + + fun applicationContext(): Context { + return instance.applicationContext + } + + fun get(): KitchenSinkApp { + return instance + } + + var inForeground: Boolean = false + } + + override fun onCreate() { + super.onCreate() + startKoin { + androidLogger() + androidContext(this@KitchenSinkApp) + } + ProcessLifecycleOwner.get().getLifecycle().addObserver(this); + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + instance = this + } + + @OnLifecycleEvent(Lifecycle.Event.ON_START) + fun onMoveToForeground() { + // app moved to foreground + inForeground = true + } + + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + fun onMoveToBackground() { + // app moved to background + inForeground = false + } + + fun closeApplication() { + android.os.Process.killProcess(android.os.Process.myPid()) + } + + fun loadKoinModules(type: LoginActivity.LoginType) { + when (type) { + LoginActivity.LoginType.JWT -> { + loadKoinModules(listOf(mainAppModule, webexModule, loginModule, JWTWebexModule, searchModule, callModule, messagingModule, personModule, searchPeopleModule, webhooksModule, extrasModule)) + } + else -> { + loadKoinModules(listOf(mainAppModule, webexModule, loginModule, OAuthWebexModule, searchModule, callModule, messagingModule, personModule, searchPeopleModule, webhooksModule, extrasModule)) + } + } + } + + fun unloadKoinModules() { + unloadKoinModules(listOf(mainAppModule, webexModule, loginModule, JWTWebexModule, OAuthWebexModule, searchModule, callModule, messagingModule, personModule, searchPeopleModule, webhooksModule, extrasModule)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/OAuthWebexModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/OAuthWebexModule.kt new file mode 100644 index 0000000..b012701 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/OAuthWebexModule.kt @@ -0,0 +1,23 @@ +package com.ciscowebex.androidsdk.kitchensink + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.auth.OAuthWebViewAuthenticator +import com.ciscowebex.androidsdk.auth.Authenticator +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.getEmailPref +import org.koin.android.ext.koin.androidApplication +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val OAuthWebexModule = module { + single (named("oAuth")) { + val clientId = BuildConfig.CLIENT_ID + val clientSecret = BuildConfig.CLIENT_SECRET + val redirectUri = BuildConfig.REDIRECT_URI + val email = getEmailPref(androidApplication()).orEmpty() + OAuthWebViewAuthenticator(clientId, clientSecret, redirectUri, email) + } + + factory { + Webex(androidApplication(), get(named("oAuth"))) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexModule.kt new file mode 100644 index 0000000..97d8544 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexModule.kt @@ -0,0 +1,14 @@ +package com.ciscowebex.androidsdk.kitchensink + +import com.ciscowebex.androidsdk.kitchensink.calling.RingerManager +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val webexModule = module(createdAtStart = true) { + single { WebexRepository(get()) } + single { RingerManager(get()) } + + viewModel { + WebexViewModel(get(), get()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt new file mode 100644 index 0000000..e7ed0af --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt @@ -0,0 +1,296 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.WebexUCLoginDelegate +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.people.Person +import com.ciscowebex.androidsdk.space.Space +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.auth.PhoneServiceRegistrationFailureReason +import com.ciscowebex.androidsdk.auth.UCLoginServerConnectionStatus +import com.ciscowebex.androidsdk.kitchensink.utils.CallObjectStorage +import com.ciscowebex.androidsdk.membership.Membership +import com.ciscowebex.androidsdk.membership.MembershipObserver +import com.ciscowebex.androidsdk.message.MessageObserver +import com.ciscowebex.androidsdk.phone.CallMembership +import com.ciscowebex.androidsdk.phone.CallObserver +import com.ciscowebex.androidsdk.phone.NotificationCallType +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.MediaOption +import com.ciscowebex.androidsdk.phone.Phone +import com.ciscowebex.androidsdk.space.SpaceObserver + +class WebexRepository(val webex: Webex) : WebexUCLoginDelegate { + private val tag = "WebexRepository" + + enum class CallCap { + Audio_Only, + Audio_Video + } + + enum class CucmEvent { + ShowSSOLogin, + ShowNonSSOLogin, + OnUCLoginFailed, + OnUCLoggedIn, + OnUCServerConnectionStateChanged + } + + enum class LogLevel { + ALL, + VERBOSE, + INFO, + WARNING, + DEBUG, + ERROR, + NO + } + + enum class SpaceEvent { + Created, + Updated, + CallStarted, + CallEnded + } + + enum class MembershipEvent { + Created, + Updated, + Deleted, + MessageSeen + } + + enum class MessageEvent { + Received, + Edited, + Deleted, + MessageThumbnailUpdated + } + + enum class CallEvent { + DialCompleted, + DialFailed, + AnswerCompleted, + AnswerFailed, + AssociationCallCompleted, + AssociationCallFailed, + MeetingPinOrPasswordRequired + } + + data class CallLiveData(val event: CallEvent, + val call: Call? = null, + val sharingLabel: String? = null, + val errorMessage: String? = null, + val callMembershipEvent: CallObserver.CallMembershipChangedEvent? = null, + val mediaChangeEvent: CallObserver.MediaChangedEvent? = null, + val disconnectEvent: CallObserver.CallDisconnectedEvent? = null) {} + + var isAddedCall = false + var currentCallId: String? = null + var oldCallId: String? = null + var isSendingAudio = true + var doMuteAll = true + var incomingCallJoinedCallId: String? = null + var isLocalVideoMuted = true + var isRemoteVideoMuted = true + var isRemoteScreenShareON = false + var enableBgStreamtoggle = true + var enableBgConnectiontoggle = true + var enablePhoneStatePermission = true + var logFilter = LogLevel.ALL.name + var isConsoleLoggerEnabled = true + var callCapability: CallCap = CallCap.Audio_Video + var scalingMode: Call.VideoRenderMode = Call.VideoRenderMode.Fit + var compositedVideoLayout: MediaOption.CompositedVideoLayout = MediaOption.CompositedVideoLayout.FILMSTRIP + var streamMode: Phone.VideoStreamMode = Phone.VideoStreamMode.AUXILIARY + var isSpaceCallStarted = false + var spaceCallId:String? = null + + val participantMuteMap = hashMapOf() + var isCUCMServerLoggedIn = false + var ucServerConnectionStatus: UCLoginServerConnectionStatus = UCLoginServerConnectionStatus.Idle + var ucServerConnectionFailureReason: PhoneServiceRegistrationFailureReason = PhoneServiceRegistrationFailureReason.Unknown + + var _callMembershipsLiveData: MutableLiveData>? = null + var _muteAllLiveData: MutableLiveData? = null + var _cucmLiveData: MutableLiveData>? = null + var _callingLiveData: MutableLiveData? = null + var _startAssociationLiveData: MutableLiveData? = null + var _startShareLiveData: MutableLiveData? = null + var _stopShareLiveData: MutableLiveData? = null + + var _spaceEventLiveData: MutableLiveData>? = null + var _membershipEventLiveData: MutableLiveData>? = null + var _messageEventLiveData: MutableLiveData>? = null + + init { + webex.delegate = this + } + + fun clearCallData() { + isAddedCall = false + currentCallId = null + oldCallId = null + incomingCallJoinedCallId = null + isSendingAudio = true + doMuteAll = true + isLocalVideoMuted = true + isRemoteScreenShareON = false + isRemoteVideoMuted = true + + _callMembershipsLiveData = null + _muteAllLiveData = null + _callingLiveData = null + _startAssociationLiveData = null + _startShareLiveData = null + _stopShareLiveData = null + } + + fun clearSpaceData(){ + _spaceEventLiveData = null + } + + fun setSpaceObserver() { + webex.spaces.setSpaceObserver(object : SpaceObserver { + override fun onEvent(event: SpaceObserver.SpaceEvent) { + Log.d(tag, "onEvent: $event with actorID : ${event.getActorId().orEmpty()}") + when (event) { + is SpaceObserver.SpaceCallStarted -> { + _spaceEventLiveData?.postValue(Pair(SpaceEvent.CallStarted, event.getSpaceId())) + isSpaceCallStarted = true + spaceCallId = event.getSpaceId() + } + is SpaceObserver.SpaceCallEnded -> { + _spaceEventLiveData?.postValue(Pair(SpaceEvent.CallEnded, event.getSpaceId())) + isSpaceCallStarted = false + spaceCallId = null + } + is SpaceObserver.SpaceCreated -> { + _spaceEventLiveData?.postValue(Pair(SpaceEvent.Created, event.getSpace())) + } + is SpaceObserver.SpaceUpdated -> { + _spaceEventLiveData?.postValue(Pair(SpaceEvent.Updated, event.getSpace())) + } + } + } + }) + } + + fun setMembershipObserver() { + webex.memberships.setMembershipObserver(object : MembershipObserver { + override fun onEvent(event: MembershipObserver.MembershipEvent?) { + Log.d(tag, "onMembershipEvent: $event") + when (event) { + is MembershipObserver.MembershipCreated -> { + _membershipEventLiveData?.postValue(Pair(MembershipEvent.Created, event.getMembership())) + } + is MembershipObserver.MembershipUpdated -> { + _membershipEventLiveData?.postValue(Pair(MembershipEvent.Updated, event.getMembership())) + } + is MembershipObserver.MembershipDeleted -> { + _membershipEventLiveData?.postValue(Pair(MembershipEvent.Deleted, event.getMembership())) + } + is MembershipObserver.MembershipMessageSeen -> { + _membershipEventLiveData?.postValue(Pair(MembershipEvent.MessageSeen, event.getMembership())) + } + } + } + }) + } + + fun setMessageObserver() { + webex.messages.setMessageObserver(object : MessageObserver { + override fun onEvent(event: MessageObserver.MessageEvent) { + Log.d(tag, "onMessageEvent: $event") + when (event) { + is MessageObserver.MessageReceived -> { + _messageEventLiveData?.postValue(Pair(MessageEvent.Received, event.getMessage())) + } + is MessageObserver.MessageDeleted -> { + _messageEventLiveData?.postValue(Pair(MessageEvent.Deleted, event.getMessageId())) + } + is MessageObserver.MessageFileThumbnailsUpdated -> { + Log.d(tag, "onMessageFileThumbnailsUpdated triggered!") + _messageEventLiveData?.postValue(Pair(MessageEvent.MessageThumbnailUpdated, event.getFiles())) + } + is MessageObserver.MessageEdited -> { + _messageEventLiveData?.postValue(Pair(MessageEvent.Edited, event.getMessage())) + } + } + } + }) + } + + fun setIncomingListener() { + Log.d(tag, "setIncomingListener") + if (webex.phone.getIncomingCallListener() != null) { + webex.phone.setIncomingCallListener(object : Phone.IncomingCallListener { + override fun onIncomingCall(call: Call?) { + call?.let { + CallObjectStorage.addCallObject(it) + } ?: run { + Log.d(tag, "setIncomingCallListener Call object null") + } + } + }) + } + } + + fun getCall(callId: String): Call? { + return CallObjectStorage.getCallObject(callId) + } + + fun getCallIdByNotificationId(notificationId: String, callType: NotificationCallType): String { + return webex.getCallIdByNotificationId(notificationId, callType) + } + + fun stopShare(callId: String) { + getCall(callId)?.stopSharing(CompletionHandler { result -> + _stopShareLiveData?.postValue(result.isSuccessful) + }) + } + + fun getSpace(spaceId: String, handler: CompletionHandler){ + webex.spaces.get(spaceId, handler) + } + + fun getPerson(personId: String, handler: CompletionHandler){ + webex.people.get(personId, handler) + } + + fun listMessages(spaceId: String, handler: CompletionHandler>){ + webex.messages.list(spaceId, null, 10000, null, handler) + } + + // Callbacks + override fun showUCSSOLoginView(ssoUrl: String) { + _cucmLiveData?.postValue(Pair(CucmEvent.ShowSSOLogin, ssoUrl)) + Log.d(tag, "showUCSSOLoginView") + } + + override fun showUCNonSSOLoginView() { + _cucmLiveData?.postValue(Pair(CucmEvent.ShowNonSSOLogin, "")) + Log.d(tag, "showUCNonSSOLoginView") + } + + override fun onUCLoginFailed() { + _cucmLiveData?.postValue(Pair(CucmEvent.OnUCLoginFailed, "")) + Log.d(tag, "onUCLoginFailed") + isCUCMServerLoggedIn = false + } + + override fun onUCLoggedIn() { + _cucmLiveData?.postValue(Pair(CucmEvent.OnUCLoggedIn, "")) + Log.d(tag, "onUCLoggedIn") + isCUCMServerLoggedIn = true + } + + override fun onUCServerConnectionStateChanged(status: UCLoginServerConnectionStatus, failureReason: PhoneServiceRegistrationFailureReason) { + _cucmLiveData?.postValue(Pair(CucmEvent.OnUCServerConnectionStateChanged, "")) + Log.d(tag, "onUCServerConnectionStateChanged status: $status failureReason: $failureReason") + ucServerConnectionStatus = status + ucServerConnectionFailureReason = failureReason + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt new file mode 100644 index 0000000..d11c5b3 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt @@ -0,0 +1,752 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.app.AlertDialog +import android.app.Notification +import android.net.Uri +import android.util.Log +import android.view.View +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.firebase.RegisterTokenService +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.CallObserver +import com.ciscowebex.androidsdk.phone.MediaOption +import com.ciscowebex.androidsdk.phone.CallMembership +import com.ciscowebex.androidsdk.phone.Phone +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.WebexError +import com.google.android.gms.tasks.OnCompleteListener +import com.google.android.gms.tasks.Task +import com.google.firebase.messaging.FirebaseMessaging +import org.json.JSONObject +import com.ciscowebex.androidsdk.phone.CallAssociationType +import com.ciscowebex.androidsdk.auth.PhoneServiceRegistrationFailureReason +import com.ciscowebex.androidsdk.auth.UCLoginServerConnectionStatus +import com.ciscowebex.androidsdk.kitchensink.calling.CallObserverInterface +import com.ciscowebex.androidsdk.kitchensink.utils.CallObjectStorage +import com.ciscowebex.androidsdk.phone.AdvancedSetting +import com.ciscowebex.androidsdk.phone.AuxStream + + +class WebexViewModel(val webex: Webex, val repository: WebexRepository) : BaseViewModel() { + private val tag = "WebexViewModel" + + var _callMembershipsLiveData = MutableLiveData>() + val _muteAllLiveData = MutableLiveData() + val _cucmLiveData = MutableLiveData>() + val _callingLiveData = MutableLiveData() + val _startAssociationLiveData = MutableLiveData() + val _startShareLiveData = MutableLiveData() + val _stopShareLiveData = MutableLiveData() + val _setCompositeLayoutLiveData = MutableLiveData>() + val _setRemoteVideoRenderModeLiveData = MutableLiveData>() + + var callMembershipsLiveData: LiveData> = _callMembershipsLiveData + val muteAllLiveData: LiveData = _muteAllLiveData + val cucmLiveData: LiveData> = _cucmLiveData + val callingLiveData: LiveData = _callingLiveData + val startAssociationLiveData: LiveData = _startAssociationLiveData + val startShareLiveData: LiveData = _startShareLiveData + val stopShareLiveData: LiveData = _stopShareLiveData + val setCompositeLayoutLiveData: LiveData> = _setCompositeLayoutLiveData + val setRemoteVideoRenderModeLiveData: LiveData> = _setRemoteVideoRenderModeLiveData + + private val _incomingListenerLiveData = MutableLiveData() + val incomingListenerLiveData: LiveData = _incomingListenerLiveData + + private val _signOutListenerLiveData = MutableLiveData() + val signOutListenerLiveData: LiveData = _signOutListenerLiveData + + private val _tokenLiveData = MutableLiveData>() + val tokenLiveData: LiveData> = _tokenLiveData + + var selfPersonId: String? = null + var compositedLayoutState = MediaOption.CompositedVideoLayout.NOT_SUPPORTED + + var callObserverInterface: CallObserverInterface? = null + + var callCapability: WebexRepository.CallCap + get() = repository.callCapability + set(value) { + repository.callCapability = value + } + + var scalingMode: Call.VideoRenderMode + get() = repository.scalingMode + set(value) { + repository.scalingMode = value + } + + var compositedVideoLayout: MediaOption.CompositedVideoLayout + get() = repository.compositedVideoLayout + set(value) { + repository.compositedVideoLayout = value + } + + var streamMode: Phone.VideoStreamMode + get() = repository.streamMode + set(value) { + repository.streamMode = value + } + + var isAddedCall: Boolean + get() = repository.isAddedCall + set(value) { + repository.isAddedCall = value + } + + var currentCallId: String? + get() = repository.currentCallId + set(value) { + repository.currentCallId = value + } + + var oldCallId: String? + get() = repository.oldCallId + set(value) { + repository.oldCallId = value + } + + var isSendingAudio: Boolean + get() = repository.isSendingAudio + set(value) { + repository.isSendingAudio = value + } + + var doMuteAll: Boolean + get() = repository.doMuteAll + set(value) { + repository.doMuteAll = value + } + + var incomingCallJoinedCallId: String? + get() = repository.incomingCallJoinedCallId + set(value) { + repository.incomingCallJoinedCallId = value + } + + var isLocalVideoMuted: Boolean + get() = repository.isLocalVideoMuted + set(value) { + repository.isLocalVideoMuted = value + } + + var isRemoteVideoMuted: Boolean + get() = repository.isRemoteVideoMuted + set(value) { + repository.isRemoteVideoMuted = value + } + + var isCUCMServerLoggedIn: Boolean + get() = repository.isCUCMServerLoggedIn + set(value) { + repository.isCUCMServerLoggedIn = value + } + + var ucServerConnectionStatus: UCLoginServerConnectionStatus + get() = repository.ucServerConnectionStatus + set(value) { + repository.ucServerConnectionStatus = value + } + + var ucServerConnectionFailureReason: PhoneServiceRegistrationFailureReason + get() = repository.ucServerConnectionFailureReason + set(value) { + repository.ucServerConnectionFailureReason = value + } + + var isRemoteScreenShareON: Boolean + get() = repository.isRemoteScreenShareON + set(value) { + repository.isRemoteScreenShareON = value + } + + var enableBgStreamtoggle: Boolean + get() = repository.enableBgStreamtoggle + set(value) { + repository.enableBgStreamtoggle = value + } + + var enableBgConnectiontoggle: Boolean + get() = repository.enableBgConnectiontoggle + set(value) { + repository.enableBgConnectiontoggle = value + } + + var enablePhoneStatePermission: Boolean + get() = repository.enablePhoneStatePermission + set(value) { + repository.enablePhoneStatePermission = value + } + + var logFilter: String + get() = repository.logFilter + set(value) { + repository.logFilter = value + } + + var isConsoleLoggerEnabled: Boolean + get() = repository.isConsoleLoggerEnabled + set(value) { + repository.isConsoleLoggerEnabled = value + } + + init { + repository._callMembershipsLiveData = _callMembershipsLiveData + repository._cucmLiveData = _cucmLiveData + repository._muteAllLiveData = _muteAllLiveData + repository._callingLiveData = _callingLiveData + repository._startAssociationLiveData = _startAssociationLiveData + repository._startShareLiveData = _startShareLiveData + repository._stopShareLiveData = _stopShareLiveData + } + + fun setLogLevel(logLevel: String) { + var level: Webex.LogLevel = Webex.LogLevel.ALL + when (logLevel) { + WebexRepository.LogLevel.ALL.name -> level = Webex.LogLevel.ALL + WebexRepository.LogLevel.VERBOSE.name -> level = Webex.LogLevel.VERBOSE + WebexRepository.LogLevel.INFO.name -> level = Webex.LogLevel.INFO + WebexRepository.LogLevel.WARNING.name -> level = Webex.LogLevel.WARNING + WebexRepository.LogLevel.DEBUG.name -> level = Webex.LogLevel.DEBUG + WebexRepository.LogLevel.ERROR.name -> level = Webex.LogLevel.ERROR + WebexRepository.LogLevel.NO.name -> level = Webex.LogLevel.NO + } + webex.setLogLevel(level) + } + + fun enableConsoleLogger(enable: Boolean) { + webex.enableConsoleLogger(enable) + } + + override fun onCleared() { + repository.clearCallData() + } + + fun setSpaceObserver() { + repository.setSpaceObserver() + } + + fun setMembershipObserver() { + repository.setMembershipObserver() + } + + fun setMessageObserver() { + repository.setMessageObserver() + } + + fun setIncomingListener() { + webex.phone.setIncomingCallListener(object : Phone.IncomingCallListener { + override fun onIncomingCall(call: Call?) { + call?.let { + CallObjectStorage.addCallObject(it) + _incomingListenerLiveData.postValue(it) + setCallObserver(it) + } ?: run { + Log.d(tag, "setIncomingCallListener Call object null") + } + } + }) + } + + fun setFCMIncomingListenerObserver(callId: String) { + val call = CallObjectStorage.getCallObject(callId) + call?.let { + setCallObserver(it) + } + } + + fun setGlobalIncomingListener() { + repository.setIncomingListener() + } + + fun signOut() { + webex.authenticator?.deauthorize(CompletionHandler { result -> + result?.let { + _signOutListenerLiveData.postValue(it.isSuccessful) + if (!it.isSuccessful) { + Log.d(tag, "Logut error : ${it.error?.errorMessage}") + } + } + }) + } + + fun dial(input: String, option: MediaOption) { + webex.phone.dial(input, option, CompletionHandler { result -> + Log.d(tag, "Omnius: onCallEvent CallStateChanged") + if (result.isSuccessful) { + result.data?.let { _call -> + CallObjectStorage.addCallObject(_call) + currentCallId = _call.getCallId() + setCallObserver(_call) + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.DialCompleted, _call)) + } + } else { + result.error?.let { errorCode -> + if (errorCode.errorCode == WebexError.ErrorCode.HOST_PIN_OR_MEETING_PASSWORD_REQUIRED.code) { + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.MeetingPinOrPasswordRequired, null)) + } else { + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.DialFailed, null, null, result.error?.errorMessage)) + } + } ?: run { + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.DialFailed, null, null, result.error?.errorMessage)) + } + } + }) + } + + fun answer(call: Call, mediaOption: MediaOption) { + call.answer(mediaOption, CompletionHandler { result -> + if (result.isSuccessful) { + result.data.let { + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.AnswerCompleted, call)) + } + } else { + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.AnswerFailed, null, null, result.error?.errorMessage)) + } + }) + } + + private fun setCallObserver(call: Call) { + call.setObserver(object : CallObserver { + override fun onConnected(call: Call?) { + callObserverInterface?.onConnected(call) + } + + override fun onRinging(call: Call?) { + callObserverInterface?.onRinging(call) + } + + override fun onWaiting(call: Call?, reason: Call.WaitReason?) { + Log.d(tag, "CallObserver onWaiting reason: $reason") + callObserverInterface?.onWaiting(call) + } + + override fun onDisconnected(event: CallObserver.CallDisconnectedEvent?) { + Log.d(tag, "CallObserver onDisconnected event: $event") + callObserverInterface?.onDisconnected(call, event) + } + + override fun onInfoChanged(call: Call?) { + callObserverInterface?.onInfoChanged(call) + } + + override fun onMediaChanged(event: CallObserver.MediaChangedEvent?) { + Log.d(tag, "CallObserver OnMediaChanged event: $event") + callObserverInterface?.onMediaChanged(call, event) + } + + override fun onCallMembershipChanged(event: CallObserver.CallMembershipChangedEvent?) { + Log.d(tag, "CallObserver onCallMembershipChanged event: $event") + callObserverInterface?.onCallMembershipChanged(call, event) + getParticipants(event?.getCall()?.getCallId().orEmpty()) + } + + override fun onScheduleChanged(call: Call?) { + callObserverInterface?.onScheduleChanged(call) + } + }) + } + + fun setReceivingVideo(call: Call, receiving: Boolean) { + call.setReceivingVideo(receiving) + } + + fun setReceivingAudio(call: Call, receiving: Boolean) { + call.setReceivingAudio(receiving) + } + + fun setReceivingSharing(call: Call, receiving: Boolean) { + call.setReceivingSharing(receiving) + } + + fun muteSelfVideo(callId: String, doMute: Boolean) { + getCall(callId)?.setSendingVideo(!doMute) + } + + fun getCall(callId: String): Call? { + return repository.getCall(callId) + } + + fun muteAllParticipantAudio(callId: String) { + if (!isSendingAudio) { + muteSelfAudio(callId) + } + Log.d(tag, "postParticipantData muteAllParticipantAudio: $doMuteAll") + getCall(callId)?.muteAllParticipantAudio(doMuteAll) + } + + fun muteParticipant(callId: String, participantId: String) { + repository.participantMuteMap[participantId]?.let { doMute -> + if (participantId == selfPersonId) { + muteSelfAudio(callId) + } else { + getCall(callId)?.muteParticipantAudio(participantId, doMute) + } + } + } + + fun muteSelfAudio(callId: String) { + Log.d(tag, "muteSelfAudio isSendingAudio: $isSendingAudio") + getCall(callId)?.setSendingAudio(!isSendingAudio) + } + + fun startShare(callId: String) { + getCall(callId)?.startSharing(CompletionHandler { result -> + _startShareLiveData.postValue(result.isSuccessful) + }) + } + + fun startShare(callId: String, notification: Notification?, notificationId: Int) { + getCall(callId)?.startSharing(notification, notificationId, CompletionHandler { result -> + _startShareLiveData.postValue(result.isSuccessful) + }) + } + + fun setSendingSharing(callId: String, value: Boolean) { + getCall(callId)?.setSendingSharing(value) + } + + fun stopShare(callId: String) { + getCall(callId)?.stopSharing(CompletionHandler { result -> + _stopShareLiveData.postValue(result.isSuccessful) + }) + } + + fun sendFeedback(callId: String, rating: Int, comment: String) { + getCall(callId)?.sendFeedback(rating, comment) + } + + fun sendDTMF(callId: String, keys: String) { + getCall(callId)?.sendDTMF(keys, CompletionHandler { result -> + if (result.isSuccessful) { + Log.d(tag, "sendDTMF successful") + } else { + Log.d(tag, "sendDTMF error: ${result.error?.errorMessage}") + } + }) + } + + fun hangup(callId: String) { + getCall(callId)?.hangup(CompletionHandler { result -> + if (result.isSuccessful) { + Log.d(tag, "hangup successful") + } else { + Log.d(tag, "hangup error: ${result.error?.errorMessage}") + } + }) + } + + fun rejectCall(callId: String) { + getCall(callId)?.reject(CompletionHandler { result -> + if (result.isSuccessful) { + Log.d(tag, "rejectCall successful") + } else { + Log.d(tag, "rejectCall error: ${result.error?.errorMessage}") + } + }) + } + + fun holdCall(callId: String) { + val callInfo = getCall(callId) + val isOnHold = callInfo?.isOnHold() ?: false + Log.d(tag, "holdCall isOnHold = $isOnHold") + callInfo?.holdCall(!isOnHold) + } + + fun isOnHold(callId: String) = getCall(callId)?.isOnHold() + + fun getParticipants(_callId: String) { + val callParticipants = getCall(_callId)?.getMemberships() ?: ArrayList() + repository._callMembershipsLiveData?.postValue(callParticipants) + + callParticipants.forEach { + repository.participantMuteMap[it.getPersonId()] = it.isSendingAudio() + } + } + + fun setUCDomainServerUrl(ucDomain: String, serverUrl: String) { + webex.setUCDomainServerUrl(ucDomain, serverUrl) + } + + fun setCUCMCredential(username: String, password: String) { + webex.setCUCMCredential(username, password) + } + + fun isUCLoggedIn(): Boolean { + return webex.isUCLoggedIn() + } + + fun getUCServerConnectionStatus(): UCLoginServerConnectionStatus { + return webex.getUCServerConnectionStatus() + } + + fun startAssociatedCall(callId: String, dialNumber: String, associationType: CallAssociationType, audioCall: Boolean) { + getCall(callId)?.startAssociatedCall(dialNumber, associationType, audioCall, CompletionHandler { result -> + Log.d(tag, "startAssociatedCall Lambda") + if (result.isSuccessful) { + Log.d(tag, "startAssociatedCall Lambda isSuccessful") + result.data?.let { + setCallObserver(it) + _startAssociationLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.AssociationCallCompleted, it)) + } + } else { + Log.d(tag, "startAssociatedCall Lambda isSuccessful 5") + _startAssociationLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.AssociationCallFailed, null, null, result.error?.errorMessage)) + Log.d(tag, "startAssociatedCall Lambda isSuccessful 6") + } + }) + } + + fun transferCall(fromCallId: String, toCallId: String) { + getCall(fromCallId)?.transferCall(toCallId) + } + + fun mergeCalls(currentCallId: String, targetCallId: String) { + getCall(currentCallId)?.mergeCalls(targetCallId) + } + + fun getlogFileUri(includelastRunLog: Boolean = false): Uri { + return webex.getlogFileUri(includelastRunLog) + } + + fun getFCMToken(personModel: PersonModel) { + FirebaseMessaging.getInstance().token + .addOnCompleteListener(object : OnCompleteListener { + override fun onComplete(task: Task) { + if (!task.isSuccessful) { + Log.w(tag, "Fetching FCM registration token failed", task.exception) + return + } + + // Get new FCM registration token + val token: String? = task.result + Log.d(tag, "$token") + sendTokenToServer(Pair(token, personModel)) + } + }) + } + + private fun sendTokenToServer(it: Pair) { + val json = JSONObject() + json.put("token", it.first) + json.put("personId", it.second.personId) + json.put("email", it.second.emailList) + RegisterTokenService().execute(json.toString()) + } + + fun postParticipantData(data: List?) { + synchronized(this) { + _callMembershipsLiveData.postValue(data) + + var isRemoteSendingAudio = false + data?.forEach { + if (it.getPersonId() != selfPersonId) { + if (it.isSendingAudio()) { + isRemoteSendingAudio = true + } + } + repository.participantMuteMap[it.getPersonId()] = it.isSendingAudio() + } + + Log.d(tag, "postParticipantData hasMutedAll: $isRemoteSendingAudio") + doMuteAll = isRemoteSendingAudio + repository._muteAllLiveData?.postValue(doMuteAll) + } + } + + fun getHeader(state: CallMembership.State): String { + return when(state) { + CallMembership.State.UNKNOWN -> "Not in meeting" + CallMembership.State.JOINED -> "In meeting" + CallMembership.State.WAITING -> "In lobby" + CallMembership.State.IDLE -> "Idle" + CallMembership.State.DECLINED -> "Call declined" + CallMembership.State.LEFT -> "Left meeting" + CallMembership.State.NOTIFIED -> "Notified" + } + } + + fun setVideoMaxTxFPSSetting(fps: Int) { + webex.phone.setAdvancedSetting(AdvancedSetting.VideoMaxTxFPS(fps) as AdvancedSetting<*>) + } + + fun setVideoEnableDecoderMosaicSetting(value: Boolean) { + webex.phone.setAdvancedSetting(AdvancedSetting.VideoEnableDecoderMosaic(value) as AdvancedSetting<*>) + } + + fun setShareMaxCaptureFPSSetting(fps: Int) { + webex.phone.setAdvancedSetting(AdvancedSetting.ShareMaxCaptureFPS(fps) as AdvancedSetting<*>) + } + + fun setVideoEnableCamera2Setting(value: Boolean) { + webex.phone.setAdvancedSetting(AdvancedSetting.VideoEnableCamera2(value) as AdvancedSetting<*>) + } + + fun switchAudioMode(mode: Call.AudioOutputMode) { + getCall(currentCallId.orEmpty())?.switchAudioOutput(mode) + } + + fun enableAudioBNR(value: Boolean) { + webex.phone.enableAudioBNR(value) + } + + fun isAudioBNREnable(): Boolean { + return webex.phone.isAudioBNREnable() + } + + fun setAudioBNRMode(mode: Phone.AudioBRNMode) { + webex.phone.setAudioBNRMode(mode) + } + + fun getAudioBNRMode(): Phone.AudioBRNMode { + return webex.phone.getAudioBNRMode() + } + + fun setDefaultFacingMode(mode: Phone.FacingMode) { + webex.phone.setDefaultFacingMode(mode) + } + + fun getDefaultFacingMode() : Phone.FacingMode { + return webex.phone.getDefaultFacingMode() + } + + fun disableVideoCodecActivation() { + webex.phone.disableVideoCodecActivation() + } + + fun getVideoCodecLicense(): String { + return webex.phone.getVideoCodecLicense() + } + + fun getVideoCodecLicenseURL(): String { + return webex.phone.getVideoCodecLicenseURL() + } + + fun requestVideoCodecActivation(builder: AlertDialog.Builder) { + webex.phone.requestVideoCodecActivation(builder, CompletionHandler { result -> + Log.d(tag, "requestVideoCodecActivation result action: ${result.data}") + }) + } + + fun setHardwareAccelerationEnabled(enable: Boolean) { + webex.phone.setHardwareAccelerationEnabled(enable) + } + + fun setVideoMaxRxBandwidth(bandwidth: Int) { + webex.phone.setVideoMaxRxBandwidth(bandwidth) + } + + fun setVideoMaxTxBandwidth(bandwidth: Int) { + webex.phone.setVideoMaxTxBandwidth(bandwidth) + } + + fun setSharingMaxRxBandwidth(bandwidth: Int) { + webex.phone.setSharingMaxRxBandwidth(bandwidth) + } + + fun setAudioMaxRxBandwidth(bandwidth: Int) { + webex.phone.setAudioMaxRxBandwidth(bandwidth) + } + + fun startPreview(preView: View) { + webex.phone.startPreview(preView) + } + + fun stopPreview() { + webex.phone.stopPreview() + } + + fun enableBackgroundConnection(enable: Boolean) { + webex.phone.enableBackgroundConnection(enable) + } + + fun enableBackgroundStream(enable: Boolean) { + webex.phone.enableBackgroundStream(enable) + } + + fun enableAskingReadPhoneStatePermission(enable: Boolean) { + webex.phone.enableAskingReadPhoneStatePermission(enable) + } + + fun getVideoRenderViews(callId: String): Pair { + return getCall(callId)?.getVideoRenderViews() ?: Pair(null, null) + } + + fun setVideoRenderViews(callId: String, localVideoView: View, remoteVideoView: View) { + getCall(callId)?.setVideoRenderViews(Pair(localVideoView, remoteVideoView)) + } + + fun getSharingRenderView(callId: String): View? { + return getCall(callId)?.getSharingRenderView() + } + + fun setSharingRenderView(callId: String, view: View?) { + getCall(callId)?.setSharingRenderView(view) + } + + fun setRemoteVideoRenderMode(callId: String, mode: Call.VideoRenderMode) { + getCall(callId)?.setRemoteVideoRenderMode(mode, CompletionHandler { + it.let { + if (it.isSuccessful) { + Log.d(tag, "setRemoteVideoRenderMode successful") + _setRemoteVideoRenderModeLiveData.postValue(Pair(true, "")) + } else { + Log.d(tag, "setRemoteVideoRenderMode failed: ${it.error?.errorMessage}") + _setRemoteVideoRenderModeLiveData.postValue(Pair(false, it.error?.errorMessage ?: "")) + } + } + }) + } + + fun letIn(callId: String, callMembership: CallMembership) { + getCall(callId)?.letIn(callMembership) + } + + fun setVideoStreamMode(mode: Phone.VideoStreamMode) { + webex.phone.setVideoStreamMode(mode) + } + + fun getVideoStreamMode(): Phone.VideoStreamMode { + return webex.phone.getVideoStreamMode() + } + + fun getCompositedLayout(): MediaOption.CompositedVideoLayout { + return getCall(currentCallId.orEmpty())?.getCompositedVideoLayout() ?: MediaOption.CompositedVideoLayout.NOT_SUPPORTED + } + + fun setCompositedLayout(compositedLayout: MediaOption.CompositedVideoLayout) { + compositedLayoutState = compositedLayout + getCall(currentCallId.orEmpty())?.setCompositedVideoLayout(compositedLayout, CompletionHandler { result -> + if (result.isSuccessful) { + Log.d(tag, "setCompositedLayout Lambda isSuccessful") + _setCompositeLayoutLiveData.postValue(Pair(true, "")) + } else { + Log.d(tag, "setCompositedLayout Lambda error: ${result.error?.errorMessage}") + _setCompositeLayoutLiveData.postValue(Pair(false, result.error?.errorMessage ?: "")) + } + }) + } + + fun closeAuxStream(view: View) { + getCall(currentCallId.orEmpty())?.closeAuxStream(view) + } + + fun getAuxStream(view: View): AuxStream? { + return getCall(currentCallId.orEmpty())?.getAuxStream(view) + } + + fun getAvailableAuxStreamCount(): Int { + return getCall(currentCallId.orEmpty())?.getAvailableAuxStreamCount() ?: 0 + } + + fun getOpenedAuxStreamCount(): Int { + return getCall(currentCallId.orEmpty())?.getOpenedAuxStreamCount() ?: 0 + } + + fun openAuxStream(view: View) { + getCall(currentCallId.orEmpty())?.openAuxStream(view) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/JWTLoginActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/JWTLoginActivity.kt new file mode 100644 index 0000000..566df3c --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/JWTLoginActivity.kt @@ -0,0 +1,82 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.HomeActivity +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkApp +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityLoginWithTokenBinding +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import org.koin.android.viewmodel.ext.android.viewModel + +class JWTLoginActivity : AppCompatActivity() { + + lateinit var binding: ActivityLoginWithTokenBinding + private val loginViewModel: LoginViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_login_with_token) + .also { binding = it } + .apply { + title.text = getString(R.string.login_jwt) + progressLayout.visibility = View.VISIBLE + loginButton.setOnClickListener { + binding.loginFailedTextView.visibility = View.GONE + if (jwtTokenText.text.isEmpty()) { + showDialogWithMessage(this@JWTLoginActivity, R.string.error_occurred, resources.getString(R.string.jwt_login_token_empty_error)) + } + else { + binding.loginButton.visibility = View.GONE + progressLayout.visibility = View.VISIBLE + val token = jwtTokenText.text.toString() + loginViewModel.loginWithJWT(token) + } + } + + loginViewModel.isAuthorized.observe(this@JWTLoginActivity, Observer { isAuthorized -> + progressLayout.visibility = View.GONE + isAuthorized?.let { + if (it) { + onLoggedIn() + } else { + onLoginFailed() + } + } + }) + + loginViewModel.isAuthorizedCached.observe(this@JWTLoginActivity, Observer { isAuthorizedCached -> + progressLayout.visibility = View.GONE + isAuthorizedCached?.let { + if (it) { + onLoggedIn() + } else { + jwtTokenText.visibility = View.VISIBLE + loginButton.visibility = View.VISIBLE + loginFailedTextView.visibility = View.GONE + } + } + }) + + loginViewModel.initialize() + } + } + + override fun onBackPressed() { + (application as KitchenSinkApp).closeApplication() + } + + private fun onLoggedIn() { + startActivity(Intent(this, HomeActivity::class.java)) + finish() + } + + private fun onLoginFailed() { + binding.loginButton.visibility = View.VISIBLE + binding.loginFailedTextView.visibility = View.VISIBLE + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivity.kt new file mode 100644 index 0000000..4b6e88a --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivity.kt @@ -0,0 +1,114 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkApp +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityLoginBinding +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.clearEmailPref +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.getLoginTypePref +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.saveEmailPref +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogForInputEmail + +class LoginActivity : AppCompatActivity() { + lateinit var binding: ActivityLoginBinding + + enum class LoginType(var value: String) { + OAuth("OAuth"), + JWT("JWT") + } + + private var loginTypeCalled = LoginType.OAuth + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_login) + .also { binding = it } + .apply { + + val type = getLoginTypePref(this@LoginActivity) + + when (type) { + LoginType.JWT.value -> { + loginTypeCalled = LoginType.JWT + (application as KitchenSinkApp).loadKoinModules(loginTypeCalled) + startActivity(Intent(this@LoginActivity, JWTLoginActivity::class.java)) + finish() + } + LoginType.OAuth.value -> { + loginTypeCalled = LoginType.OAuth + (application as KitchenSinkApp).loadKoinModules(loginTypeCalled) + startActivity(Intent(this@LoginActivity, OAuthWebLoginActivity::class.java)) + finish() + } + } + + btnJwtLogin.setOnClickListener { + buttonClicked(LoginType.JWT) + } + + btnOauthLogin.setOnClickListener { + buttonClicked(LoginType.OAuth) + } + + } + } + + private fun buttonClicked(type: LoginType) { + loginTypeCalled = type + toggleButtonsVisibility(true) + + when (type) { + LoginType.JWT -> { + startJWTActivity() + } + LoginType.OAuth -> { + showEmailDialog(type) + } + } + } + + private fun toggleButtonsVisibility(hide: Boolean) { + if (hide) { + binding.loginButtonLayout.visibility = View.GONE + binding.loginFailedTextView.visibility = View.GONE + binding.btnJwtLogin.visibility = View.GONE + } else { + binding.loginButtonLayout.visibility = View.VISIBLE + binding.loginFailedTextView.visibility = View.GONE + binding.btnJwtLogin.visibility = View.VISIBLE + } + } + + private fun startOAuthActivity() { + (application as KitchenSinkApp).loadKoinModules(loginTypeCalled) + startActivity(Intent(this@LoginActivity, OAuthWebLoginActivity::class.java)) + finish() + } + + private fun startJWTActivity() { + (application as KitchenSinkApp).loadKoinModules(loginTypeCalled) + startActivity(Intent(this@LoginActivity, JWTLoginActivity::class.java)) + finish() + } + + private fun showEmailDialog(type: LoginType) { + showDialogForInputEmail(this, getString(R.string.enter_user_email_address), onPositiveButtonClick = { dialog: DialogInterface, email: String -> + when (type) { + LoginType.OAuth -> { + saveEmailPref(this, email) + startOAuthActivity() + } + } + dialog.dismiss() + }, onNegativeButtonClick = { dialog: DialogInterface, _: Int -> + clearEmailPref(this) + toggleButtonsVisibility(false) + dialog.dismiss() + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginModule.kt new file mode 100644 index 0000000..b323422 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginModule.kt @@ -0,0 +1,11 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val loginModule = module { + + viewModel { LoginViewModel(get(), get()) } + + single { LoginRepository() } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginRepository.kt new file mode 100644 index 0000000..2841125 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginRepository.kt @@ -0,0 +1,48 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import android.webkit.WebView +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.auth.JWTAuthenticator +import com.ciscowebex.androidsdk.auth.OAuthWebViewAuthenticator +import com.ciscowebex.androidsdk.CompletionHandler +import io.reactivex.Observable +import io.reactivex.Single + +class LoginRepository() { + fun authorizeOAuth(loginWebview: WebView, oAuthAuthenticator: OAuthWebViewAuthenticator): Observable { + return Single.create { emitter -> + oAuthAuthenticator.authorize(loginWebview, CompletionHandler { result -> + if (result.error != null) { + emitter.onError(Throwable(result.error?.errorMessage)) + } else { + emitter.onSuccess(result.isSuccessful) + } + }) + }.toObservable() + } + + fun initialize(webex: Webex): Observable { + return Single.create { emitter -> + webex.initialize(CompletionHandler { result -> + if (result.error != null) { + emitter.onError(Throwable(result.error?.errorMessage)) + } else { + emitter.onSuccess(result.isSuccessful) + } + }) + }.toObservable() + } + + fun loginWithJWT(token: String, jwtAuthenticator: JWTAuthenticator): Observable { + return Single.create { emitter -> + jwtAuthenticator.authorize(token, CompletionHandler { result -> + if (result.error != null) { + emitter.onError(Throwable(result.error?.errorMessage)) + } else { + emitter.onSuccess(result.isSuccessful) + } + }) + }.toObservable() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginViewModel.kt new file mode 100644 index 0000000..e484c48 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginViewModel.kt @@ -0,0 +1,52 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import android.webkit.WebView +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.auth.JWTAuthenticator +import com.ciscowebex.androidsdk.auth.OAuthWebViewAuthenticator +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import io.reactivex.android.schedulers.AndroidSchedulers + +class LoginViewModel(private val webex: Webex, private val loginRepository: LoginRepository) : BaseViewModel() { + private val _isAuthorized = MutableLiveData() + val isAuthorized: LiveData = _isAuthorized + + private val _isAuthorizedCached = MutableLiveData() + val isAuthorizedCached: LiveData = _isAuthorizedCached + + private val _errorData = MutableLiveData() + val errorData : LiveData = _errorData + + fun authorizeOAuth(loginWebview: WebView) { + val oAuthAuthenticator = webex.authenticator as? OAuthWebViewAuthenticator + oAuthAuthenticator?.let { auth -> + loginRepository.authorizeOAuth(loginWebview, auth).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _isAuthorized.postValue(it) + }, { + _errorData.postValue(it.message) + }).autoDispose() + } ?: run { + _isAuthorized.postValue(false) + } + } + + fun initialize() { + loginRepository.initialize(webex).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _isAuthorizedCached.postValue(it) + }, {_isAuthorizedCached.postValue(false)}).autoDispose() + } + + fun loginWithJWT(token: String) { + val jwtAuthenticator = webex.authenticator as? JWTAuthenticator + jwtAuthenticator?.let { auth -> + loginRepository.loginWithJWT(token, auth).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _isAuthorized.postValue(it) + }, {_isAuthorized.postValue(false)}).autoDispose() + } ?: run { + _isAuthorized.postValue(false) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/OAuthWebLoginActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/OAuthWebLoginActivity.kt new file mode 100644 index 0000000..fff804c --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/OAuthWebLoginActivity.kt @@ -0,0 +1,84 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.HomeActivity +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkApp +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityOauthBinding +import org.koin.android.viewmodel.ext.android.viewModel + +class OAuthWebLoginActivity : AppCompatActivity() { + + lateinit var binding: ActivityOauthBinding + private val loginViewModel: LoginViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_oauth) + .also { binding = it } + .apply { + progressLayout.visibility = View.VISIBLE + + loginViewModel.isAuthorized.observe(this@OAuthWebLoginActivity, Observer { isAuthorized -> + progressLayout.visibility = View.GONE + isAuthorized?.let { + if (it) { + onLoggedIn() + } else { + onLoginFailed() + } + } + }) + + loginViewModel.isAuthorizedCached.observe(this@OAuthWebLoginActivity, Observer { isAuthorizedCached -> + progressLayout.visibility = View.GONE + isAuthorizedCached?.let { + if (it) { + onLoggedIn() + } else { + appBarLayout.visibility = View.GONE + binding.exitButton.visibility = View.GONE + loginFailedTextView.visibility = View.GONE + loginWebview.visibility = View.VISIBLE + loginViewModel.authorizeOAuth(loginWebview) + } + } + }) + + loginViewModel.errorData.observe(this@OAuthWebLoginActivity, Observer { errorMessage -> + onLoginFailed(errorMessage) + }) + + exitButton.setOnClickListener { + // close application as user needs to reload koin modules, currently unloading and reloading of koin modules doesn't work + (application as KitchenSinkApp).closeApplication() + } + + loginViewModel.initialize() + } + } + + override fun onBackPressed() { + (application as KitchenSinkApp).closeApplication() + } + + private fun onLoggedIn() { + startActivity(Intent(this, HomeActivity::class.java)) + finish() + } + + private fun onLoginFailed(failureMessage: String = getString(R.string.login_failed)) { + Log.d("auth : ", "onLoginFailed, updating ui") + binding.loginWebview.visibility = View.GONE + binding.appBarLayout.visibility = View.VISIBLE + binding.exitButton.visibility = View.VISIBLE + binding.loginFailedTextView.visibility = View.VISIBLE + binding.loginFailedTextView.text = failureMessage + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt new file mode 100644 index 0000000..d32f612 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt @@ -0,0 +1,108 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.WindowManager +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityCallBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants + + +class CallActivity : BaseActivity() { + + lateinit var binding: ActivityCallBinding + + companion object { + fun getOutgoingIntent(context: Context, callerName: String): Intent { + val intent = Intent(context, CallActivity::class.java) + intent.putExtra(Constants.Intent.CALLING_ACTIVITY_ID, 0) + intent.putExtra(Constants.Intent.OUTGOING_CALL_CALLER_ID, callerName) + return intent + } + fun getIncomingIntent(context: Context): Intent { + val intent = Intent(context, CallActivity::class.java) + intent.putExtra(Constants.Intent.CALLING_ACTIVITY_ID, 1) + return intent + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "CallActivity" + DataBindingUtil.setContentView(this, R.layout.activity_call) + .also { binding = it } + .apply { + val callingActivity = intent.getIntExtra(Constants.Intent.CALLING_ACTIVITY_ID, 0) + + if (callingActivity == 0) { + val callerId = intent.getStringExtra(Constants.Intent.OUTGOING_CALL_CALLER_ID) + val fragment = supportFragmentManager.findFragmentById(R.id.containerFragment) as CallControlsFragment + + callerId?.let { + fragment.dialOutgoingCall(callerId) + } + } else if (intent.action == Constants.Action.WEBEX_CALL_ACTION){ + intent?.getStringExtra(Constants.Intent.CALL_ID) ?.let { callId -> + handleIncomingWebexCallFromFCM(callId) + } + } + } + } + + private fun handleIncomingWebexCallFromFCM(callId: String) { + val fragment = supportFragmentManager.findFragmentById(R.id.containerFragment) + if (fragment is CallControlsFragment){ + fragment.handleFCMIncomingCall(callId) + } else { + Log.d(CallActivity::class.java.name, "fragment is null") + } + } + + override fun onBackPressed() { + val fragment = supportFragmentManager.findFragmentById(R.id.containerFragment) + if ( (fragment is CallControlsFragment) && (fragment.needBackPressed())) { + fragment.onBackPressed() + } else { + super.onBackPressed() + } + } + + fun alertDialog(shouldFinishActivity: Boolean, message: String) { + val builder = AlertDialog.Builder(this) + builder.setTitle(resources.getString(R.string.call_failed)) + builder.setMessage(message) + + builder.setPositiveButton("OK") { _, _ -> + if(shouldFinishActivity) finish() + } + + builder.show() + } + + private fun toBeShownOnLockScreen() { + window.addFlags( + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setTurnScreenOn(true) + setShowWhenLocked(true) + } else { + window.addFlags( + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + or WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + ) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + toBeShownOnLockScreen() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt new file mode 100644 index 0000000..3d1e0f7 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt @@ -0,0 +1,137 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetCallOptionsBinding +import com.ciscowebex.androidsdk.phone.Call +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.phone.MediaOption +import com.ciscowebex.androidsdk.phone.Phone + +class CallBottomSheetFragment(val receivingVideoClickListener: (Call?) -> Unit, + val receivingAudioClickListener: (Call?) -> Unit, + val receivingSharingClickListener: (Call?) -> Unit, + val scalingModeClickListener: (Call?) -> Unit, + val compositeStreamLayoutClickListener: (Call?) -> Unit): BottomSheetDialogFragment() { + companion object { + val TAG = "MessageActionBottomSheetFragment" + } + + private lateinit var binding: BottomSheetCallOptionsBinding + var call: Call? = null + lateinit var scalingModeValue: Call.VideoRenderMode + lateinit var compositeLayoutValue: MediaOption.CompositedVideoLayout + lateinit var streamMode: Phone.VideoStreamMode + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetCallOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + var receivingVideoText = getString(R.string.receiving_video) + val receivingVideoStatus = call?.isReceivingVideo() ?: false + receivingVideoText += if (receivingVideoStatus) { + " - " + getString(R.string.receiving_on) + } else { + " - " + getString(R.string.receiving_off) + } + receivingVideo.text = receivingVideoText + + receivingVideo.setOnClickListener { + dismiss() + receivingVideoClickListener(call) + } + + var receivingAudioText = getString(R.string.receiving_audio) + val receiving = call?.isReceivingAudio() ?: false + receivingAudioText += if (receiving) { + " - " + getString(R.string.receiving_on) + } else { + " - " + getString(R.string.receiving_off) + } + receivingAudio.text = receivingAudioText + + receivingAudio.setOnClickListener { + dismiss() + receivingAudioClickListener(call) + } + + var receivingSharingText = getString(R.string.receiving_sharing) + val sharing = call?.isReceivingSharing() ?: false + receivingSharingText += if (sharing) { + " - " + getString(R.string.receiving_on) + } else { + " - " + getString(R.string.receiving_off) + } + receivingSharing.text = receivingSharingText + + receivingSharing.setOnClickListener { + dismiss() + receivingSharingClickListener(call) + } + + var scalingTypeText = getString(R.string.scaling_mode) + + scalingTypeText += when (scalingModeValue) { + Call.VideoRenderMode.Fit -> { + " - " + getString(R.string.scaling_mode_fit) + } + Call.VideoRenderMode.CropFill -> { + " - " + getString(R.string.scaling_mode_cropFill) + } + Call.VideoRenderMode.StretchFill -> { + " - " + getString(R.string.scaling_mode_stretchFill) + } + Call.VideoRenderMode.NotSupported -> { + " - " + getString(R.string.scaling_mode_not_supported) + } + else -> { + " - " + getString(R.string.scaling_mode_unknown) + } + } + scalingMode.text = scalingTypeText + scalingMode.setOnClickListener { + dismiss() + scalingModeClickListener(call) + } + + var compositeLayoutText = getString(R.string.composite_stream) + + compositeLayoutText += when (compositeLayoutValue) { + MediaOption.CompositedVideoLayout.FILMSTRIP -> { + " - " + getString(R.string.composite_stream_filmstrip) + } + MediaOption.CompositedVideoLayout.GRID -> { + " - " + getString(R.string.composite_stream_grid) + } + MediaOption.CompositedVideoLayout.SINGLE -> { + " - " + getString(R.string.composite_stream_single) + } + MediaOption.CompositedVideoLayout.NOT_SUPPORTED -> { + " - " + getString(R.string.composite_stream_not_supported) + } + else -> { + " - " + getString(R.string.composite_stream_unknown) + } + } + + if (streamMode == Phone.VideoStreamMode.COMPOSITED) { + compositeStream.isEnabled = true + compositeStream.alpha = 1.0f + } else { + compositeStream.isEnabled = false + compositeStream.alpha = 0.5f + compositeLayoutText = getString(R.string.video_stream_mode_multi) + } + + compositeStream.text = compositeLayoutText + compositeStream.setOnClickListener { + dismiss() + compositeStreamLayoutClickListener(call) + } + + cancel.setOnClickListener { dismiss() } + }.root + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt new file mode 100644 index 0000000..1beb156 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt @@ -0,0 +1,1717 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.app.Activity +import android.app.AlertDialog +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.Pair +import android.view.LayoutInflater +import android.view.View +import android.view.View.OnClickListener +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.annotation.RequiresApi +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.WebexViewModel +import com.ciscowebex.androidsdk.kitchensink.calling.participants.ParticipantsFragment +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentCallControlsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemCallMeetingBinding +import com.ciscowebex.androidsdk.kitchensink.person.PersonViewModel +import com.ciscowebex.androidsdk.kitchensink.utils.AudioManagerUtils +import com.ciscowebex.androidsdk.kitchensink.utils.CallObjectStorage +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.MediaOption +import com.ciscowebex.androidsdk.phone.CallObserver +import com.ciscowebex.androidsdk.phone.CallMembership +import com.ciscowebex.androidsdk.phone.CallAssociationType +import com.ciscowebex.androidsdk.phone.CallSchedule +import com.ciscowebex.androidsdk.phone.Phone +import com.ciscowebex.androidsdk.phone.MediaRenderView +import com.ciscowebex.androidsdk.phone.MultiStreamObserver +import com.ciscowebex.androidsdk.phone.AuxStream +import org.koin.android.ext.android.inject +import android.widget.EditText +import android.widget.Toast +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogCreateSpaceBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogEnterMeetingPinBinding +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import java.util.Date + + +class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface { + private val TAG = "CallControlsFragment" + private lateinit var webexViewModel: WebexViewModel + private lateinit var binding: FragmentCallControlsBinding + private var callFailed = false + private var isIncomingActivity = false + private var callingActivity = 0 + private var audioManagerUtils: AudioManagerUtils? = null + var onLockSelfVideoMutedState = true + var onLockRemoteSharingStateON = false + val SHARE_SCREEN_FOREGROUND_SERVICE_NOTIFICATION_ID = 0xabc61 + private val ringerManager: RingerManager by inject() + private val personViewModel : PersonViewModel by inject() + private lateinit var callOptionsBottomSheetFragment: CallBottomSheetFragment + private lateinit var incomingInfoAdapter: IncomingInfoAdapter + private val mAuxStreamViewMap: HashMap = HashMap() + private var callerId: String = "" + + enum class ShareButtonState { + OFF, + ON, + DISABLED + } + + class AuxStreamViewHolder(var item: View) { + var mediaRenderView: MediaRenderView = item.findViewById(R.id.view_video) + var textView: TextView = item.findViewById(R.id.name) + var viewAvatar: ImageView = item.findViewById(R.id.view_avatar) + var remoteBorder: RelativeLayout = item.findViewById(R.id.remote_border) + } + + companion object { + const val REQUEST_CODE = 1212 + const val TAG = "CallControlsFragment" + private const val CALLER_ID = "callerId" + const val MEDIA_PROJECTION_REQUEST = 1 + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return DataBindingUtil.inflate(LayoutInflater.from(context), + R.layout.fragment_call_controls, container, false).also { binding = it }.apply { + webexViewModel = (activity as? CallActivity)?.webexViewModel!! + Log.d(TAG, "CallControlsFragment onCreateView webexViewModel: $webexViewModel") + setUpViews() + observerCallLiveData() + initAudioManager() + }.root + } + + private fun initAudioManager() { + audioManagerUtils = AudioManagerUtils(requireContext()) + } + + override fun onResume() { + Log.d(TAG, "CallControlsFragment onResume") + super.onResume() + checkIsOnHold() + webexViewModel.currentCallId?.let { + onVideoStreamingChanged(it) + } + } + + private fun checkIsOnHold() { + val isOnHold = webexViewModel.currentCallId?.let { webexViewModel.isOnHold(it) } + binding.ibHoldCall.isSelected = isOnHold ?: false + } + + private fun getMediaOption(isModerator: Boolean = false, pin: String = ""): MediaOption { + val mediaOption: MediaOption + if (webexViewModel.callCapability == WebexRepository.CallCap.Audio_Only) { + mediaOption = MediaOption.audioOnly() + } else { + mediaOption = MediaOption.audioVideoSharing(Pair(binding.localView, binding.remoteView), binding.screenShareView) + // MediaOption.audioVideo(Pair(binding.localView, binding.remoteView)) + } + mediaOption.setModerator(isModerator) + mediaOption.setPin(pin) + return mediaOption + } + + fun dialOutgoingCall(callerId: String, isModerator: Boolean = false, pin: String = "") { + Log.d(TAG, "dialOutgoingCall") + this.callerId = callerId + webexViewModel.dial(callerId, getMediaOption(isModerator, pin)) + } + + private fun checkLicenseAPIs() { + val license = webexViewModel.getVideoCodecLicense() + Log.d(TAG, "checkLicenseAPIs license $license") + val URL = webexViewModel.getVideoCodecLicenseURL() + Log.d(TAG, "checkLicenseAPIs license URL $URL") + webexViewModel.requestVideoCodecActivation(AlertDialog.Builder(activity)) + } + + override fun onConnected(call: Call?) { + Log.d(TAG, "CallObserver onConnected : " + call?.getCallId()) + onCallConnected(call?.getCallId().orEmpty()) + ringerManager.stopRinger(if (isIncomingActivity) RingerManager.RingerType.Incoming else RingerManager.RingerType.Outgoing) + webexViewModel.sendDTMF(call?.getCallId().orEmpty(), "2") + webexViewModel.sendFeedback(call?.getCallId().orEmpty(), 5, "Testing Comments SDK-v3") + webexViewModel.setShareMaxCaptureFPSSetting(5) + } + + override fun onRinging(call: Call?) { + Log.d(TAG, "CallObserver OnRinging : " + call?.getCallId()) + ringerManager.startRinger(RingerManager.RingerType.Outgoing) + } + + override fun onWaiting(call: Call?) { + Log.d(TAG, "CallObserver OnWaiting : " + call?.getCallId()) + } + + override fun onDisconnected(call: Call?, event: CallObserver.CallDisconnectedEvent?) { + Log.d(TAG, "CallObserver onDisconnected : " + call?.getCallId()) + + var callFailed = false + var callEnded = false + var localClose = false + + event?.let { _event -> + val _call = _event.getCall() + when (_event) { + is CallObserver.LocalLeft -> { + Log.d(TAG, "CallObserver LocalLeft") + localClose = true + } + is CallObserver.LocalDecline -> { + Log.d(TAG, "CallObserver LocalDecline") + } + is CallObserver.LocalCancel -> { + Log.d(TAG, "CallObserver LocalCancel") + localClose = true + } + is CallObserver.RemoteLeft -> { + Log.d(TAG, "CallObserver RemoteLeft") + } + is CallObserver.RemoteDecline -> { + Log.d(TAG, "CallObserver RemoteDecline") + } + is CallObserver.RemoteCancel -> { + Log.d(TAG, "CallObserver RemoteCancel") + } + is CallObserver.OtherConnected -> { + Log.d(TAG, "CallObserver OtherConnected") + } + is CallObserver.OtherDeclined -> { + Log.d(TAG, "CallObserver OtherDeclined") + } + is CallObserver.CallErrorEvent -> { + Log.d(TAG, "CallObserver CallErrorEvent") + callFailed = true + } + is CallObserver.CallEnded -> { + Log.d(TAG, "CallObserver CallEnded") + callEnded = true + } + else -> {} + } + } + + when { + callFailed -> { + onCallFailed(call?.getCallId().orEmpty()) + } + callEnded -> { + onCallTerminated(call?.getCallId().orEmpty()) + } + else -> { + val schedules = call?.getSchedules() + if (localClose) { + if (schedules == null && !isIncomingActivity) { + /** + * Taken care of space call when local left + */ + onCallTerminated(call?.getCallId().orEmpty()) + } else { + onCallDisconnected(call) + } + } + } + } + + ringerManager.stopRinger(if (isIncomingActivity) RingerManager.RingerType.Incoming else RingerManager.RingerType.Outgoing) + } + + override fun onInfoChanged(call: Call?) { + Log.d(TAG, "CallObserver onInfoChanged : " + call?.getCallId()) + + Handler(Looper.getMainLooper()).post { + call?.let { _call -> + binding.ibHoldCall.isSelected = _call.isOnHold() + } + } + } + + override fun onMediaChanged(call: Call?, event: CallObserver.MediaChangedEvent?) { + Log.d(TAG, "CallObserver OnMediaChanged") + + event?.let { _event -> + val call = _event.getCall() + when (_event) { + is CallObserver.RemoteSendingVideoEvent -> { + Log.d(TAG, "CallObserver OnMediaChanged RemoteSendingVideoEvent: ${_event.isSending()}") + webexViewModel.isRemoteVideoMuted = !_event.isSending() + onVideoStreamingChanged(call?.getCallId().orEmpty()) + } + is CallObserver.SendingVideo -> { + Log.d(TAG, "CallObserver OnMediaChanged SendingVideo: ${_event.isSending()}") + webexViewModel.isLocalVideoMuted = !_event.isSending() + onVideoStreamingChanged(call?.getCallId().orEmpty()) + } + is CallObserver.ReceivingVideo -> { + Log.d(TAG, "CallObserver OnMediaChanged ReceivingVideo: ${_event.isReceiving()}") + webexViewModel.isRemoteVideoMuted = !_event.isReceiving() + onVideoStreamingChanged(call?.getCallId().orEmpty()) + } + is CallObserver.RemoteSendingAudioEvent -> { + Log.d(TAG, "CallObserver OnMediaChanged RemoteSendingAudioEvent: ${_event.isSending()}") + audioEventChanged(null, call, null, _event.isSending()) + } + is CallObserver.SendingAudio -> { + Log.d(TAG, "CallObserver OnMediaChanged SendingAudio: ${_event.isSending()}") + audioEventChanged(null, call, _event.isSending()) + } + is CallObserver.ReceivingAudio -> { + Log.d(TAG, "CallObserver OnMediaChanged ReceivingAudio: ${_event.isReceiving()}") + audioEventChanged(null, call, null, _event.isReceiving()) + } + is CallObserver.RemoteSendingSharingEvent -> { + Log.d(TAG, "CallObserver OnMediaChanged RemoteSendingSharingEvent: ${_event.isSending()}") + onScreenShareStateChanged(call?.getCallId().orEmpty(), call?.getScreenShareLabel().orEmpty()) + onScreenShareVideoStreamInUseChanged(call?.getCallId().orEmpty()) + } + is CallObserver.SendingSharingEvent -> { + Log.d(TAG, "CallObserver OnMediaChanged SendingSharingEvent: ${_event.isSending()}") + onScreenShareStateChanged(call?.getCallId().orEmpty(), call?.getScreenShareLabel().orEmpty()) + onScreenShareVideoStreamInUseChanged(call?.getCallId().orEmpty()) + } + is CallObserver.ReceivingSharing -> { + Log.d(TAG, "CallObserver OnMediaChanged ReceivingSharing: ${_event.isReceiving()}") + onScreenShareStateChanged(call?.getCallId().orEmpty(), call?.getScreenShareLabel().orEmpty()) + onScreenShareVideoStreamInUseChanged(call?.getCallId().orEmpty()) + } + is CallObserver.CameraSwitched -> { + Log.d(TAG, "CallObserver CameraSwitched") + } + is CallObserver.LocalVideoViewSizeChanged -> { + Log.d(TAG, "CallObserver LocalVideoViewSizeChanged") + } + is CallObserver.RemoteVideoViewSizeChanged -> { + Log.d(TAG, "CallObserver RemoteVideoViewSizeChanged") + } + is CallObserver.LocalSharingViewSizeChanged -> { + Log.d(TAG, "CallObserver LocalSharingViewSizeChanged") + } + is CallObserver.RemoteSharingViewSizeChanged -> { + Log.d(TAG, "CallObserver RemoteSharingViewSizeChanged") + } + is CallObserver.ActiveSpeakerChangedEvent -> { + Log.d(TAG, "CallObserver ActiveSpeakerChangedEvent from: ${_event.from()}, To: ${_event.to()}") + } + else -> {} + } + } + } + + override fun onCallMembershipChanged(call: Call?, event: CallObserver.CallMembershipChangedEvent?) { + Log.d(TAG, "CallObserver OnCallMembershipEvent") + + event?.let { membershipEvent -> + val call = membershipEvent.getCall() + val callMembership = membershipEvent.getCallMembership() + when (membershipEvent) { + is CallObserver.MembershipJoinedEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipJoinedEvent") + audioEventChanged(callMembership, call) + } + is CallObserver.MembershipLeftEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipLeftEvent") + } + is CallObserver.MembershipDeclinedEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipDeclinedEvent") + } + is CallObserver.MembershipSendingVideoEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipSendingVideoEvent") + } + is CallObserver.MembershipSendingAudioEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipSendingAudioEvent") + } + is CallObserver.MembershipSendingSharingEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipSendingSharingEvent") + } + is CallObserver.MembershipWaitingEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipWaitingEvent") + } + is CallObserver.MembershipAudioMutedControlledEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipAudioMutedControlledEvent") + audioEventChanged(callMembership, call) + } + else -> {} + } + } + } + + override fun onScheduleChanged(call: Call?) { + Log.d(TAG, "CallObserver OnScheduleChanged : " + call?.getCallId()) + schedulesChanged(call) + } + + private fun observerCallLiveData() { + + personViewModel.person.observe(viewLifecycleOwner, Observer { person -> + person?.let { + webexViewModel.selfPersonId = it.personId + } + }) + + webexViewModel.startShareLiveData.observe(viewLifecycleOwner, Observer { status -> + status?.let { + if (it) { + Log.d(TAG, "startShareLiveData success") + } else { + updateScreenShareButtonState(ShareButtonState.OFF) + Log.d(TAG, "User cancelled screen request") + } + } + }) + + webexViewModel.stopShareLiveData.observe(viewLifecycleOwner, Observer { status -> + status?.let { + if (it) { + Log.d(TAG, "stopShareLiveData success") + } else { + Log.d(TAG, "stopShareLiveData Failed") + } + } + }) + + webexViewModel.setCompositeLayoutLiveData.observe(viewLifecycleOwner, Observer { result -> + result?.let { + if (it.first) { + Log.d(TAG, "setCompositeLayoutLiveData success") + webexViewModel.compositedVideoLayout = webexViewModel.compositedLayoutState + } else { + Log.d(TAG, "setCompositeLayoutLiveData Failed") + } + } + }) + + webexViewModel.setRemoteVideoRenderModeLiveData.observe(viewLifecycleOwner, Observer { result -> + result?.let { + if (it.first) { + Log.d(TAG, "setRemoteVideoRenderModeLiveData success") + } else { + Log.d(TAG, "setRemoteVideoRenderModeLiveData Failed: ${it.second}") + showDialogWithMessage(requireContext(), R.string.scaling_mode, it.second) + } + } + }) + + webexViewModel.callingLiveData.observe(viewLifecycleOwner, Observer { + it?.let { + val event = it.event + val call = it.call + val sharingLabel = it.sharingLabel + val errorMessage = it.errorMessage + + when (event) { + WebexRepository.CallEvent.DialCompleted -> { + Log.d(tag, "callingLiveData DIAL_COMPLETED callerId: ${call?.getCallId()}") + onCallJoined(call) + handleCUCMControls(call) + } + WebexRepository.CallEvent.DialFailed -> { + val callActivity = activity as CallActivity? + callActivity?.alertDialog(true, errorMessage ?: "") + } + WebexRepository.CallEvent.AnswerCompleted -> { + Log.d(TAG, "answer Lambda callInfo Id: ${call?.getCallId()}") + onCallJoined(call) + handleCUCMControls(null) + } + WebexRepository.CallEvent.AnswerFailed -> { + Log.d(TAG, "answer Lambda failed $errorMessage") + callEndedUIUpdate(call?.getCallId().orEmpty()) + } + WebexRepository.CallEvent.MeetingPinOrPasswordRequired -> { + Log.d(TAG, "CallObserver MeetingPinOrPasswordRequired : " + call?.getCallId()) + onMeetingHostPinError() + } + else -> {} + } + } + }) + + webexViewModel.startAssociationLiveData.observe(viewLifecycleOwner, Observer { + it?.let { + val event = it.event + val call = it.call + val errorMessage = it.errorMessage + + when (event) { + WebexRepository.CallEvent.AssociationCallCompleted -> { + webexViewModel.isAddedCall = true + webexViewModel.oldCallId = webexViewModel.currentCallId + webexViewModel.currentCallId = call?.getCallId()?:"" + + call?.let { _call -> + CallObjectStorage.addCallObject(_call) + } + onCallJoined(call) + handleCUCMControls(call) + Log.d(tag, "startAssociatedCall currentCallId = ${webexViewModel.currentCallId}, oldCallId = ${webexViewModel.oldCallId}") + } + WebexRepository.CallEvent.AssociationCallFailed -> { + Log.d(TAG, "startAssociatedCall Lambda failed $errorMessage") + val callActivity = activity as CallActivity? + callActivity?.alertDialog(false, resources.getString(R.string.start_associated_call_failed)) + } + else -> {} + } + } + }) + } + + private fun onMeetingHostPinError() { + showDialogWithMessage(requireContext(), getString(R.string.meeting_error), getString(R.string.are_you_host), cancelable = false, + onPositiveButtonClick = { dialog, _ -> + dialog.dismiss() + handleMeetingPinInput(true) + + }, + onNegativeButtonClick = { dialog, _ -> + dialog.dismiss() + handleMeetingPinInput(false) + }) + } + + private fun handleMeetingPinInput(isHost: Boolean) { + val builder: androidx.appcompat.app.AlertDialog.Builder = androidx.appcompat.app.AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.calling) + builder.setCancelable(false) + val hint = if (isHost) R.string.enter_host_key else R.string.enter_meeting_pin + + DialogEnterMeetingPinBinding.inflate(layoutInflater) + .apply { + builder.setView(this.root) + pinTitleLabel.text = getString(hint) + builder.setPositiveButton(android.R.string.ok) { _, _ -> + if (pinTitleEditText.text.isEmpty()) { + val error = if (isHost) getString(R.string.host_key_required) else getString(R.string.meeting_pin_required) + Toast.makeText(requireContext(), error, Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + + dialOutgoingCall(callerId, isHost, pinTitleEditText.text.toString()) + } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + + } + + private fun audioEventChanged(callMembership: CallMembership?, call: Call?, isSendingAudio: Boolean? = null, isRemoteSendingAudio: Boolean? = null) { + Handler(Looper.getMainLooper()).post { + callMembership?.let { member -> + if (member.getPersonId() == webexViewModel.selfPersonId) { + val audioMuted = !member.isSendingAudio() + + webexViewModel.isSendingAudio = member.isSendingAudio() + if (audioMuted) { + showMutedIcon(true) + } else { + showMutedIcon(false) + } + } + } ?: run { + isSendingAudio?.let { audio -> + val audioMuted = !audio + + webexViewModel.isSendingAudio = audio + if (audioMuted) { + showMutedIcon(true) + } else { + showMutedIcon(false) + } + } + } + + webexViewModel.postParticipantData(call?.getMemberships()) + showCallHeader(call?.getCallId().orEmpty()) + } + } + + private fun schedulesChanged(call: Call?) { + val schedules= call?.getSchedules() + schedules?.let { + for (item in schedules) { + incomingInfoAdapter.info.forEach { model -> + if ((model is MeetingInfoModel) && (model.meetingId == item.getId())) { + val infoModel = MeetingInfoModel.convertToMeetingInfoModel(call, item) + incomingInfoAdapter.info.remove(model) + incomingInfoAdapter.info.add(infoModel) + incomingInfoAdapter.notifyDataSetChanged() + return + } + } + } + } ?: run { + //Canceled meeting + incomingInfoAdapter.info.forEach { model -> + if (model is MeetingInfoModel) { + incomingInfoAdapter.info.remove(model) + } + } + incomingInfoAdapter.notifyDataSetChanged() + } + } + + private fun handleCUCMControls(call: Call?) { + Handler(Looper.getMainLooper()).post { + Log.d(TAG, "handleCUCMControls isAddedCall = ${webexViewModel.isAddedCall}") + webexViewModel.currentCallId?.let { callId -> + + var _call = call + + if (_call == null) { + _call = webexViewModel.getCall(callId) + } + + _call?.let { + when { + it.isCUCMCall() && webexViewModel.isAddedCall -> { + binding.ibTransferCall.visibility = View.VISIBLE + binding.ibMerge.visibility = View.VISIBLE + binding.ibAdd.visibility = View.INVISIBLE + binding.ibVideo.visibility = View.INVISIBLE + } + !it.isCUCMCall() -> { + binding.ibAdd.visibility = View.GONE + binding.ibTransferCall.visibility = View.INVISIBLE + } + } + } + } + } + } + + private fun showMutedIcon(showMuted: Boolean) { + binding.ibMute.isSelected = showMuted + } + + override fun onDestroyView() { + super.onDestroyView() + webexViewModel.callObserverInterface = null + } + + private fun setUpViews() { + Log.d(TAG, "setUpViews fragment") + personViewModel.getMe() + videoViewState(true) + + webexViewModel.callObserverInterface = this + + webexViewModel.enableBackgroundStream(webexViewModel.enableBgStreamtoggle) + webexViewModel.enableAudioBNR(true) + webexViewModel.setAudioBNRMode(Phone.AudioBRNMode.HP) + webexViewModel.setDefaultFacingMode(Phone.FacingMode.USER) + + webexViewModel.setVideoMaxTxFPSSetting(5) + webexViewModel.setVideoEnableCamera2Setting(true) + webexViewModel.setVideoEnableDecoderMosaicSetting(true) + + webexViewModel.setHardwareAccelerationEnabled(true) + webexViewModel.setVideoMaxRxBandwidth(Phone.DefaultBandwidth.MAX_BANDWIDTH_720P.getValue()) + webexViewModel.setVideoMaxTxBandwidth(Phone.DefaultBandwidth.MAX_BANDWIDTH_720P.getValue()) + webexViewModel.setSharingMaxRxBandwidth(Phone.DefaultBandwidth.MAX_BANDWIDTH_SESSION.getValue()) + webexViewModel.setAudioMaxRxBandwidth(Phone.DefaultBandwidth.MAX_BANDWIDTH_AUDIO.getValue()) + + webexViewModel.setVideoStreamMode(webexViewModel.streamMode) + + val incomingCallEvent: (Call?) -> Unit = { call -> + Log.d(tag, "incomingCallEvent") + webexViewModel.currentCallId = call?.getCallId().orEmpty() + } + + val incomingCallPickEvent: (Call?) -> Unit = { call -> + Log.d(tag, "incomingCallPickEvent") + call?.let { + webexViewModel.answer(it, getMediaOption()) + } + } + + val incomingCallCancelEvent: (Call?) -> Unit = { call -> + Log.d(tag, "incomingCallEndEvent") + endIncomingCall(call?.getCallId().orEmpty()) + } + + incomingInfoAdapter = IncomingInfoAdapter(incomingCallEvent, incomingCallPickEvent, incomingCallCancelEvent) + binding.incomingRecyclerView.adapter = incomingInfoAdapter + + callOptionsBottomSheetFragment = CallBottomSheetFragment({ call -> receivingVideoListener(call) }, + { call -> receivingAudioListener(call) }, + { call -> receivingSharingListener(call) }, + { call -> scalingModeClickListener(call) }, + { call -> compositeStreamLayoutClickListener(call) }) + + callingActivity = activity?.intent?.getIntExtra(Constants.Intent.CALLING_ACTIVITY_ID, 0)!! + if (callingActivity == 1) { + isIncomingActivity = true + binding.mainContentLayout.visibility = View.GONE + binding.incomingCallHeader.visibility = View.VISIBLE + incomingLayoutState(false) + + webexViewModel.setIncomingListener() + webexViewModel.incomingListenerLiveData.observe(viewLifecycleOwner, Observer { + it?.let { + ringerManager.startRinger(RingerManager.RingerType.Incoming) + onIncomingCall(it) + } + }) + } else { + isIncomingActivity = false + binding.incomingCallHeader.visibility = View.GONE + incomingLayoutState(true) + + binding.callingHeader.text = getString(R.string.calling) + val callerId = activity?.intent?.getStringExtra(Constants.Intent.OUTGOING_CALL_CALLER_ID) + binding.tvName.text = callerId + } + + binding.ibMute.setOnClickListener(this) + binding.ibParticipants.setOnClickListener(this) + binding.ibSpeaker.setOnClickListener(this) + binding.ibAdd.setOnClickListener(this) + binding.ibTransferCall.setOnClickListener(this) + binding.ibHoldCall.setOnClickListener(this) + binding.ivCancelCall.setOnClickListener(this) + binding.ibVideo.setOnClickListener(this) + binding.ibSwapCamera.setOnClickListener(this) + binding.ibMerge.setOnClickListener(this) + binding.ibScreenShare.setOnClickListener(this) + binding.mainContentLayout.setOnClickListener(this) + binding.ibMoreOption.setOnClickListener(this) + + initAddedCallControls() + + } + + override fun onClick(v: View?) { + webexViewModel.currentCallId?.let { callId -> + when (v) { + binding.ibMute -> { + webexViewModel.muteSelfAudio(callId) + } + binding.ibParticipants -> { + val dialog = ParticipantsFragment.newInstance(callId) + dialog.show(childFragmentManager, ParticipantsFragment::javaClass.name) + } + binding.ibSpeaker -> { + toggleSpeaker(v) + } + binding.ibAdd -> { + //while associating a call, existing call needs to be put on hold + webexViewModel.holdCall(callId) + startActivityForResult(DialerActivity.getIntent(requireContext()), REQUEST_CODE) + } + binding.ibTransferCall -> { + transferCall() + initAddedCallControls() + } + binding.ibMerge -> { + mergeCalls() + initAddedCallControls() + } + binding.ibHoldCall -> { + webexViewModel.holdCall(callId) + } + binding.ivCancelCall -> { + endCall() + } + binding.ibVideo -> { + muteSelfVideo(!webexViewModel.isLocalVideoMuted) + } + binding.ibSwapCamera -> { + val call = webexViewModel.getCall(webexViewModel.currentCallId.orEmpty()) + + call?.let { + val mode = it.getFacingMode() + + if (mode == Phone.FacingMode.ENVIROMENT) { + it.setFacingMode(Phone.FacingMode.USER) + } else { + it.setFacingMode(Phone.FacingMode.ENVIROMENT) + } + } + } + binding.ibScreenShare -> { + shareScreen() + } + binding.mainContentLayout -> { + mainContentLayoutClickListener() + } + binding.ibMoreOption -> { + webexViewModel.currentCallId?.let { + showBottomSheet(webexViewModel.getCall(it)) + } + } + else -> { + } + } + } + } + + private fun mainContentLayoutClickListener() { + Log.d(TAG, "mainContentLayoutClickListener") + if (binding.incomingRecyclerView.visibility == View.VISIBLE) { + return + } + + if (binding.controlGroup.visibility == View.VISIBLE) { + binding.controlGroup.visibility = View.GONE + } else { + binding.controlGroup.visibility = View.VISIBLE + } + } + + private fun screenShareButtonVisibilityState() { + webexViewModel.currentCallId?.let { + val canShare = webexViewModel.getCall(it)?.canShare() ?: false + Log.d(TAG, "CallControlsFragment screenShareButtonVisibilityState canShare: $canShare") + + if (canShare) { + binding.ibScreenShare.visibility = View.VISIBLE + } else { + binding.ibScreenShare.visibility = View.INVISIBLE + } + + } ?: run { + binding.ibScreenShare.visibility = View.INVISIBLE + } + } + + private fun updateScreenShareButtonState(state: ShareButtonState) { + when (state) { + ShareButtonState.OFF -> { + binding.ibScreenShare.isEnabled = true + binding.ibScreenShare.alpha = 1.0f + binding.ibScreenShare.background = ContextCompat.getDrawable(requireActivity(), R.drawable.screen_sharing_default) + } + ShareButtonState.ON -> { + binding.ibScreenShare.isEnabled = true + binding.ibScreenShare.alpha = 1.0f + binding.ibScreenShare.background = ContextCompat.getDrawable(requireActivity(), R.drawable.screen_sharing_active) + } + ShareButtonState.DISABLED -> { + binding.ibScreenShare.isEnabled = false + binding.ibScreenShare.alpha = 0.5f + } + } + } + + private fun isLocalSharing(callId: String): Boolean { + val call = webexViewModel.getCall(callId) + return call?.isSendingSharing() ?: false + } + + private fun isReceivingSharing(callId: String): Boolean { + val call = webexViewModel.getCall(callId) + return call?.isReceivingSharing() ?: false + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel(channelId: String, channelName: String): String{ + val chan = NotificationChannel(channelId, + channelName, NotificationManager.IMPORTANCE_NONE) + chan.lightColor = Color.BLUE + chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE + val service = requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + service?.createNotificationChannel(chan) + return channelId + } + + private fun buildScreenShareForegroundServiceNotification(): Notification { + val contentId = R.string.notification_start_share_foreground_text + + val channelId = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel("screen_share_service_v3_sdk", "Background Screen Share Service v3 SDK") + } else { "" } + + + val notificationBuilder = + NotificationCompat.Builder(requireContext(), channelId) + .setSmallIcon(R.drawable.app_notification_icon) + .setContentTitle(getString(R.string.notification_share_foreground_title)) + .setContentText(getString(contentId)) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setTicker(getString(contentId)) + .setDefaults(Notification.DEFAULT_SOUND) + + return notificationBuilder.build() + } + + private fun shareScreen() { + Log.d(TAG, "shareScreen") + + webexViewModel.currentCallId?.let { + val isSharing = isLocalSharing(it) + Log.d(TAG, "shareScreen isSharing: $isSharing") + if (!isSharing) { + updateScreenShareButtonState(ShareButtonState.DISABLED) + if (requireContext().applicationInfo.targetSdkVersion >= 29) { + webexViewModel.startShare(webexViewModel.currentCallId.orEmpty(), buildScreenShareForegroundServiceNotification(), SHARE_SCREEN_FOREGROUND_SERVICE_NOTIFICATION_ID) + } else { + webexViewModel.startShare(webexViewModel.currentCallId.orEmpty()) + } + } else { + updateScreenShareButtonState(ShareButtonState.DISABLED) + webexViewModel.currentCallId?.let { id -> webexViewModel.stopShare(id) } + } + } + } + + fun needBackPressed(): Boolean { + if (isIncomingActivity && + webexViewModel.currentCallId == null) { + return false + } + + return true + } + + fun onBackPressed() { + endCall() + } + + private fun endCall() { + if (isIncomingActivity) { + if (binding.incomingRecyclerView.visibility == View.VISIBLE) { + activity?.finish() + } else { + endIncomingCall() + } + } else { + webexViewModel.currentCallId?.let { + webexViewModel.hangup(it) + } ?: run { + activity?.finish() + } + } + } + + private fun incomingLayoutState(hide: Boolean) { + if (hide) { + binding.incomingRecyclerView.visibility = View.GONE + binding.mainContentLayout.visibility = View.VISIBLE + } else { + binding.incomingRecyclerView.visibility = View.VISIBLE + binding.mainContentLayout.visibility = View.GONE + + if (incomingInfoAdapter.info.size > 0) { + for (model in incomingInfoAdapter.info) { + model.isEnabled = true + } + incomingInfoAdapter.notifyDataSetChanged() + } + } + } + + private fun videoViewTextColorState(hidden: Boolean) { + var hide = hidden + if (hide && webexViewModel.isRemoteScreenShareON) { + hide = false + } + + if (hide) { + binding.callingHeader.setTextColor(ContextCompat.getColor(requireContext(), R.color.black)) + binding.tvName.setTextColor(ContextCompat.getColor(requireContext(), R.color.black)) + } else { + binding.callingHeader.setTextColor(ContextCompat.getColor(requireContext(), R.color.white)) + binding.tvName.setTextColor(ContextCompat.getColor(requireContext(), R.color.white)) + } + } + + private fun localVideoViewState(toHide: Boolean) { + if (toHide) { + binding.localViewLayout.visibility = View.GONE + binding.ibSwapCamera.visibility = View.GONE + } else { + binding.localViewLayout.visibility = View.VISIBLE + binding.ibSwapCamera.visibility = View.VISIBLE + binding.localView.setZOrderOnTop(true) + } + } + + private fun screenShareViewRemoteState(toHide: Boolean, needResize: Boolean = true) { + Log.d(TAG, "screenShareViewRemoteState toHide: $toHide") + if (toHide) { + binding.screenShareView.visibility = View.GONE + webexViewModel.isRemoteScreenShareON = false + } else { + binding.screenShareView.visibility = View.VISIBLE + webexViewModel.isRemoteScreenShareON = true + } + if (needResize) { + resizeRemoteVideoView() + } + } + + private fun resizeRemoteVideoView() { + Log.d(TAG, "resizeRemoteVideoView isRemoteScreenShareON ${webexViewModel.isRemoteScreenShareON}") + if (webexViewModel.isRemoteScreenShareON) { + val width = resources.getDimension(R.dimen.remote_video_view_width).toInt() + val height = resources.getDimension(R.dimen.remote_video_view_height).toInt() + + val params = ConstraintLayout.LayoutParams(width, height) + params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + params.leftToLeft = ConstraintLayout.LayoutParams.PARENT_ID + params.marginStart = resources.getDimension(R.dimen.remote_video_view_margin_start).toInt() + params.bottomMargin = resources.getDimension(R.dimen.remote_video_view_margin_Bottom).toInt() + binding.remoteViewLayout.layoutParams = params + binding.remoteViewLayout.background = ContextCompat.getDrawable(requireActivity(), R.drawable.surfaceview_border) + binding.remoteView.setZOrderOnTop(true) + } else { + val params = ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.MATCH_PARENT) + params.leftToLeft = ConstraintLayout.LayoutParams.PARENT_ID + params.rightToRight = ConstraintLayout.LayoutParams.PARENT_ID + params.topToTop = ConstraintLayout.LayoutParams.PARENT_ID + binding.remoteViewLayout.layoutParams = params + binding.remoteViewLayout.background = ContextCompat.getDrawable(requireActivity(), R.drawable.surfaceview_transparent_border) + binding.remoteView.setZOrderOnTop(false) + } + } + + private fun videoViewState(toHide: Boolean) { + localVideoViewState(toHide) + if (toHide) { + binding.remoteViewLayout.visibility = View.GONE + } else { + binding.remoteViewLayout.visibility = View.VISIBLE + } + + videoViewTextColorState(toHide) + videoButtonState(toHide) + } + + private fun videoButtonState(videoViewHidden: Boolean) { + if (videoViewHidden) { + binding.ibVideo.background = ContextCompat.getDrawable(requireActivity(), R.drawable.turn_off_video_active) + } else { + binding.ibVideo.background = ContextCompat.getDrawable(requireActivity(), R.drawable.turn_on_video_default) + } + } + + private fun endIncomingCall() { + webexViewModel.currentCallId?.let { + endIncomingCall(it) + } ?: run { + activity?.finish() + } + } + + private fun endIncomingCall(callId: String) { + if (webexViewModel.incomingCallJoinedCallId != null && webexViewModel.incomingCallJoinedCallId == callId) + webexViewModel.hangup(callId) + else + webexViewModel.rejectCall(callId) + } + + private fun onCallConnected(callId: String) { + Log.d(TAG, "CallControlsFragment onCallConnected callerId: $callId, currentCallId: ${webexViewModel.currentCallId}") + + Handler(Looper.getMainLooper()).post { + + val layout = webexViewModel.getCompositedLayout() + Log.d(TAG, "onCallConnected getCompositedLayout: $layout") + webexViewModel.setCompositedLayout(layout) + webexViewModel.setRemoteVideoRenderMode(callId, webexViewModel.scalingMode) + + webexViewModel.getCall(callId)?.setMultiStreamObserver(object : MultiStreamObserver { + override fun onAuxStreamChanged(event: MultiStreamObserver.AuxStreamChangedEvent?) { + Log.d(tag, "MultiStreamObserver onAuxStreamChanged : $event") + Handler(Looper.getMainLooper()).post { + val auxStream: AuxStream? = event?.getAuxStream() + + when (event) { + is MultiStreamObserver.AuxStreamOpenedEvent -> { + if (event.isSuccessful()) { + val auxStreamViewHolder = mAuxStreamViewMap[event.getRenderView()] + Log.d(tag, "MultiStreamObserver AuxStreamOpenedEvent successful") + auxStreamViewHolder?.let { + binding.viewAuxVideos.addView(it.item) + val membership = auxStream?.getPerson() + Log.d(tag, "MultiStreamObserver AuxStreamOpenedEvent successful membership: " + membership?.getDisplayName()) + it.textView.text = membership?.getDisplayName() + } + } else { + Log.d(tag, "MultiStreamObserver AuxStreamOpenedEvent failed: " + event.getError()?.errorMessage) + mAuxStreamViewMap.remove(event.getRenderView()) + } + } + is MultiStreamObserver.AuxStreamClosedEvent -> { + if (event.isSuccessful()) { + Log.d(tag, "MultiStreamObserver AuxStreamClosedEvent successful") + val auxStreamViewHolder = mAuxStreamViewMap[event.getRenderView()] + mAuxStreamViewMap.remove(event.getRenderView()) + binding.viewAuxVideos.removeView(auxStreamViewHolder?.item) + } else { + Log.d(tag, "MultiStreamObserver AuxStreamClosedEvent failed: " + event.getError()?.errorMessage) + } + } + is MultiStreamObserver.AuxStreamSendingVideoEvent -> { + Log.d(tag, "AuxStreamSendingVideoEvent: " + auxStream?.isSendingVideo()) + auxStream?.let { + val auxStreamViewHolder = mAuxStreamViewMap[it.getRenderView()] + + if (auxStreamViewHolder != null) { + if (it.isSendingVideo()) { + auxStreamViewHolder.viewAvatar.visibility = View.GONE + } else { + val membership = it.getPerson() + membership?.let { member -> + if (member.getPersonId().isNotEmpty()) { + auxStreamViewHolder.viewAvatar.visibility = View.VISIBLE + } + } + } + } + } + } + is MultiStreamObserver.AuxStreamPersonChangedEvent -> { + Log.d(tag, "MultiStreamObserver AuxStreamPersonChangedEvent getPerson: " + auxStream?.getPerson() + " from: " + event.from() + " to: " + event.to()) + auxStream?.let { + val auxStreamViewHolder = mAuxStreamViewMap[it.getRenderView()] + val membership = it.getPerson() + membership?.let { member -> + Log.d(tag, "MultiStreamObserver AuxStreamPersonChangedEvent name: " + member.getDisplayName()) + auxStreamViewHolder?.viewAvatar?.visibility = if (it.isSendingVideo()) View.GONE else View.VISIBLE + auxStreamViewHolder?.textView?.text = member.getDisplayName() + } + } + } + is MultiStreamObserver.AuxStreamSizeChangedEvent -> { + Log.d(tag, "MultiStreamObserver AuxStreamSizeChangedEvent width: " + event.getAuxStream()?.getSize()?.width + + " height: " + event.getAuxStream()?.getSize()?.height) + } + } + } + } + + override fun onAuxStreamAvailable(): View? { + Log.d(tag, "MultiStreamObserver onAuxStreamAvailable") + val auxStreamView: View = LayoutInflater.from(activity).inflate(R.layout.remote_video_view, null) + val auxStreamViewHolder = AuxStreamViewHolder(auxStreamView) + mAuxStreamViewMap[auxStreamViewHolder.mediaRenderView] = auxStreamViewHolder + return auxStreamViewHolder.mediaRenderView + } + + override fun onAuxStreamUnavailable(): View? { + Log.d(tag, "MultiStreamObserver onAuxStreamUnavailable") + return null + } + + }) + + if (callId == webexViewModel.currentCallId) { + val callInfo = webexViewModel.getCall(callId) + + var isSelfVideoMuted = true + callInfo?.let { _callInfo -> + isSelfVideoMuted = !_callInfo.isSendingVideo() + webexViewModel.isRemoteVideoMuted = !_callInfo.isReceivingVideo() + Log.d(TAG, "CallControlsFragment onCallConnected isAudioOnly: ${_callInfo.isAudioOnly()} isSelfVideoMuted: ${isSelfVideoMuted}, webexViewModel.isRemoteVideoMuted: ${webexViewModel.isRemoteVideoMuted}") + Log.d(TAG, "CallControlsFragment onCallConnected from: ${_callInfo.getFrom()?.getDisplayName()} to: ${_callInfo.getTo()?.getDisplayName()}") + } + + if (isIncomingActivity) { + if (callId == webexViewModel.currentCallId) { + binding.videoCallLayout.visibility = View.VISIBLE + incomingLayoutState(true) + } + } + + webexViewModel.isLocalVideoMuted = isSelfVideoMuted + + if (webexViewModel.isLocalVideoMuted) { + localVideoViewState(true) + videoButtonState(true) + } else { + localVideoViewState(false) + videoButtonState(false) + } + + if (webexViewModel.isRemoteVideoMuted) { + binding.remoteViewLayout.visibility = View.GONE + } else { + binding.remoteViewLayout.visibility = View.VISIBLE + } + + binding.controlGroup.visibility = View.VISIBLE + + screenShareButtonVisibilityState() + videoViewTextColorState(webexViewModel.isRemoteVideoMuted) + + } + + } + } + + private fun onScreenShareStateChanged(callId: String, label: String) { + Log.d(TAG, "CallControlsFragment onScreenShareStateChanged callerId: $callId, label: $label") + + if (webexViewModel.currentCallId != callId) { + return + } + + Handler(Looper.getMainLooper()).post { + + val callInfo = webexViewModel.getCall(callId) + + val remoteSharing = isReceivingSharing(callId) + val localSharing = isLocalSharing(callId) + Log.d(TAG, "CallControlsFragment onScreenShareStateChanged isRemoteSharing: ${remoteSharing}, isLocalSharing: ${localSharing}") + + if (localSharing) { + updateScreenShareButtonState(ShareButtonState.ON) + } else { + updateScreenShareButtonState(ShareButtonState.OFF) + } + } + } + + private fun onScreenShareVideoStreamInUseChanged(callId: String) { + Log.d(TAG, "CallControlsFragment onScreenShareVideoStreamInUseChanged callerId: $callId") + + if (webexViewModel.currentCallId != callId) { + return + } + + Handler(Looper.getMainLooper()).post { + + val remoteSharing = isReceivingSharing(callId) + val localSharing = isLocalSharing(callId) + Log.d(TAG, "CallControlsFragment onScreenShareVideoStreamInUseChanged isRemoteSharing: ${remoteSharing}, isLocalSharing: ${localSharing}") + if (remoteSharing) { + binding.controlGroup.visibility = View.GONE + screenShareViewRemoteState(false) + val view = webexViewModel.getSharingRenderView(callId) + if (view == null) { + webexViewModel.setSharingRenderView(callId, binding.screenShareView) + } + } + else { + onVideoStreamingChanged(callId) + screenShareViewRemoteState(true) + binding.controlGroup.visibility = View.VISIBLE + } + + videoViewTextColorState(!remoteSharing) + } + } + + private fun onVideoStreamingChanged(callId: String) { + Log.d(TAG, "CallControlsFragment onVideoStreamingChanged callerId: $callId") + + if (webexViewModel.currentCallId == null) { + return + } + + Handler(Looper.getMainLooper()).post { + + if (webexViewModel.isLocalVideoMuted) { + localVideoViewState(true) + } else { + localVideoViewState(false) + val pair = webexViewModel.getVideoRenderViews(callId) + if (pair.first == null) { + webexViewModel.setVideoRenderViews(callId, binding.localView, binding.remoteView) + } + } + + if (webexViewModel.isRemoteVideoMuted) { + binding.remoteViewLayout.visibility = View.GONE + } else { + if (webexViewModel.isRemoteScreenShareON) { + resizeRemoteVideoView() + } + binding.remoteViewLayout.visibility = View.VISIBLE + val pair = webexViewModel.getVideoRenderViews(callId) + if (pair.second == null) { + webexViewModel.setVideoRenderViews(callId, binding.localView, binding.remoteView) + } + } + + videoViewTextColorState(webexViewModel.isRemoteVideoMuted) + + Log.d(TAG, "CallControlsFragment onVideoStreamingChanged isLocalVideoMuted: ${webexViewModel.isLocalVideoMuted}, isRemoteVideoMuted: ${webexViewModel.isRemoteVideoMuted}") + + if (webexViewModel.isLocalVideoMuted) { + videoButtonState(true) + } else { + videoButtonState(false) + } + } + } + + private fun toggleSpeaker(v: View) { + v.isSelected = !v.isSelected + when { + v.isSelected -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.SPEAKER) + } + audioManagerUtils?.isBluetoothHeadsetConnected == true -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.BLUETOOTH_HEADSET) + } + audioManagerUtils?.isWiredHeadsetOn == true -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.HEADSET) + } + else -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.PHONE) + } + } + } + + internal fun handleFCMIncomingCall(callId: String) { + Handler(Looper.getMainLooper()).post { + webexViewModel.setFCMIncomingListenerObserver(callId) + onIncomingCall(webexViewModel.getCall(callId)) + } + } + + private fun onIncomingCall(call: Call?) { + Handler(Looper.getMainLooper()).post { + + Log.d(TAG, "CallControlsFragment onIncomingCall callerId: ${call?.getCallId()}, callInfo title: ${call?.getTitle()}") + + binding.incomingCallHeader.visibility = View.GONE + + val schedules= call?.getSchedules() + incomingLayoutState(false) + + schedules?.let { + val item = schedules.first() + if (!checkIncomingAdapterList(item)) { + val model = MeetingInfoModel.convertToMeetingInfoModel(call, item) + incomingInfoAdapter.info.add(model) + Log.d(TAG, "CallControlsFragment onIncomingCall schedules size: ${schedules.size}") + } + } ?: run { + val group = call?.isGroupCall() ?: false + if (group) { + val model = SpaceIncomingCallModel(call) + incomingInfoAdapter.info.add(model) + } else { + val model = OneToOneIncomingCallModel(call) + incomingInfoAdapter.info.add(model) + } + } + + incomingInfoAdapter.notifyDataSetChanged() + } + } + + private fun checkIncomingAdapterList(item: CallSchedule): Boolean { + incomingInfoAdapter.info.forEach { _model -> + if ((_model is MeetingInfoModel) && (_model.meetingId == item.getId())) { + return true + } + } + + return false + } + + private fun onCallJoined(call: Call?) { + Log.d(TAG, "CallControlsFragment onCallJoined callerId: ${call?.getCallId().orEmpty()}, currentCallId: ${webexViewModel.currentCallId}") + Handler(Looper.getMainLooper()).post { + if (call?.getCallId().orEmpty() == webexViewModel.currentCallId) { + showCallHeader(call?.getCallId().orEmpty()) + call?.let { + val schedules = it.getSchedules() + schedules?.let { + binding.callingHeader.text = getString(R.string.meeting) + } + } + } + if (callingActivity == 1) { + webexViewModel.incomingCallJoinedCallId = call?.getCallId().orEmpty() + } + Log.d(TAG,"CallControlsFragment callingHeader text: ${binding.callingHeader.text}") + } + } + + private fun showCallHeader(callId: String) { + Handler(Looper.getMainLooper()).post { + try { + val callInfo = webexViewModel.getCall(callId) + Log.d(TAG, "CallControlsFragment showCallHeader callerId: $callId, callInfo title: ${callInfo?.getTitle()}") + + binding.tvName.text = callInfo?.getTitle() + binding.callingHeader.text = getString(R.string.onCall) + } catch (e: Exception) { + Log.d(TAG, "error: ${e.message}") + } + } + } + + private fun onCallFailed(callId: String) { + Log.d(TAG, "CallControlsFragment onCallFailed callerId: $callId") + + Handler(Looper.getMainLooper()).post { + if (webexViewModel.isAddedCall) { + resumePrevCallIfAdded(callId) + updateCallHeader() + } + + callFailed = !webexViewModel.isAddedCall + + val callActivity = activity as CallActivity? + callActivity?.alertDialog(!webexViewModel.isAddedCall, "") + } + } + + private fun onCallDisconnected(call: Call?) { + call?.let { _call -> + Log.d(TAG, "CallControlsFragment onCallDisconnected callerId: ${_call.getCallId().orEmpty()}") + Handler(Looper.getMainLooper()).post { + val schedules = call.getSchedules() + schedules?.let { + incomingLayoutState(false) + } ?: run { + if (call.isGroupCall()) { + incomingLayoutState(false) + } + } + } + } + } + + private fun onCallTerminated(callId: String) { + Log.d(TAG, "CallControlsFragment onCallTerminated callerId: $callId") + + Handler(Looper.getMainLooper()).post { + if (webexViewModel.isAddedCall) { + resumePrevCallIfAdded(callId) + updateCallHeader() + initAddedCallControls() + } + + CallObjectStorage.removeCallObject(callId) + + if (!callFailed && !webexViewModel.isAddedCall) { + callEndedUIUpdate(callId, true) + } + webexViewModel.isAddedCall = false + } + } + + private fun callEndedUIUpdate(callId: String, terminated: Boolean = false) { + if (isIncomingActivity) { + for (model in incomingInfoAdapter.info) { + if ( (model is OneToOneIncomingCallModel) && (model.call?.getCallId() == callId)) { + incomingInfoAdapter.info.remove(model) + break + } else if (model is MeetingInfoModel) { + if (Date().after(model.endTime)) { + incomingInfoAdapter.info.remove(model) + break + } + + if (terminated && (model.call?.getCallId().orEmpty() == callId)) { + incomingInfoAdapter.info.remove(model) + break + } + } else if ( (model is SpaceIncomingCallModel) && (model.call?.getCallId() == callId)) { + incomingInfoAdapter.info.remove(model) + break + } + } + incomingInfoAdapter.notifyDataSetChanged() + + if (incomingInfoAdapter.info.isNotEmpty()) { + webexViewModel.currentCallId = null + incomingLayoutState(false) + } else { + activity?.finish() + } + } else { + activity?.finish() + } + } + + private fun initAddedCallControls() { + binding.ibTransferCall.visibility = View.INVISIBLE + binding.ibVideo.visibility = View.VISIBLE + + binding.ibAdd.visibility = View.VISIBLE + binding.ibMerge.visibility = View.INVISIBLE + } + + private fun onNewCallHeader(callerId: String?) { + binding.callingHeader.text = getString(R.string.calling) + binding.tvName.text = callerId + } + + private fun resumePrevCallIfAdded(callId: String) { + //resume old call + if (callId == webexViewModel.currentCallId) { + webexViewModel.currentCallId = webexViewModel.oldCallId + Log.d(TAG, "resumePrevCallIfAdded currentCallId = ${webexViewModel.currentCallId}") + webexViewModel.currentCallId?.let { _currentCallId -> + webexViewModel.holdCall(_currentCallId) + } + webexViewModel.oldCallId = null //old is disconnected need to make it null + } + } + + private fun updateCallHeader() { + webexViewModel.currentCallId?.let { + showCallHeader(it) + } + } + + private fun startAssociatedCall(dialNumber: String, associationType: CallAssociationType, audioCall: Boolean) { + Log.d(tag, "startAssociatedCall dialNumber = $dialNumber : associationType = $associationType : audioCall = $audioCall") + webexViewModel.currentCallId?.let { callId -> + onNewCallHeader(callId) + webexViewModel.startAssociatedCall(callId, dialNumber, associationType, audioCall) + } + } + + private fun transferCall() { + Log.d(tag, "transferCall currentCallId = ${webexViewModel.currentCallId}, oldCallId = ${webexViewModel.oldCallId}") + if (webexViewModel.currentCallId != null && webexViewModel.oldCallId != null) { + webexViewModel.transferCall(webexViewModel.oldCallId!!, webexViewModel.currentCallId!!) + } + } + + private fun mergeCalls() { + Log.d(tag, "mergeCalls currentCallId = ${webexViewModel.currentCallId}, targetCallId = ${webexViewModel.oldCallId}") + if (webexViewModel.currentCallId != null && webexViewModel.oldCallId != null) { + webexViewModel.mergeCalls(webexViewModel.currentCallId!!, webexViewModel.oldCallId!!) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK) { + val callNumber = data?.getStringExtra(CALLER_ID) ?: "" + //start call association to add new person on call + startAssociatedCall(callNumber, CallAssociationType.Transfer, true) + } + } + + private fun muteSelfVideo(value: Boolean) { + webexViewModel.currentCallId?.let { + webexViewModel.muteSelfVideo(it, value) + } + } + + private fun receivingVideoListener(call: Call?) { + Log.d(TAG, "receivingVideoListener") + call?.let { + if (it.isReceivingVideo()) { + webexViewModel.setReceivingVideo(it, false) + } else { + webexViewModel.setReceivingVideo(it, true) + } + } + } + + private fun receivingAudioListener(call: Call?) { + Log.d(TAG, "receivingAudioListener") + call?.let { + if (it.isReceivingAudio()) { + webexViewModel.setReceivingAudio(it, false) + } else { + webexViewModel.setReceivingAudio(it, true) + } + } + } + + private fun receivingSharingListener(call: Call?) { + Log.d(TAG, "receivingSharingListener") + call?.let { + if (it.isReceivingSharing()) { + webexViewModel.setReceivingSharing(it, false) + } else { + webexViewModel.setReceivingSharing(it, true) + } + } + } + + private fun compositeStreamLayoutClickListener(call: Call?) { + Log.d(TAG, "compositeStreamLayoutClickListener getCompositedLayout: ${webexViewModel.getCompositedLayout()}") + + if (webexViewModel.compositedVideoLayout == MediaOption.CompositedVideoLayout.NOT_SUPPORTED) { + showDialogWithMessage(requireContext(), R.string.composite_stream, resources.getString(R.string.composite_stream_not_supported)) + return + } + + var layout = webexViewModel.compositedVideoLayout + + when (layout) { + MediaOption.CompositedVideoLayout.FILMSTRIP -> { + layout = MediaOption.CompositedVideoLayout.GRID + } + MediaOption.CompositedVideoLayout.GRID -> { + layout = MediaOption.CompositedVideoLayout.SINGLE + } + MediaOption.CompositedVideoLayout.SINGLE -> { + layout = MediaOption.CompositedVideoLayout.FILMSTRIP + } + else -> {} + } + + webexViewModel.setCompositedLayout(layout) + } + + private fun scalingModeClickListener(call: Call?) { + Log.d(TAG, "scalingModeClickListener") + + when (webexViewModel.scalingMode) { + Call.VideoRenderMode.Fit -> { + webexViewModel.scalingMode = Call.VideoRenderMode.CropFill + } + Call.VideoRenderMode.CropFill -> { + webexViewModel.scalingMode = Call.VideoRenderMode.StretchFill + } + Call.VideoRenderMode.StretchFill -> { + webexViewModel.scalingMode = Call.VideoRenderMode.Fit + } + } + + webexViewModel.setRemoteVideoRenderMode(call?.getCallId().orEmpty(), webexViewModel.scalingMode) + } + + private fun showBottomSheet(call: Call?) { + callOptionsBottomSheetFragment.call = call + callOptionsBottomSheetFragment.scalingModeValue = webexViewModel.scalingMode + callOptionsBottomSheetFragment.compositeLayoutValue = webexViewModel.compositedVideoLayout + callOptionsBottomSheetFragment.streamMode = webexViewModel.streamMode + activity?.supportFragmentManager?.let { callOptionsBottomSheetFragment.show(it, CallBottomSheetFragment.TAG) } + } + + class IncomingInfoAdapter(private val incomingCallEvent: (Call?) -> Unit, private val IncomingCallPickEvent: (Call?) -> Unit, private val incomingCallCancelEvent: (Call?) -> Unit) : RecyclerView.Adapter() { + var info: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): IncomingInfoViewHolder { + return IncomingInfoViewHolder(ListItemCallMeetingBinding.inflate(LayoutInflater.from(parent.context), parent, false), incomingCallEvent, IncomingCallPickEvent, incomingCallCancelEvent) + } + + override fun getItemCount(): Int = info.size + + override fun onBindViewHolder(holder: IncomingInfoViewHolder, position: Int) { + holder.bind(info[position]) + } + } + + class IncomingInfoViewHolder(private val binding: ListItemCallMeetingBinding, private val incomingCallEvent: (Call?) -> Unit, + private val IncomingCallPickEvent: (Call?) -> Unit, private val incomingCallCancelEvent: (Call?) -> Unit) : RecyclerView.ViewHolder(binding.root) { + var item: IncomingCallInfoModel? = null + val tag = "IncomingInfoViewHolder" + init { + binding.meetingJoinButton.setOnClickListener { + item?.let { model -> + if (model is MeetingInfoModel) { + incomingCallEvent(model.call) + Log.d(tag, "JoinButton clicked meetingInfo: ${model.subject}") + IncomingCallPickEvent(model.call) + model.isEnabled = false + binding.meetingJoinButton.alpha = 0.5f + binding.meetingJoinButton.isEnabled = false + } + else if (model is SpaceIncomingCallModel) { + incomingCallEvent(model.call) + Log.d(tag, "JoinButton clicked SpaceCall") + IncomingCallPickEvent(model.call) + model.isEnabled = false + binding.meetingJoinButton.alpha = 0.5f + binding.meetingJoinButton.isEnabled = false + } + } + } + + binding.ivPickCall.setOnClickListener { + item?.let { model -> + if (model is OneToOneIncomingCallModel) { + incomingCallEvent(model.call) + Log.d(tag, "ivPickCall clicked") + IncomingCallPickEvent(model.call) + model.isEnabled = false + binding.ivPickCall.alpha = 0.5f + binding.ivPickCall.isEnabled = false + } + } + } + + binding.ivCancelCall.setOnClickListener { + item?.let { model -> + if (model is OneToOneIncomingCallModel) { + incomingCallCancelEvent(model.call) + } + } + } + } + + fun bind(model: IncomingCallInfoModel) { + item = model + + if (model is MeetingInfoModel) { + if (model.isEnabled) { + binding.meetingJoinButton.alpha = 1.0f + binding.meetingJoinButton.isEnabled = true + } else { + binding.meetingJoinButton.alpha = 0.5f + binding.meetingJoinButton.isEnabled = false + } + + binding.titleTextView.text = model.subject + binding.meetingTimeTextView.text = model.timeString + binding.meetingTimeTextView.visibility = View.VISIBLE + binding.callingOneToOneButtonLayout.visibility = View.GONE + binding.meetingJoinButton.visibility = View.VISIBLE + } else if (model is OneToOneIncomingCallModel) { + if (model.isEnabled) { + binding.ivPickCall.alpha = 1.0f + binding.ivPickCall.isEnabled = true + } else { + binding.ivPickCall.alpha = 0.5f + binding.ivPickCall.isEnabled = false + } + + binding.meetingJoinButton.visibility = View.GONE + binding.meetingTimeTextView.visibility = View.GONE + binding.callingOneToOneButtonLayout.visibility = View.VISIBLE + binding.titleTextView.text = model.call?.getTitle() + } else if (model is SpaceIncomingCallModel) { + if (model.isEnabled) { + binding.meetingJoinButton.alpha = 1.0f + binding.meetingJoinButton.isEnabled = true + } else { + binding.meetingJoinButton.alpha = 0.5f + binding.meetingJoinButton.isEnabled = false + } + + binding.meetingTimeTextView.visibility = View.GONE + binding.titleTextView.text = model.call?.getTitle() + binding.callingOneToOneButtonLayout.visibility = View.GONE + binding.meetingJoinButton.visibility = View.VISIBLE + } + binding.executePendingBindings() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt new file mode 100644 index 0000000..96b5666 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt @@ -0,0 +1,10 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val callModule = module { + viewModel { + CallViewModel(get()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt new file mode 100644 index 0000000..67d3a6f --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt @@ -0,0 +1,21 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.CallObserver + +/* +* This interface is written to overcome the limitation of live data postValue. +* When SDK pushes media events continuously then some events were getting lost. +* When post value gets trigger continuously then the latest value replaces the previous one and then the previous value doesn't reach to the UI observer. +* To overcome that limitation, the interface registration happens from UI and the all events now directly reaches to UI without any postValue. +* */ +interface CallObserverInterface { + fun onConnected(call: Call?) {} + fun onRinging(call: Call?) {} + fun onWaiting(call: Call?) {} + fun onDisconnected(call: Call?, event: CallObserver.CallDisconnectedEvent?) {} + fun onInfoChanged(call: Call?) {} + fun onMediaChanged(call: Call?, event: CallObserver.MediaChangedEvent?) {} + fun onCallMembershipChanged(call: Call?, event: CallObserver.CallMembershipChangedEvent?) {} + fun onScheduleChanged(call: Call?) {} +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallViewModel.kt new file mode 100644 index 0000000..3b2a4a2 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallViewModel.kt @@ -0,0 +1,6 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import androidx.lifecycle.ViewModel +import com.ciscowebex.androidsdk.Webex + +class CallViewModel(private val webex: Webex) : ViewModel() { } \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragment.kt new file mode 100644 index 0000000..a874918 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragment.kt @@ -0,0 +1,173 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentCallBinding +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.hideKeyboard +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.showKeyboard + +class DialFragment : Fragment() { + + lateinit var binding: FragmentCallBinding + private var isAddingCall = false + + companion object{ + private const val IS_ADDING_CALL = "isAddingCall" + private const val CALLER_ID = "callerId" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return FragmentCallBinding.inflate(inflater, container, false) + .also { binding = it } + .apply { + isAddingCall = arguments?.getBoolean(IS_ADDING_CALL) ?: false + } + .root + } + + @SuppressLint("SetTextI18n") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val dialKeysList = listOf( + binding.tvNumber1, + binding.tvNumber2, + binding.tvNumber3, + binding.tvNumber4, + binding.tvNumber5, + binding.tvNumber6, + binding.tvNumber7, + binding.tvNumber8, + binding.tvNumber9, + binding.tvNumberStar, + binding.tvNumberHash + ) + for (dialKey in dialKeysList) { + dialKey.setOnClickListener { updateDialText(it) } + } + + binding.ibStartCall.setOnClickListener { + val dialText = binding.etDialInput.text.toString() + if(isAddingCall){ + val intent = Intent() + intent.putExtra(CALLER_ID, dialText) + activity?.setResult(Activity.RESULT_OK, intent) + activity?.finish() + }else{ + startActivity(context?.let { ctx -> CallActivity.getOutgoingIntent(ctx, dialText) }) + } + } + + binding.ibKeypadToggle.setOnClickListener { + binding.dialButtonsContainer.visibility = View.GONE + enableInput() + binding.toggleButtonsContainer.showNext() + } + + binding.ibBackspace.setOnClickListener { + var str = binding.etDialInput.text.toString() + if (str.isNotEmpty()) { + str = str.substring(0, str.length - 1) + binding.etDialInput.setText(str) + binding.etDialInput.setSelection(binding.etDialInput.text.length) + } + } + + binding.ibBackspace.setOnLongClickListener { + binding.etDialInput.setText("") + true + } + + binding.llNumber0.setOnLongClickListener { + binding.etDialInput.append(getString(R.string.number_plus)) + true + } + + binding.llNumber0.setOnClickListener { + binding.etDialInput.setText(binding.etDialInput.text.toString() + "0") + } + + disableInput() + + binding.ibNumpadToggle.setOnClickListener { + disableInput() + binding.dialButtonsContainer.visibility = View.VISIBLE + binding.toggleButtonsContainer.showNext() + } + } + + private fun enableInput() { + binding.etDialInput.inputType = InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS + binding.etDialInput.setSelection(binding.etDialInput.text.length) + binding.etDialInput.showKeyboard() + binding.etDialInput.requestFocus() + } + + private fun disableInput() { + binding.etDialInput.inputType = InputType.TYPE_NULL + context?.hideKeyboard(binding.etDialInput) + } + + override fun onResume() { + super.onResume() + if (binding.ibNumpadToggle.visibility == View.VISIBLE) { + binding.etDialInput.showKeyboard() + } + } + + override fun onPause() { + super.onPause() + context?.hideKeyboard(binding.etDialInput) + } + + @SuppressLint("SetTextI18n") + private fun updateDialText(view: View?) { + val editText = binding.etDialInput + when (view?.id) { + R.id.tv_number_1 -> { + editText.setText(editText.text.toString() + "1") + } + R.id.tv_number_2 -> { + editText.setText(editText.text.toString() + "2") + } + R.id.tv_number_3 -> { + editText.setText(editText.text.toString() + "3") + } + R.id.tv_number_4 -> { + editText.setText(editText.text.toString() + "4") + } + R.id.tv_number_5 -> { + editText.setText(editText.text.toString() + "5") + } + R.id.tv_number_6 -> { + editText.setText(editText.text.toString() + "6") + } + R.id.tv_number_7 -> { + editText.setText(editText.text.toString() + "7") + } + R.id.tv_number_8 -> { + editText.setText(editText.text.toString() + "8") + } + R.id.tv_number_9 -> { + editText.setText(editText.text.toString() + "9") + } + R.id.tv_number_star -> { + editText.setText(editText.text.toString() + "*") + } + R.id.tv_number_hash -> { + editText.setText(editText.text.toString() + "#") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialerActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialerActivity.kt new file mode 100644 index 0000000..ee6ca31 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialerActivity.kt @@ -0,0 +1,56 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityDialerBinding + +class DialerActivity : AppCompatActivity(){ + lateinit var binding: ActivityDialerBinding + + companion object{ + const val IS_ADDING_CALL = "isAddingCall" + fun getIntent(context: Context): Intent{ + return Intent(context, DialerActivity::class.java) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_dialer).also { + binding = it + }.apply { + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + setDialerFragment() + handleNavigationClickListener() + } + + } + + private fun handleNavigationClickListener() { + binding.toolbar.setNavigationOnClickListener { onBackPressed() } + } + + private fun setDialerFragment() { + val dialFragment = DialFragment() + val bundle = Bundle() + bundle.putBoolean(IS_ADDING_CALL, true) + dialFragment.arguments = bundle + + val transaction = supportFragmentManager.beginTransaction() + transaction.replace(R.id.container, dialFragment) + transaction.commit() + } + + override fun onBackPressed() { + setResult(Activity.RESULT_CANCELED) + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/IncomingCallInfoModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/IncomingCallInfoModel.kt new file mode 100644 index 0000000..5d8beb6 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/IncomingCallInfoModel.kt @@ -0,0 +1,7 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import com.ciscowebex.androidsdk.phone.Call + +abstract class IncomingCallInfoModel(var call: Call?) { + var isEnabled: Boolean = true +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/MeetingInfoModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/MeetingInfoModel.kt new file mode 100644 index 0000000..8d740d6 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/MeetingInfoModel.kt @@ -0,0 +1,38 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.CallSchedule +import java.text.SimpleDateFormat +import java.util.Date + +data class MeetingInfoModel(val _call: Call, val meetingId: String, val startTime: Date, val endTime: Date, val link: String, val subject: String): IncomingCallInfoModel(_call) { + val startTimeString: String = SimpleDateFormat("hh:mm a").format(startTime) + val endTimeString: String = SimpleDateFormat("hh:mm a").format(endTime) + val timeString: String = "$startTimeString - $endTimeString" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MeetingInfoModel + + return meetingId == other.meetingId + } + + override fun hashCode(): Int { + var result = meetingId.hashCode() + result = 31 * result + startTime.hashCode() + result = 31 * result + endTime.hashCode() + result = 31 * result + link.hashCode() + result = 31 * result + subject.hashCode() + return result + } + + companion object { + fun convertToMeetingInfoModel(call: Call, schedule: CallSchedule): MeetingInfoModel { + return MeetingInfoModel(call, schedule.getId().orEmpty(), schedule.getStart() ?: Date(), schedule.getEnd() ?: Date(), + schedule.getMeetingLink().orEmpty(), schedule.getSubject().orEmpty()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/OneToOneIncomingCallModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/OneToOneIncomingCallModel.kt new file mode 100644 index 0000000..c9e5c53 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/OneToOneIncomingCallModel.kt @@ -0,0 +1,5 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import com.ciscowebex.androidsdk.phone.Call + +data class OneToOneIncomingCallModel(val _call: Call?): IncomingCallInfoModel(_call) \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/RingerManager.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/RingerManager.kt new file mode 100644 index 0000000..176523f --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/RingerManager.kt @@ -0,0 +1,254 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.MediaPlayer +import android.media.SoundPool +import android.net.Uri +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.util.Log +import androidx.media.AudioFocusRequestCompat +import androidx.media.AudioManagerCompat +import com.ciscowebex.androidsdk.kitchensink.R +import org.koin.core.KoinComponent +import java.io.IOException + + +open class RingerManager(private val androidContext: Context): KoinComponent { + enum class RingerType { + Incoming, + Outgoing + } + + private val tag = "RingerManager" + + private var vibrator: Vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + private var audioManager: AudioManager = androidContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val inCallSoundPool: SoundPool = SoundPool.Builder() + .setMaxStreams(1) + .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION).build()) + .build() + private var incomingCallPlayer: MediaPlayer? = null + + private var currentPlayingToneId = 0 + private var audioFocusGainForRingtone = false + private var needAudioFocusForCall = false + private val ringerLock = Any() + private var outGoingCallId = 0 + + init { + loadInCallTones() + } + + private fun loadInCallTones() { + outGoingCallId = inCallSoundPool.load(androidContext, R.raw.ring_back, 1) + } + + private fun playOutgoingCallTone() { + if (currentPlayingToneId != 0) return + val streamVolume = getStreamVolume(AudioManager.STREAM_VOICE_CALL) + currentPlayingToneId = inCallSoundPool.play(outGoingCallId, streamVolume, streamVolume, 1, -1, 1.toFloat()) + Log.d(tag, "currentPlayingToneId=$currentPlayingToneId") + } + + fun startRinger(type: RingerType) { + Log.d(tag, "startRinger type: $type") + synchronized(ringerLock) { + handleStartRinger(type) + } + } + + fun stopRinger(type: RingerType) { + Log.d(tag, "stopRinger type: $type") + synchronized(ringerLock) { + handleStopRinger(type) + } + } + + private fun handleStartRinger(type: RingerType) { + Log.d(tag, "handleStartRinger type: $type") + when (type) { + RingerType.Incoming -> playIncomingTone() + RingerType.Outgoing -> playOutgoingCallTone() + } + } + + private fun handleStopRinger(type: RingerType) { + Log.d(tag, "handleStopRinger type: $type") + when (type) { + RingerType.Incoming -> stopIncomingTone() + RingerType.Outgoing -> stopCallTone() + } + } + + private fun playIncomingTone() { + Log.d(tag,"playIncomingTone") + playIncomingCallTone() + } + + private fun stopIncomingTone() { + Log.d(tag, "stopIncomingTone") + stopIncomingCallTone() + } + + private fun playIncomingCallTone() { + Log.d(tag, "playing ringtone for incoming call") + if (incomingCallPlayer?.isPlaying == true) { + Log.d(tag, "incoming call is already playing, ignore this request") + return + } + startVibrate() + Log.d(tag, "start playing incoming tone") + requestAudioFocusForRingtone() + if (incomingCallPlayer == null) { + incomingCallPlayer = MediaPlayer() + setupMediaPlayer(incomingCallPlayer) + } + incomingCallPlayer?.start() + } + + private fun setupMediaPlayer(mediaPlayer: MediaPlayer?) { + Log.d(tag, "setupMediaPlayer") + val incomingCallToneUri: Uri = Uri.parse("android.resource://" + androidContext.packageName + "/" + R.raw.notification_oneone_call) + mediaPlayer?.run { + try { + setDataSource(androidContext, incomingCallToneUri) + setRingtoneStreamType(this) + isLooping = true + prepare() + } catch (e: IOException) { + Log.e(tag, "io exception when setting tone: $incomingCallToneUri") + } catch (illegalException: IllegalStateException) { + Log.e(tag, "Illegal state when setting tone:$incomingCallToneUri") + } + } + } + + private fun stopIncomingCallTone() { + abandonAudioFocusForRingtone() + incomingCallPlayer?.run { + stop() + release() + } + incomingCallPlayer = null + stopVibrate() + } + + private val focusRequest: AudioFocusRequestCompat by lazy { + AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT) + .setOnAudioFocusChangeListener(initFocusChangeListener()) + .build() + } + + private fun initFocusChangeListener(): AudioManager.OnAudioFocusChangeListener { + return AudioManager.OnAudioFocusChangeListener { + when (it) { + AudioManager.AUDIOFOCUS_GAIN -> { + Log.d(tag, "OnAudioFocusChanged:AUDIOFOCUS_GAIN") + } + AudioManager.AUDIOFOCUS_LOSS -> { + Log.d(tag, "OnAudioFocusChanged:AUDIOFOCUS_LOSS") + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + Log.d(tag, "OnAudioFocusChanged:AUDIOFOCUS_LOSS_TRANSIENT") + } + else -> { + Log.d(tag, "OnAudioFocusChanged, state: $it") + } + } + } + } + + private fun requestAudioFocusForRingtone() { + Log.d(tag, "audioFocusGainForRingtone: $audioFocusGainForRingtone") + if (!audioFocusGainForRingtone) { + audioManager.mode = AudioManager.MODE_RINGTONE + AudioManagerCompat.requestAudioFocus(audioManager, focusRequest) + audioFocusGainForRingtone = true + } + } + + private fun requestAudioFocusForCall() { + if (!audioFocusGainForRingtone) { + Log.d(tag, "requesting audio focus for calls") + val result = AudioManagerCompat.requestAudioFocus(audioManager, focusRequest) + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + audioFocusGainForRingtone = true + } + needAudioFocusForCall = !audioFocusGainForRingtone + } else { + needAudioFocusForCall = true + } + } + + private fun abandonAudioFocusForRingtone() { + Log.d(tag,"audioFocusGainForRingtone: $audioFocusGainForRingtone") + if (audioFocusGainForRingtone) { + AudioManagerCompat.abandonAudioFocusRequest(audioManager, focusRequest) + if (needAudioFocusForCall) { + // in case stop ringer callback is called later than requestAudioFocusForCall(), + // causing audio focus is not gained by call, need to request audio focus again for call + Log.d(tag, "request audio focus again for call") + requestAudioFocusForCall() + } else { + // do not need reset audio mode when in a call + Log.d(tag, "reset audio mode when ringer stopped") + audioManager.mode = AudioManager.MODE_NORMAL + } + audioFocusGainForRingtone = false + } + } + + private fun startVibrate() { + if (shouldVibrate()) { + Log.d(tag, "start vibrating...") + val vibratePattern = longArrayOf(0, 1000, 1750) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = VibrationEffect.createWaveform(vibratePattern, 0) + val attributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .build() + vibrator.vibrate(effect, attributes) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(vibratePattern, 0) + } + } + } + + private fun stopVibrate() { + if (vibrator.hasVibrator()) { + Log.d(tag,"stop vibrating...") + vibrator.cancel() + } + } + + private fun shouldVibrate(): Boolean { + val silentMode = audioManager.ringerMode == AudioManager.RINGER_MODE_SILENT + return vibrator.hasVibrator() && !silentMode + } + + // Only for incoming call + private fun setRingtoneStreamType(mediaPlayer: MediaPlayer?) { + val attributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .build() + mediaPlayer?.setAudioAttributes(attributes) + } + + + private fun stopCallTone() { + Log.d(tag, "stopCallTone currentPlayingToneId=$currentPlayingToneId") + if (currentPlayingToneId != 0) { + inCallSoundPool.stop(currentPlayingToneId) + currentPlayingToneId = 0 + } + } + + private fun getStreamVolume(stream: Int): Float { + return audioManager.getStreamVolume(stream).toFloat() / audioManager.getStreamMaxVolume(stream).toFloat() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/SpaceIncomingCallModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/SpaceIncomingCallModel.kt new file mode 100644 index 0000000..42965fb --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/SpaceIncomingCallModel.kt @@ -0,0 +1,5 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import com.ciscowebex.androidsdk.phone.Call + +data class SpaceIncomingCallModel(val _call: Call?): IncomingCallInfoModel(_call) \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsAdapter.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsAdapter.kt new file mode 100644 index 0000000..a1c5238 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsAdapter.kt @@ -0,0 +1,91 @@ +package com.ciscowebex.androidsdk.kitchensink.calling.participants + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ParticipantsHeaderItemBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ParticipantsListItemBinding +import com.ciscowebex.androidsdk.phone.CallMembership + +class ParticipantsAdapter(private val participants: ArrayList, private val itemClickListener: OnItemActionListener, private val selfId: String) : RecyclerView.Adapter() { + private val viewTypeHeader = 0 + private val viewTypeParticipant = 1 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when(viewType) { + viewTypeHeader -> { + HeaderViewHolder(ParticipantsHeaderItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + viewTypeParticipant -> { + ParticipantViewHolder(ParticipantsListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + else -> { + ParticipantViewHolder(ParticipantsListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + } + } + + override fun getItemViewType(position: Int): Int { + return if (participants[position] is String) { + viewTypeHeader + } else viewTypeParticipant + } + + override fun getItemCount(): Int { + return participants.size + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if(participants[position] is String) { + (holder as HeaderViewHolder).bind() + } else { + (holder as ParticipantViewHolder).bind() + } + } + + fun refreshData(list: List) { + participants.clear() + participants.addAll(list) + } + + inner class ParticipantViewHolder(private val binding: ParticipantsListItemBinding): RecyclerView.ViewHolder(binding.root){ + + fun bind(){ + val participant = participants[adapterPosition] as CallMembership + binding.tvName.text = participant.getDisplayName() + binding.imgMute.setImageResource(R.drawable.ic_mic_off_24) + binding.imgMute.visibility = if(!participant.isSendingAudio()) View.VISIBLE else View.INVISIBLE + + val personId = participant.getPersonId() + + if (personId == selfId) { + binding.infoLabelView.visibility = View.VISIBLE + } + else { + binding.infoLabelView.visibility = View.GONE + } + binding.root.setOnClickListener { itemClickListener.onParticipantMuted(personId)} + binding.root.setOnLongClickListener { + itemClickListener.onLetInClicked(participant) + true + } + + } + } + + inner class HeaderViewHolder(private val binding: ParticipantsHeaderItemBinding): RecyclerView.ViewHolder(binding.root){ + + fun bind(){ + binding.tvName.text = participants[adapterPosition] as String + binding.root.setOnClickListener(null) + } + } + + interface OnItemActionListener{ + fun onParticipantMuted(participantId: String) + fun onLetInClicked(callMembership: CallMembership) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsFragment.kt new file mode 100644 index 0000000..6030eca --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsFragment.kt @@ -0,0 +1,146 @@ +package com.ciscowebex.androidsdk.kitchensink.calling.participants + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexViewModel +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentParticipantsBinding +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import com.ciscowebex.androidsdk.phone.CallMembership +import kotlinx.android.synthetic.main.fragment_participants.* + + +class ParticipantsFragment : DialogFragment(), ParticipantsAdapter.OnItemActionListener { + + lateinit var binding: FragmentParticipantsBinding + lateinit var adapter: ParticipantsAdapter + private lateinit var webexViewModel: WebexViewModel + private var currentCallId: String? = null + private var selfId: String? = null + + companion object { + private const val CALL_KEY = "call_id" + private const val SELF_ID_KEY = "self_id" + + fun newInstance(callid: String): ParticipantsFragment { + val bundle = Bundle() + bundle.putString(CALL_KEY, callid) + val fragment = ParticipantsFragment() + fragment.arguments = bundle + return fragment + } + } + + override fun onStart() { + super.onStart() + val dialog: Dialog? = dialog + if (dialog != null) { + val width = ViewGroup.LayoutParams.MATCH_PARENT + val height = ViewGroup.LayoutParams.MATCH_PARENT + dialog.window?.setLayout(width, height) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val participantsBinding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.fragment_participants, container,false).also { binding = it }.apply { + webexViewModel = (activity as? CallActivity)?.webexViewModel!! + Log.d(tag, "onCreateView webexViewModel: $webexViewModel") + selfId = webexViewModel.selfPersonId + setUpViews() + } + return participantsBinding.root + } + + private fun setUpViews() { + adapter = ParticipantsAdapter(arrayListOf(), this, selfId.orEmpty()) + binding.participants.adapter = adapter + + val dividerItemDecoration = DividerItemDecoration(requireContext(), + LinearLayoutManager.VERTICAL) + binding.participants.addItemDecoration(dividerItemDecoration) + + webexViewModel.currentCallId?.let { _callId -> + currentCallId = _callId + webexViewModel.getParticipants(_callId) + } + + webexViewModel.callMembershipsLiveData.observe(this, Observer { + it?.let { callMemberships -> + Log.d(tag, callMemberships.toString()) + val data = arrayListOf() + val stateWiseMap = callMemberships.groupBy { it.getState() } + stateWiseMap.keys.forEach { state -> + val memberships = stateWiseMap[state] + data.add(webexViewModel.getHeader(state)) + data.addAll(memberships.orEmpty()) + } + adapter.refreshData(data) + adapter.notifyDataSetChanged() + } + }) + + webexViewModel.muteAllLiveData.observe(this, Observer { shouldMuteAll -> + if (shouldMuteAll != null) { + tvMute.text = if(shouldMuteAll) getString(R.string.mute_all) else getString(R.string.unmute_all) + } + }) + + binding.tvMute.text = getString(R.string.mute_all) + binding.tvMute.setOnClickListener { + if (webexViewModel.getCall(webexViewModel.currentCallId.orEmpty())?.isCUCMCall() == false) { + webexViewModel.currentCallId?.let { + webexViewModel.muteAllParticipantAudio(it) + } + } else { + showToast(getString(R.string.mute_feature_is_not_available_for_cucm_calls)) + } + } + + binding.close.setOnClickListener { dismiss() } + + } + + fun showToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + } + + override fun onParticipantMuted(participantId: String) { + currentCallId?.let { + if (webexViewModel.getCall(webexViewModel.currentCallId.orEmpty())?.isCUCMCall() == false || webexViewModel.selfPersonId == participantId) { + webexViewModel.muteParticipant(it, participantId) + adapter.notifyDataSetChanged() + } else { + showToast(getString(R.string.mute_feature_is_not_available_for_cucm_calls)) + } + } + } + + override fun onLetInClicked(callMembership: CallMembership) { + if (callMembership.getState() == CallMembership.State.WAITING) { + context?.let { ctx -> + showDialogWithMessage(ctx, getString(R.string.message), getString(R.string.let_in_confirmation), + onPositiveButtonClick = { dialog, _ -> + currentCallId?.let { + webexViewModel.letIn(it, callMembership) + } + dialog.dismiss() + }, + onNegativeButtonClick = { dialog, _ -> + dialog.dismiss() + }) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/cucm/UCLoginActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/cucm/UCLoginActivity.kt new file mode 100644 index 0000000..1a2d127 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/cucm/UCLoginActivity.kt @@ -0,0 +1,192 @@ +package com.ciscowebex.androidsdk.kitchensink.cucm + + +import android.app.AlertDialog +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.auth.UCSSOWebViewAuthenticator +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityCucmLoginBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogUcloginNonssoBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogUcloginSettingsBinding +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.auth.UCLoginServerConnectionStatus + + +class UCLoginActivity : BaseActivity() { + lateinit var binding: ActivityCucmLoginBinding + + private var nonSSOAlertDialog: AlertDialog? = null + private var ucSettingsAlertDialog: AlertDialog? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "UCLoginActivity" + DataBindingUtil.setContentView(this, R.layout.activity_cucm_login) + .also { binding = it } + .apply { + webexViewModel.cucmLiveData.observe(this@UCLoginActivity, Observer { + if (it != null) { + when (WebexRepository.CucmEvent.valueOf(it.first.name)) { + WebexRepository.CucmEvent.ShowSSOLogin -> { + + progressBar.visibility = View.GONE + ssologinWebview.visibility = View.VISIBLE + + nonSSOAlertDialog?.dismiss() + ucSettingsAlertDialog?.dismiss() + + UCSSOWebViewAuthenticator.launchWebView(ssologinWebview, it.second, CompletionHandler { result -> + if (result.isSuccessful) { + Log.d(tag, "UCLoginActivity SSO login Successful") + + Handler(Looper.getMainLooper()).post { + ssologinWebview.visibility = View.GONE + progressBar.visibility = View.VISIBLE + } + } else { + Log.d(tag, "UCLoginActivity SSO login Failed") + ucLoginEvent(getString(R.string.uc_login_failed)) + } + }) + } + WebexRepository.CucmEvent.ShowNonSSOLogin -> { + showUCNonSSOLoginDialog() + } + WebexRepository.CucmEvent.OnUCLoggedIn -> { + ucLoginEvent(getString(R.string.uc_login_success)) + } + WebexRepository.CucmEvent.OnUCLoginFailed -> { + ucLoginEvent(getString(R.string.uc_login_failed)) + } + WebexRepository.CucmEvent.OnUCServerConnectionStateChanged -> { + processServerConnectionStatus(webexViewModel.getUCServerConnectionStatus()) + } + } + } + }) + + progressBar.visibility = View.VISIBLE + + Handler(Looper.getMainLooper()).post { + if (webexViewModel.isUCLoggedIn()) { + ucLoginEvent(getString(R.string.uc_login_success)) + } else { + showUCLoginSettingsDialog() + } + } + } + } + + private fun processServerConnectionStatus(status: UCLoginServerConnectionStatus) { + Log.d(tag, "processServerConnectionStatus status: $status") + when (status) { + UCLoginServerConnectionStatus.Idle -> {} + UCLoginServerConnectionStatus.Connecting -> {} + UCLoginServerConnectionStatus.Connected -> { + ucLoginEvent(getString(R.string.uc_server_connected)) + } + UCLoginServerConnectionStatus.Disconnected -> {} + UCLoginServerConnectionStatus.Failed -> {} + } + } + + private fun ucLoginEvent(message: String) { + Handler(Looper.getMainLooper()).post { + binding.ssologinWebview.visibility = View.GONE + binding.progressBar.visibility = View.GONE + showToast(message) + } + } + + private fun showToast(message: String) { + val toast = Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT) + toast.show() + updateUCData() + } + + private fun setUCDomainServerUrl(domain: String, serverUrl: String) { + Log.d(tag, "setUCDomainServerUrl domain: $domain, serverUrl: $serverUrl") + webexViewModel.setUCDomainServerUrl(ucDomain = domain, serverUrl = serverUrl) + } + + private fun showUCLoginSettingsDialog() { + val builder = AlertDialog.Builder(this) + builder.setTitle(R.string.uc_login_settings) + DialogUcloginSettingsBinding.inflate(layoutInflater).apply { + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + + Handler(Looper.getMainLooper()).postDelayed({ + setUCDomainServerUrl(domain.text.toString(), server.text.toString()) + }, 200) + dialog.dismiss() + } + builder.setNeutralButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + builder.setOnDismissListener { + ucSettingsAlertDialog = null + } + } + ucSettingsAlertDialog = builder.create() + ucSettingsAlertDialog?.setCanceledOnTouchOutside(false) + ucSettingsAlertDialog?.show() + } + + private fun showUCNonSSOLoginDialog() { + val builder = AlertDialog.Builder(this) + builder.setTitle(R.string.uc_login_settings) + DialogUcloginNonssoBinding.inflate(layoutInflater).apply { + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + + Handler(Looper.getMainLooper()).postDelayed({ + val username = username.text.toString() + val password = password.text.toString() + webexViewModel.setCUCMCredential(username, password) + }, 200) + + dialog.dismiss() + } + builder.setNeutralButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + builder.setOnDismissListener { + nonSSOAlertDialog = null + } + } + + nonSSOAlertDialog = builder.create() + nonSSOAlertDialog?.setCanceledOnTouchOutside(false) + nonSSOAlertDialog?.show() + } + + private fun updateUCData() { + Log.d(tag, "updateUCData isCUCMServerLoggedIn: ${webexViewModel.repository.isCUCMServerLoggedIn} ucServerConnectionStatus: ${webexViewModel.repository.ucServerConnectionStatus}") + if (webexViewModel.isCUCMServerLoggedIn) { + binding.ucLoginStatusTextView.visibility = View.VISIBLE + } else { + binding.ucLoginStatusTextView.visibility = View.GONE + } + + when (webexViewModel.ucServerConnectionStatus) { + UCLoginServerConnectionStatus.Connected -> { + binding.ucServerConnectionStatusTextView.text = resources.getString(R.string.phone_service_connected) + binding.ucServerConnectionStatusTextView.visibility = View.VISIBLE + } + UCLoginServerConnectionStatus.Failed -> { + val text = resources.getString(R.string.phone_service_failed) + " " + webexViewModel.ucServerConnectionFailureReason + binding.ucServerConnectionStatusTextView.text = text + binding.ucServerConnectionStatusTextView.visibility = View.VISIBLE + } + else -> { + binding.ucServerConnectionStatusTextView.visibility = View.GONE + } + } + } +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasActivity.kt new file mode 100644 index 0000000..a56b974 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasActivity.kt @@ -0,0 +1,49 @@ +package com.ciscowebex.androidsdk.kitchensink.extras + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityExtrasBinding +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import org.koin.android.ext.android.inject + +class ExtrasActivity : AppCompatActivity() { + + lateinit var binding: ActivityExtrasBinding + val tag = "ExtrasActivity" + + private val extrasViewModel: ExtrasViewModel by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_extras) + .also { binding = it } + .apply { + btnViewAccessToken.setOnClickListener { + extrasViewModel.getAccessToken() + } + btnRefreshAccessToken.setOnClickListener { + extrasViewModel.getRefreshToken() + } + btnGetJwtTokenExpiry.setOnClickListener { + val expiryDate = extrasViewModel.getJwtAccessTokenExpiration() + val message = expiryDate?.toString()?: getString(R.string.expiry_date_not_available) + showDialogWithMessage(this@ExtrasActivity, R.string.access_token_expiration, message) + } + + setUpObservers() + } + } + + private fun setUpObservers() { + val observer: Observer = Observer { + val accessToken = it?: getString(R.string.no_access_token_yet) + showDialogWithMessage(this, R.string.access_token, accessToken) + } + + extrasViewModel.accessToken.observe(this, observer) + extrasViewModel.refreshToken.observe(this, observer) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasModule.kt new file mode 100644 index 0000000..c2b9507 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasModule.kt @@ -0,0 +1,10 @@ +package com.ciscowebex.androidsdk.kitchensink.extras + +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val extrasModule = module { + viewModel { ExtrasViewModel(get()) } + + single { ExtrasRepository(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasRepository.kt new file mode 100644 index 0000000..9686ca8 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasRepository.kt @@ -0,0 +1,50 @@ +package com.ciscowebex.androidsdk.kitchensink.extras + +import android.util.Log +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.auth.JWTAuthenticator +import io.reactivex.Observable +import io.reactivex.Single +import java.util.Date + +class ExtrasRepository(private val webex: Webex) { + private val tag = "ExtrasRepository" + fun getAccessToken(): Observable { + return Single.create { emitter -> + webex.authenticator?.getToken(CompletionHandler { result -> + if (result.isSuccessful) { + val token = result.data + emitter.onSuccess(token ?: "No Access Token yet") + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getRefreshToken(): Observable { + return Single.create { emitter -> + if (webex.authenticator is JWTAuthenticator) { + (webex.authenticator as JWTAuthenticator).refreshToken(CompletionHandler { result -> + if (result.isSuccessful) { + val token = result.data + emitter.onSuccess(token ?: "No Access Token yet") + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + } else { + emitter.onError(Throwable("Authenticator should be an instance of JWTAuthenticator")) + } + }.toObservable() + } + + fun getJwtAccessTokenExpiration(): Date? { + Log.d(tag, "isAuthorized : ${webex.authenticator?.isAuthorized()}") + if (webex.authenticator is JWTAuthenticator) { + return (webex.authenticator as JWTAuthenticator).getExpiration() + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasViewModel.kt new file mode 100644 index 0000000..8246f25 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasViewModel.kt @@ -0,0 +1,34 @@ +package com.ciscowebex.androidsdk.kitchensink.extras + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import io.reactivex.android.schedulers.AndroidSchedulers +import java.util.Date + +class ExtrasViewModel(private val extrasRepository: ExtrasRepository) : BaseViewModel() { + private val tag = "ExtrasViewModel" + + private val _accessToken = MutableLiveData() + val accessToken: LiveData = _accessToken + + private val _refreshToken = MutableLiveData() + val refreshToken: LiveData = _refreshToken + + fun getAccessToken() { + extrasRepository.getAccessToken().observeOn(AndroidSchedulers.mainThread()).subscribe({ + _accessToken.postValue(it) + }, { _accessToken.postValue(null) }).autoDispose() + } + + fun getRefreshToken() { + extrasRepository.getRefreshToken().observeOn(AndroidSchedulers.mainThread()).subscribe({ + _refreshToken.postValue(it) + }, { _refreshToken.postValue(null) }).autoDispose() + } + + fun getJwtAccessTokenExpiration(): Date? { + return extrasRepository.getJwtAccessTokenExpiration() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/Data.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/Data.kt new file mode 100644 index 0000000..8f7814b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/Data.kt @@ -0,0 +1,15 @@ +package com.ciscowebex.androidsdk.kitchensink.firebase + +import com.google.gson.annotations.SerializedName + +data class Data( + + @SerializedName("id") val id: String?, + @SerializedName("roomId") val roomId: String?, + @SerializedName("callId") val callId: String?, + @SerializedName("state") val state: String?, + @SerializedName("roomType") val roomType: String?, + @SerializedName("personId") val personId: String?, + @SerializedName("personEmail") val personEmail: String?, + @SerializedName("created") val created: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/FCMPushModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/FCMPushModel.kt new file mode 100644 index 0000000..232efd5 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/FCMPushModel.kt @@ -0,0 +1,20 @@ +package com.ciscowebex.androidsdk.kitchensink.firebase + +import com.google.gson.annotations.SerializedName + +data class FCMPushModel( + + @SerializedName("id") val id: String?, + @SerializedName("name") val name: String?, + @SerializedName("targetUrl") val targetUrl: String?, + @SerializedName("resource") val resource: String?, + @SerializedName("event") val event: String?, + @SerializedName("orgId") val orgId: String?, + @SerializedName("createdBy") val createdBy: String?, + @SerializedName("appId") val appId: String?, + @SerializedName("ownedBy") val ownedBy: String?, + @SerializedName("status") val status: String?, + @SerializedName("created") val created: String?, + @SerializedName("actorId") val actorId: String?, + @SerializedName("data") val data: Data? +) \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/KitchenSinkFCMService.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/KitchenSinkFCMService.kt new file mode 100644 index 0000000..3850525 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/KitchenSinkFCMService.kt @@ -0,0 +1,244 @@ +package com.ciscowebex.androidsdk.kitchensink.firebase + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.text.Html +import android.util.Log +import androidx.core.app.NotificationCompat +import com.ciscowebex.androidsdk.kitchensink.HomeActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.firebase.KitchenSinkFCMService.WebhookResources.CALL_MEMBERSHIPS +import com.ciscowebex.androidsdk.kitchensink.firebase.KitchenSinkFCMService.WebhookResources.MESSAGES +import com.ciscowebex.androidsdk.kitchensink.utils.Base64Utils +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.decryptPushRESTPayload +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkApp +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import com.ciscowebex.androidsdk.phone.NotificationCallType +import org.json.JSONObject +import org.koin.android.ext.android.inject +import kotlin.random.Random + + +class KitchenSinkFCMService : FirebaseMessagingService() { + + private val repository: WebexRepository by inject() + + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + + Log.d(TAG, "From: " + remoteMessage.from) + Log.d(TAG, "APP isInForeground: " + KitchenSinkApp.inForeground) + if (KitchenSinkApp.inForeground) return + + var notificationData: FCMPushModel? + + if (remoteMessage.data.isNotEmpty()) { + val map = remoteMessage.data + val pushRestPayload = map["body"] + if (!pushRestPayload.isNullOrEmpty()) { + // This FCM notification is generated by PushREST +// sample payload: eyJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiZGlyIn0..pnhaRj0e109Khb1j.mXZBfDQMj_c4dFaZRwRuVSOI0LcrZRvpnoBknDQDsYKsDVQtIppi1y7cWBsQ8doNLs-Cp6UEkzLlOX_SHOLqYhdHdfo8n5-nRfTI0gUx72UQtvuPGBFKUStU_B7TQmEBs7OQBClHjUNiTIo_Q70NTijE0ErgUzXhpXVHtgDnMW79HDzJ37Y4PUM96ssd8uY7WZuezTKkDYAjVYutQ5-MBe2z3oaFeXqy1hgfWVJY_y2L9eC7RHaMkUFmONaNmiryTssxcp1aWkWOqyMWNlu6igh1.Wy3QMt5_loajfrHrCCyfzQ + Log.d(TAG, "Payload from PushREST : $pushRestPayload") + + // Sample encryption logic + //val dummyPayload = "This is a dummyP@yload! for Testing" + //val encryptedPayload = encryptPushRESTPayload(dummyPayload) + + // Decrypt using key + val decryptedPayload = decryptPushRESTPayload(pushRestPayload) + Log.d(TAG, "Decrypted payload : $decryptedPayload") + val pushRestPayloadJson = getPushRestPayloadModel(decryptedPayload) + buildCallNotification(pushRestPayloadJson) + + } else { + // FCM triggered via webhook from push notification server + val data = map["data"] + data?.let { + val jsonObject = JSONObject(it) + Log.d(TAG, "Message data payload: remoteMessage.data -> $jsonObject") + notificationData = getFCMModel(jsonObject.toString()) + when (notificationData?.resource) { + MESSAGES.value -> { + buildMessageNotification(notificationData) + } + CALL_MEMBERSHIPS.value -> { + //send call notification + notificationData?.let { data -> + buildCallNotification(data) + } + } + else -> { + Log.d(TAG, "Unknown resource found : Resource: ${notificationData?.resource}") + } + } + } + } + } + + } + + private fun buildCallNotification(data: FCMPushModel) { + val callId = Base64Utils.decodeString(data.data?.callId) //locus sessionId returned + Handler(Looper.getMainLooper()).postDelayed({ + val actualCallId = repository.getCallIdByNotificationId(callId, NotificationCallType.Webex) + val callInfo = repository.getCall(actualCallId) + Log.d(TAG, "CallInfo ${callInfo?.getCallId()} title ${callInfo?.getTitle()}") + sendCallNotification(callInfo) + }, 100) + + } + + private fun buildCallNotification(data: PushRestPayloadModel) { + Handler(Looper.getMainLooper()).postDelayed({ + if(data.pushid != null){ + val actualCallId = repository.getCallIdByNotificationId(data.pushid, NotificationCallType.Cucm) + val callInfo = repository.getCall(actualCallId) + Log.d(TAG, "CallInfo ${callInfo?.getCallId()} title ${callInfo?.getTitle()}") + if (data.type == "incomingcall") //data.type = incomingcall,missedcall + sendCallNotification(callInfo, data.displayname) + }else { + Log.d(TAG, "Push id is null") + } + + }, 100) + } + + private fun sendCallNotification(callInfo: Call?, caller: String? = null) { + val callTitle = caller ?: callInfo?.getTitle() + val notificationId = Random.nextInt(10000) + val requestCode = Random.nextInt(10000) + val intent = CallActivity.getIncomingIntent(this) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.putExtra(Constants.Intent.CALL_ID, callInfo?.getCallId()) + intent.action = Constants.Action.WEBEX_CALL_ACTION + + val pendingIntent = PendingIntent.getActivity(this, requestCode, intent, + PendingIntent.FLAG_ONE_SHOT) + val channelId: String = getString(R.string.default_notification_channel_id) + val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val notificationBuilder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.app_notification_icon) + .setContentTitle("$callTitle is calling") + .setContentText(getString(R.string.call_description)) + .setAutoCancel(true) + .setSound(defaultSoundUri) + .setContentIntent(pendingIntent) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? + + // Since android Oreo notification channel is needed. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(channelId, + WEBEX_CALL_CHANNEL, + NotificationManager.IMPORTANCE_DEFAULT) + notificationManager?.createNotificationChannel(channel) + } + notificationManager?.notify(notificationId, notificationBuilder.build()) + } + + private fun buildMessageNotification(notificationData: FCMPushModel?) { + val roomId = Base64Utils.decodeString(notificationData?.data?.roomId) + repository.listMessages(roomId, CompletionHandler { + Log.d(TAG, "message size: ${it.data?.size}") + val size = it.data?.size ?: 0 + if (size > 0) { + val message = it.data?.get(size - 1) + Log.d(TAG, "last message: ${message?.getTextAsObject()?.getMarkdown()}") + + Log.d(TAG, "Fetching person details") + repository.getPerson(Base64Utils.decodeString(notificationData?.data?.personId), CompletionHandler { personResult -> + Log.d(TAG, "Fetching space details") + repository.getSpace(Base64Utils.decodeString(notificationData?.data?.roomId), CompletionHandler { spaceResult -> + sendNotification(personResult.data?.displayName.orEmpty(), spaceResult.data?.title.orEmpty(), message) + }) + }) + } else { + Log.d(TAG, "message not found") + } + }) + } + + private fun getFCMModel(data: String): FCMPushModel { + return Gson().fromJson(data, FCMPushModel::class.java) + } + + private fun getPushRestPayloadModel(data: String): PushRestPayloadModel { + return Gson().fromJson(data, PushRestPayloadModel::class.java) + } + + /** + * Called if FCM registration token is updated. This may occur if the security of + * the previous token had been compromised. Note that this is called when the + * FCM registration token is initially generated so this is where you would retrieve + * the token. + */ + override fun onNewToken(token: String) { + Log.d(TAG, "Refreshed token: $token") + } + + /** + * Create and show a simple notification containing the received FCM message. + * + */ + private fun sendNotification(personTitle: String, spaceTitle: String, message: Message?) { + val notificationId = Random.nextInt(10000) + val requestCode = Random.nextInt(10000) + val intent = Intent(this, HomeActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.putExtra(Constants.Bundle.MESSAGE_ID, message?.getId().orEmpty()) + intent.action = Constants.Action.MESSAGE_ACTION + + val pendingIntent = PendingIntent.getActivity(this, requestCode, intent, + PendingIntent.FLAG_ONE_SHOT) + val channelId: String = getString(R.string.default_notification_channel_id) + val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val notificationBuilder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.app_notification_icon) + .setContentTitle(spaceTitle) + .setContentText(personTitle) + .setAutoCancel(true) + .setSound(defaultSoundUri) + .setContentIntent(pendingIntent) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(Html.fromHtml(message?.getTextAsObject()?.getMarkdown().orEmpty(), Html.FROM_HTML_MODE_LEGACY)) + ) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? + + // Since android Oreo notification channel is needed. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(channelId, + MESSAGE_CHANNEL, + NotificationManager.IMPORTANCE_DEFAULT) + notificationManager?.createNotificationChannel(channel) + } + notificationManager?.notify(notificationId, notificationBuilder.build()) + } + + companion object { + private const val TAG = "MyFirebaseMsgService" + private const val WEBEX_CALL_CHANNEL = "WebexCallChannel" + private const val CUCM_CALL_CHANNEL = "CUCMCallChannel" + private const val MESSAGE_CHANNEL = "MessageChannel" + } + + enum class WebhookResources(var value: String) { + MESSAGES("messages"), CALL_MEMBERSHIPS("callMemberships") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/PushRestPayloadModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/PushRestPayloadModel.kt new file mode 100644 index 0000000..7c69bde --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/PushRestPayloadModel.kt @@ -0,0 +1,13 @@ +package com.ciscowebex.androidsdk.kitchensink.firebase + +import com.google.gson.annotations.SerializedName + +data class PushRestPayloadModel ( + @SerializedName("type") val type: String?, + @SerializedName("pushid") val pushid: String?, + @SerializedName("displayname") val displayname: String?, + @SerializedName("displaynumber") val displaynumber: String?, + @SerializedName("payloadversion") val payloadversion: String?, + @SerializedName("huntpilotdn") val huntpilotdn: String?, + @SerializedName("ringexpiretime") val ringexpiretime: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/RegisterTokenService.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/RegisterTokenService.kt new file mode 100644 index 0000000..db69fcb --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/RegisterTokenService.kt @@ -0,0 +1,66 @@ +package com.ciscowebex.androidsdk.kitchensink.firebase + +import android.os.AsyncTask +import android.util.Log +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +class RegisterTokenService : AsyncTask(){ + val tag = "RegisterTokenService" + private val tokenServiceUrl = "https://serene-meadow-01887.herokuapp.com/register" + + override fun doInBackground(vararg params: String?): String { + var result = "" + try { + result = registerToken(params[0].orEmpty()) + }catch (e: Exception){ + Log.d(tag, "Error in register token", e) + } + return result + } + + override fun onPostExecute(result: String?) { + super.onPostExecute(result) + Log.d(tag, "onPostExecute response $result") + } + + override fun onPreExecute() { + super.onPreExecute() + Log.d(tag, "Sending token to server") + } + + private fun registerToken(jsonInputString: String): String{ + val url = URL(tokenServiceUrl) + + val con: HttpURLConnection = url.openConnection() as HttpURLConnection + con.requestMethod = "POST" + + con.setRequestProperty("Content-Type", "application/json; utf-8") + con.setRequestProperty("Accept", "application/json") + + con.doOutput = true + + Log.d(tag, "request body: $jsonInputString") + con.outputStream.use { os -> + val input = jsonInputString.toByteArray(charset("utf-8")) + os.write(input, 0, input.size) + } + + val code: Int = con.responseCode + Log.d(tag, "response code: $code") + + BufferedReader(InputStreamReader(con.inputStream, "utf-8")).use { br -> + val response = StringBuilder() + var responseLine: String? + while (br.readLine().also { responseLine = it } != null) { + response.append(responseLine!!.trim { it <= ' ' }) + } + println(response.toString()) + Log.d(tag, "response: $response") + return response.toString() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/BaseDialogFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/BaseDialogFragment.kt new file mode 100644 index 0000000..26d55de --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/BaseDialogFragment.kt @@ -0,0 +1,14 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + +import android.view.WindowManager +import androidx.fragment.app.DialogFragment + +open class BaseDialogFragment : DialogFragment(){ + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingActivity.kt new file mode 100644 index 0000000..25ef2fd --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingActivity.kt @@ -0,0 +1,69 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityMessagingBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsFragment +import com.ciscowebex.androidsdk.kitchensink.person.PeopleFragment +import com.google.android.material.tabs.TabLayoutMediator + +class MessagingActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMessagingBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_messaging) + .also { binding = it } + .apply { + val tabs = listOf(getString(R.string.teams), getString(R.string.spaces), getString(R.string.people), getString(R.string.memberships)) + viewPager.adapter = MessagingPagerAdapter(this@MessagingActivity, tabs.size) + viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + when(position) { + 0 -> messagingMenu.visibility = View.INVISIBLE + 1 -> messagingMenu.visibility = View.VISIBLE + 2 -> messagingMenu.visibility = View.INVISIBLE + 3 -> messagingMenu.visibility = View.INVISIBLE + } + super.onPageSelected(position) + } + }) + + TabLayoutMediator(binding.tabs, binding.viewPager, TabLayoutMediator.TabConfigurationStrategy { tab, position -> + tab.text = tabs[position] + }).attach() + + setSupportActionBar(messagingMenu) + } + } + +} + +class MessagingPagerAdapter(fragmentActivity: FragmentActivity, private val numTabs: Int) : FragmentStateAdapter(fragmentActivity) { + + override fun getItemCount(): Int { + return numTabs + } + + override fun createFragment(position: Int): Fragment { + when (position) { + 0 -> return TeamsFragment() + 1 -> return SpacesFragment() + 2 -> return PeopleFragment() + 3 -> return MembershipFragment() + } + return Fragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingModule.kt new file mode 100644 index 0000000..7fab255 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingModule.kt @@ -0,0 +1,49 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail.MessageViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail.SpaceDetailViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus.MembershipReadStatusViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.readStatusDetails.SpaceReadStatusDetailViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.detail.TeamDetailViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipViewModel +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val messagingModule = module { + viewModel { TeamsViewModel(get(), get()) } + viewModel { TeamDetailViewModel(get()) } + viewModel { TeamMembershipViewModel(get()) } + + single { TeamsRepository(get()) } + + + viewModel { SpacesViewModel(get(), get(), get(), get()) } + viewModel { SpaceDetailViewModel(get(), get(), get()) } + + single { SpacesRepository(get()) } + + viewModel { MembershipViewModel(get(), get()) } + + single { MembershipRepository(get()) } + + single { TeamMembershipRepository(get()) } + + viewModel { SpaceReadStatusDetailViewModel(get()) } + + viewModel { MessageViewModel(get()) } + + viewModel { MembershipReadStatusViewModel(get(), get()) } + + single { MessageComposerRepository(get()) } + + viewModel { MessageComposerViewModel(get(), get(), get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingRepository.kt new file mode 100644 index 0000000..062c32f --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingRepository.kt @@ -0,0 +1,149 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + +import android.net.Uri +import android.util.Log +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.message.Mention +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.message.MessageClient +import com.ciscowebex.androidsdk.message.RemoteFile +import io.reactivex.Emitter +import io.reactivex.Observable +import io.reactivex.Single +import java.io.File + +open class MessagingRepository(private val webex: Webex) { + val tag = "MessagingRepository" + + enum class FileDownloadEvent { + DOWNLOAD_COMPLETE, + DOWNLOAD_FAILED + } + + fun addSpace(spaceTitle: String, teamId: String?): Observable { + return Single.create { emitter -> + webex.spaces.create(spaceTitle, teamId, CompletionHandler { result -> + if (result.isSuccessful) { + val space = result.data + emitter.onSuccess(SpaceModel.convertToSpaceModel(space)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + + fun deleteMessage(messageId: String): Observable { + return Single.create { emitter -> + webex.messages.delete(messageId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun markMessageAsRead(spaceId: String, messageId: String? = null): Observable { + return Single.create { emitter -> + webex.messages.markAsRead(spaceId, messageId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getMessage(messageId: String): Observable { + return Single.create { emitter -> + webex.messages.get(messageId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(SpaceMessageModel.convertToSpaceMessageModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + + }) + }.toObservable() + } + + fun editMessage(messageId: String, messageText: Message.Text, mentions: ArrayList?): Observable { + return Single.create { emitter -> + webex.messages.get(messageId, CompletionHandler { messageResult -> + if (messageResult.isSuccessful) { + val message = messageResult.data + if (message != null) { + webex.messages.edit(message, messageText, mentions, CompletionHandler { result -> + if (result.isSuccessful) { + val messageObj = result.data + emitter.onSuccess(SpaceMessageModel.convertToSpaceMessageModel(messageObj)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + } else { + emitter.onError(Throwable("Error: Message cannot be found")) + } + } else { + emitter.onError(Throwable(messageResult.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun downloadThumbnail(remoteFile: RemoteFile, file: File): Observable { + return Single.create { emitter -> + webex.messages.downloadThumbnail(remoteFile, file, CompletionHandler { result -> + if (result.isSuccessful) { + if (result.data != null) { + emitter.onSuccess(result.data!!) + } else { + emitter.onError(Throwable("Unable to retrieve thumbnail")) + } + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + + fun downloadFile(remoteFile: RemoteFile, file: File, progressEmitter: Emitter, completionEmitter: Emitter>) { + webex.messages.downloadFile(remoteFile, file, + object : MessageClient.ProgressHandler { + override fun onProgress(bytes: Double) { + Log.d(tag, "downloadFile bytes: $bytes") + progressEmitter.onNext(bytes) + } + }, + CompletionHandler { fileUrlResult -> + if (fileUrlResult.isSuccessful) { + Log.d(tag, "downloadFile onComplete success: ${fileUrlResult.data}") + fileUrlResult.data?.let { + completionEmitter.onNext(Pair(FileDownloadEvent.DOWNLOAD_COMPLETE, it.toString())) + } ?: run { + completionEmitter.onNext(Pair(FileDownloadEvent.DOWNLOAD_FAILED, "Download file error occurred")) + } + } else { + Log.d(tag, "downloadFile onComplete failed") + fileUrlResult.error?.let { + it.errorMessage?.let { errorMessage -> + completionEmitter.onNext(Pair(FileDownloadEvent.DOWNLOAD_FAILED, errorMessage)) + } ?: run { + completionEmitter.onNext(Pair(FileDownloadEvent.DOWNLOAD_FAILED, "Download file error occurred")) + } + } ?: run { + completionEmitter.onNext(Pair(FileDownloadEvent.DOWNLOAD_FAILED, "Download file error occurred")) + } + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/RemoteModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/RemoteModel.kt new file mode 100644 index 0000000..901fd59 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/RemoteModel.kt @@ -0,0 +1,83 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize +import com.ciscowebex.androidsdk.message.RemoteFile +import com.ciscowebex.androidsdk.message.internal.RemoteFileImpl + +class RemoteFile(private val model: RemoteModel): RemoteFile { + + private var thumbnail: RemoteFile.Thumbnail? = null + class Thumbnail(private val width: Int, private val height: Int, private val mimeType: String, private val url: String): RemoteFile.Thumbnail { + override fun getWidth(): Int { + return width + } + + override fun getHeight(): Int { + return height + } + + override fun getMimeType(): String? { + return mimeType + } + + override fun getUrl(): String? { + return url + } + } + + init { + thumbnail = RemoteFileImpl.ThumbnailImpl(model.thumbnailWidth?:0, model.thumbnailHeight?:0, model.mimeType?:"", model.thumbnailUrl?:"") + } + + override fun getDisplayName(): String? { + return model.displayName + } + + override fun getSize(): Long { + return model.size ?: 0 + } + + override fun getMimeType(): String? { + return model.mimeType + } + + override fun getThumbnail(): RemoteFile.Thumbnail? { + return thumbnail + } + + override fun getUrl(): String? { + return model.url + } + + override fun getConversationId(): String? { + return model.conversationId + } + + override fun getMessageId(): String? { + return model.messageId + } + + override fun getContentIndex(): Int? { + return model.contentIndex + } + +} + +@Parcelize +class RemoteModel(val displayName: String?, + val mimeType: String?, + val size: Long?, + val url: String?, + val conversationId: String?, + var messageId: String?, + var contentIndex: Int?, + var thumbnailWidth: Int?, + var thumbnailHeight: Int?, + var thumbnailMimeType: String?, + val thumbnailUrl: String?) : Parcelable { + + fun getRemoteFile(): RemoteFile { + return RemoteFile(this) + } +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MentionsAutoCompleteEditText.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MentionsAutoCompleteEditText.kt new file mode 100644 index 0000000..4453cb0 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MentionsAutoCompleteEditText.kt @@ -0,0 +1,356 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.composer + +import android.content.Context +import android.text.Editable +import android.text.Spannable +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.ClickableSpan +import android.util.AttributeSet +import android.util.Log +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.widget.BaseAdapter +import android.widget.ListPopupWindow +import androidx.annotation.ColorInt +import androidx.appcompat.widget.AppCompatEditText +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemMentionBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipModel +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.utf8Offset +import com.ciscowebex.androidsdk.message.Mention + +class MessageContent() { + var text: String = "" + var messageInputMentions: ArrayList? = null +} + +data class Filter(val text: String, val position: Int) + +interface AutoCompletePlugin { + fun onFilterChanged(text: String) + fun shouldTrigger(filter: Filter): Boolean + fun getAdapter(): BaseAdapter + fun itemSelected(position: Int, editText: MentionsAutoCompleteEditText, filter: Filter) + fun hasItems(): Boolean +} + +interface BackPressedListener { + fun onImeBack(editText: MentionsAutoCompleteEditText) +} + +interface CreateInputConnectionListener { + fun onCreateInputConnection(outAttrs: EditorInfo, inputConnection: InputConnection): InputConnection +} + +class MentionSpan(val context: Context, val mention: Mention) : ClickableSpan() { + override fun onClick(widget: View) { + } + + override fun updateDrawState(ds: TextPaint) { + ds.color = getMentionTextColor() + ds.isFakeBoldText = false + ds.bgColor = ContextCompat.getColor(context, R.color.blue_40) // add mentions highlight + } + + @ColorInt + private fun getMentionTextColor() = ContextCompat.getColor(context, R.color.white) +} + +class MentionsAutoCompleteEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : + AppCompatEditText(context, attrs, defStyle) { + + companion object { + val MAX_LINES = 4 + val TAG = MentionsAutoCompleteEditText::class.java.simpleName + } + + var backPressListener: BackPressedListener? = null + var createInputConnectionListener: CreateInputConnectionListener? = null + + var popup: ListPopupWindow? = null + val plugins: MutableList = arrayListOf() + + init { + } + + fun getMessageContent(): MessageContent { + return MessageContent().apply { + text = getText().toString() + if(getText().getSpans(0, getText().length, MentionSpan::class.java).isNotEmpty()){ + messageInputMentions = ArrayList() + } + for (span in getText().getSpans(0, getText().length, MentionSpan::class.java)) { + try { + span.mention.start = this.text.utf8Offset(span.mention.start) + span.mention.end = this.text.utf8Offset(span.mention.end) + messageInputMentions?.add(span.mention) + } catch (e: IndexOutOfBoundsException) { + Log.e(TAG, e.message) + // Remove mentions that exist outside the bounds of the message text + continue + } + } + } + } + + override fun getText(): Editable { + return super.getText() ?: Editable.Factory.getInstance().newEditable("") + } + + private fun dismissPopup() { + popup?.dismiss() + popup = null + } + + private fun getFilter(): Filter? { + val position = selectionStart + var start = 0 + for (i in (position - 1) downTo 0) { + if (Character.isWhitespace(text[i])) { + start = Math.min(i + 1, position) + break + } + } + if (text.getSpans(position, position, MentionSpan::class.java).isEmpty()) { + val s = text.subSequence(start, position).toString() + if (s.isNotEmpty()) { + return Filter(s, start) + } + } + return null + } + + override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + Log.e(TAG, "text : $text") + // Shift mention span ranges as needed when text is edited + if (text is Spannable) { + Log.e(TAG, "text is Spannable") + text.getSpans(0, text.length, MentionSpan::class.java).filter { span -> + span.mention.end.toInt() >= start + }.forEach { span -> + if (span.mention.start <= start && span.mention.end > start) { + + Log.e(TAG, "text.removeSpan") + text.removeSpan(span) + } else { + // Don't shift the start position if the user is editing a mention + if (span.mention.start.toInt() > start) { + span.mention.start += lengthAfter - lengthBefore + } + + // Don't shift the end of a span if that's the character that's being removed + if (span.mention.end.toInt() != start) { + span.mention.end += lengthAfter - lengthBefore + } + } + } + } + + val filter = getFilter() + + Log.e(TAG, "filter : ${filter?.text}") + if (filter == null) { + Log.e(TAG, "filter is null") + dismissPopup() + return + } + + val plugin = plugins.find { it.shouldTrigger(filter) } + + Log.e(TAG, "plugin : $plugin") + plugin?.apply { + onFilterChanged(filter.text) + if (plugin.hasItems()) { + + Log.e(TAG, "plugin hasItems()") + if (popup == null) { + + Log.e(TAG, "popup null showing popup") + popup = ListPopupWindow(context).apply { + anchorView = this@MentionsAutoCompleteEditText + setAdapter(plugin.getAdapter()) + setOnItemClickListener { _, _, position, _ -> + getFilter()?.apply { + plugin.itemSelected(position, this@MentionsAutoCompleteEditText, this) + } + dismissPopup() + } + show() + } + } + } else { + + Log.e(TAG, "plugin has no items, dismissPopup()") + dismissPopup() + } + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + dismissPopup() + } + + fun hasAutoCompletePlugin(): Boolean = plugins.size > 0 + + fun addAutoCompletePlugin(plugin: AutoCompletePlugin) { + plugins.removeAll { + it.javaClass.isInstance(plugin) + } + plugins.add(plugin) + } + + fun updateEditTextMaxLines(hasText: Boolean) { + maxLines = if (hasText) MAX_LINES else 1 + ellipsize = if (hasText) null else TextUtils.TruncateAt.END + } + + fun isEmpty(): Boolean { + return text.isEmpty() + } + + fun reset() { + setText("") + } + + override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + backPressListener?.apply { + onImeBack(this@MentionsAutoCompleteEditText) + } + } + return super.onKeyPreIme(keyCode, event) + } + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { + val ic = super.onCreateInputConnection(outAttrs) + createInputConnectionListener?.apply { + return onCreateInputConnection(outAttrs, ic) + } + return ic + } +} + +class MentionsPlugin( + val lifecycleOwner: LifecycleOwner, + val context: Context, + val messageComposerViewModel: MessageComposerViewModel +) : AutoCompletePlugin { + val adapter = MentionsPopupAdapter() + + override fun getAdapter(): BaseAdapter = adapter + override fun hasItems(): Boolean = adapter.count > 0 + + override fun onFilterChanged(text: String) { + adapter.filter(text) + } + + override fun shouldTrigger(filter: Filter): Boolean { + val shouldTrigger = filter.text[0] == '@' + + Log.e(MentionsAutoCompleteEditText.TAG, "shouldTrigger : $shouldTrigger") + return shouldTrigger + } + + override fun itemSelected(position: Int, editText: MentionsAutoCompleteEditText, filter: Filter) { + val mentionable = adapter.getItem(position) as MembershipModel? ?: return + val shortText = mentionable.personFirstName + // Temporary work around for checking if there are duplicate first names, this would hopefully be something that would be returned to us + // by the view model + val text = if (adapter.firstNameCheck(shortText).size > 1) getFullNameIfLastFirstFormat(mentionable.personDisplayName) else shortText + editText.text.replace(filter.position, filter.position + filter.text.length, text) + val endMention = filter.position + text.length + if (editText.text.length == endMention || editText.text.length > endMention && !Character.isWhitespace(editText.text[endMention])) { + editText.text.insert(endMention, " ") + } + + val mention = when (position) { + 0 -> { + Mention.All(filter.position, endMention) + } + else -> { + Mention.Person(mentionable.personId).apply { + start = filter.position + end = endMention + } + } + } + val span = MentionSpan(context, mention) + + editText.text.setSpan(span, mention.start, mention.end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + private fun getFullNameIfLastFirstFormat(displayName: String): String { + return if (displayName.contains(",")) { + displayName.split(",")[1].trim() + " " + displayName.split(",")[0].trim() + } else { + displayName + } + } + + inner class MentionsPopupAdapter : BaseAdapter() { + val inflator = LayoutInflater.from(context) + var mentions = mutableListOf() + var currentSearch = "" + + override fun getItem(position: Int): Any? { + if (position >= mentions.size) { + return null + } + return mentions[position] + } + + override fun getItemId(position: Int): Long { + if (position >= mentions.size) { + return 0L + } + return mentions[position].hashCode().toLong() + } + + override fun getCount(): Int = mentions.size + + fun filter(filter: String) { + mentions.clear() + notifyDataSetChanged() + currentSearch = filter + mentions = messageComposerViewModel.search(filter).toMutableList() + + Log.e(MentionsAutoCompleteEditText.TAG, "mentions.size : ${mentions.size}") + notifyDataSetChanged() + } + + fun firstNameCheck(firstName: String): MutableList = messageComposerViewModel.search(firstName).toMutableList() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { + val viewHolder = if (convertView == null) { + ViewHolder(ListItemMentionBinding.inflate(inflator)) + } else { + convertView.tag as ViewHolder + } + return viewHolder.bind(mentions[position]) + } + + inner class ViewHolder(val binding: ListItemMentionBinding) { + init { + binding.root.tag = this + } + + fun bind(item: MembershipModel): View { + binding.apply { + lifecycleOwner = this@MentionsPlugin.lifecycleOwner + membership = item + } + binding.executePendingBindings() + return binding.root + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerActivity.kt new file mode 100644 index 0000000..02ec3c9 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerActivity.kt @@ -0,0 +1,422 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.composer + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.Html +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityMessageComposerBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogPostMessageHandlerBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemUploadAttachmentBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerViewModel.Companion.MINIMUM_MEMBERS_REQUIRED_FOR_MENTIONS +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.ReplyMessageModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.FileUtils.getUploadUriPath +import com.ciscowebex.androidsdk.kitchensink.utils.PermissionsHelper +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.hideKeyboard +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import com.ciscowebex.androidsdk.message.LocalFile +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.utils.EmailAddress +import com.ciscowebex.androidsdk.utils.internal.MimeUtils +import org.koin.android.ext.android.inject +import java.io.File + + +class MessageComposerActivity : AppCompatActivity() { + + companion object { + enum class ComposerType { + POST_SPACE, + POST_PERSON_ID, + POST_PERSON_EMAIL + } + + enum class StyleType { + PLAIN_TEXT, + MARKDOWN_TEXT + } + + fun getIntent(context: Context, type: ComposerType, id: String, replyParentMessage: ReplyMessageModel?, messageId: String? = null): Intent { + val intent = Intent(context, MessageComposerActivity::class.java) + intent.putExtra(Constants.Intent.COMPOSER_TYPE, type) + intent.putExtra(Constants.Intent.COMPOSER_ID, id) + intent.putExtra(Constants.Intent.COMPOSER_REPLY_PARENT_MESSAGE, replyParentMessage) + intent.putExtra(Constants.Intent.MESSAGE_ID, messageId) + return intent + } + } + + private val tag = "MessageComposerActivity" + private val PICKFILE_REQUEST_CODE = 1005 + private lateinit var binding: ActivityMessageComposerBinding + private val messageComposerViewModel: MessageComposerViewModel by inject() + private val permissionsHelper: PermissionsHelper by inject() + private lateinit var composerType: ComposerType + private var id: String? = null + private var styleType = StyleType.PLAIN_TEXT + private lateinit var attachmentAdapter: UploadAttachmentsAdapter + private var isMentionsEnabled: Boolean = false + private var replyParentMessage: ReplyMessageModel? = null + // MessageId is not null in case of edit feature. + private var messageId: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + composerType = intent.getSerializableExtra(Constants.Intent.COMPOSER_TYPE) as ComposerType + id = intent.getStringExtra(Constants.Intent.COMPOSER_ID) + replyParentMessage = intent.getParcelableExtra(Constants.Intent.COMPOSER_REPLY_PARENT_MESSAGE) + messageId = intent.getStringExtra(Constants.Intent.MESSAGE_ID) + + if (composerType == ComposerType.POST_SPACE) { + isMentionsEnabled = true + messageComposerViewModel.fetchAllMembersInSpace(id) + } + DataBindingUtil.setContentView(this, R.layout.activity_message_composer) + .also { binding = it } + .apply { + + plainRadioButton.isChecked = true + + sendButton.setOnClickListener { + sendButtonClicked() + } + + setUpObservers() + + if (messageId == null) { + attachmentButton.setOnClickListener { + val checkingPermission = checkReadStoragePermissions() + if (!checkingPermission) { + openFileExplorer() + } + } + } else { + // In case of edit we do not support editing attachments + attachmentButton.visibility = View.GONE + } + + radioGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.plainRadioButton -> { + styleType = StyleType.PLAIN_TEXT + } + R.id.markdownRadioButton -> { + styleType = StyleType.MARKDOWN_TEXT + } + } + } + + val onAttachmentCrossClick: (File) -> Unit = { file -> + Log.d(tag, "onAttachmentCrossClick path: ${file.absolutePath}") + val position = attachmentAdapter.attachedFiles.indexOf(file) + attachmentAdapter.attachedFiles.removeAt(position) + attachmentAdapter.notifyItemRemoved(position) + } + + val dividerItemDecoration = DividerItemDecoration(this@MessageComposerActivity, LinearLayoutManager.VERTICAL) + attachmentRecyclerView.addItemDecoration(dividerItemDecoration) + attachmentAdapter = UploadAttachmentsAdapter(onAttachmentCrossClick) + attachmentRecyclerView.adapter = attachmentAdapter + } + + } + + private fun setUpObservers() { + messageComposerViewModel.postMessages.observe(this@MessageComposerActivity, Observer { + it?.let { + displayPostMessageHandler(it) + } ?: run { + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_internal_error, "") + } + resetView() + }) + + messageComposerViewModel.postMessageError.observe(this@MessageComposerActivity, Observer { + it?.let { + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_internal_error, it) + } ?: run { + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_internal_error, "") + } + resetView() + }) + + messageComposerViewModel.fetchMembershipsLiveData.observe(this@MessageComposerActivity, Observer { memberships -> + memberships?.let { + if(isMentionsEnabled && it.size > MINIMUM_MEMBERS_REQUIRED_FOR_MENTIONS ) { + binding.message.addAutoCompletePlugin(MentionsPlugin(this@MessageComposerActivity, this, messageComposerViewModel)) + } + } + }) + + messageComposerViewModel.editMessage.observe(this@MessageComposerActivity, Observer { + it?.let { + showDialogWithMessage(this@MessageComposerActivity, null, getString(R.string.message_edit_successful)) + } ?: run { + showDialogWithMessage(this@MessageComposerActivity, null, getString(R.string.edit_message_internal_error)) + } + resetView() + }) + } + + private fun openFileExplorer() { + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + type = "*/*" + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + } + startActivityForResult(intent, PICKFILE_REQUEST_CODE) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == PICKFILE_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + data?.let { intent -> + intent.clipData?.let { data -> + for (index in 0 until data.itemCount) { + val uri = data.getItemAt(index).uri + addUriToList(uri) + } + } ?: run { + intent.data?.let { uri -> + addUriToList(uri) + } + } + } + } + } + + private fun addUriToList(uri: Uri) { + val filePath = getUploadUriPath(this@MessageComposerActivity, uri) + filePath?.let { + val file = File(it) + Log.d(tag, "PICKFILE_REQUEST_CODE filePath: $it") + Log.d(tag, "PICKFILE_REQUEST_CODE file Exist: ${file.exists()}") + + attachmentAdapter.attachedFiles.add(file) + attachmentAdapter.notifyDataSetChanged() + } + } + + private fun processAttachmentFiles(): ArrayList { + val files = ArrayList() + + for (file in attachmentAdapter.attachedFiles) { + var thumbnail: LocalFile.Thumbnail? = null + if (MimeUtils.getContentTypeByFilename(file.name) == MimeUtils.ContentType.IMAGE) { + thumbnail = LocalFile.Thumbnail(file, null, resources.getInteger(R.integer.attachment_thumbnail_width), resources.getInteger(R.integer.attachment_thumbnail_height)) + } + val localFile = LocalFile(file, null, thumbnail, null) + files.add(localFile) + } + + return files + } + + private fun sendButtonClicked() { + if (binding.message.text.isEmpty() && attachmentAdapter.attachedFiles.isEmpty()) { + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_error, getString(R.string.post_message_empty_error)) + } else { + messageId?.let { + // Edit message flow + editMessage(it) } + ?: composerType.let { type -> + id?.let { + val files = processAttachmentFiles() + when (type) { + ComposerType.POST_SPACE -> { + postToSpace(it, files) + } + ComposerType.POST_PERSON_ID -> { + postPersonById(it, files) + } + ComposerType.POST_PERSON_EMAIL -> { + postPersonByEmail(it, files) + } + } + } + } + } + } + + private fun editMessage(messageId: String) { + val str = binding.message.text.toString() + val messageContent = binding.message.getMessageContent() + val text: Message.Text = if (styleType == StyleType.PLAIN_TEXT) { + Message.Text.plain(str) + } else { + Message.Text.markdown(str, null, null) + } + + messageComposerViewModel.editMessage(messageId, text, messageContent.messageInputMentions) + } + + private fun displayPostMessageHandler(message: Message) { + val builder: androidx.appcompat.app.AlertDialog.Builder = androidx.appcompat.app.AlertDialog.Builder(this) + + builder.setTitle(R.string.message_details) + + DialogPostMessageHandlerBinding.inflate(layoutInflater) + .apply { + messageData = message + val msg = message.getTextAsObject() + + msg.getMarkdown()?.let { + messageBodyTextView.text = Html.fromHtml(msg.getMarkdown(), Html.FROM_HTML_MODE_LEGACY) + } ?: run { + msg.getPlain()?.let { + messageBodyTextView.text = Html.fromHtml(msg.getPlain(), Html.FROM_HTML_MODE_LEGACY) + } + } + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + } + + builder.show() + } + } + + private fun postPersonByEmail(email: String, files: ArrayList?) { + val emailAddress = EmailAddress.fromString(email) + emailAddress?.let { + messageComposerViewModel.postToPerson(emailAddress, binding.message.text.toString(), styleType == StyleType.PLAIN_TEXT, files) + showProgress() + } ?: run { + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_error, getString(R.string.post_message_email_empty)) + } + } + + private fun postPersonById(personId: String, files: ArrayList?) { + messageComposerViewModel.postToPerson(personId, binding.message.text.toString(), styleType == StyleType.PLAIN_TEXT, files) + showProgress() + } + + private fun postToSpace(spaceId: String, files: ArrayList?) { + val messageContent = binding.message.getMessageContent() + + var progress = true + + replyParentMessage?.let { replyMessage -> + val str = binding.message.text.toString() + + val text: Message.Text? = if (styleType == StyleType.PLAIN_TEXT) { + Message.Text.plain(str) + } else { + Message.Text.markdown(str, null, null) + } + + text?.let { msgTxt -> + val draft = Message.draft(msgTxt) + + messageContent.messageInputMentions?.let { mentionsArray -> + for (item in mentionsArray) { + draft.addMentions(item) + } + } + + files?.let { filesArray -> + for (item in filesArray) { + draft.addAttachments(item) + } + } + + draft.setParent(replyMessage.getMessage()) + + messageComposerViewModel.postMessageDraft(spaceId, draft) + } ?: run { + progress = false + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_error, getString(R.string.post_message_invalid_message)) + } + } ?: run { + messageComposerViewModel.postToSpace(spaceId, binding.message.text.toString(), styleType == StyleType.PLAIN_TEXT, messageContent.messageInputMentions, files) + } + + if (progress) { + showProgress() + } + } + + private fun showProgress() { + binding.progressLayout.visibility = View.VISIBLE + } + + private fun hideProgress() { + binding.progressLayout.visibility = View.GONE + } + + private fun resetView() { + binding.message.text.clear() + hideKeyboard(binding.message) + attachmentAdapter.attachedFiles.clear() + attachmentAdapter.notifyDataSetChanged() + hideProgress() + } + + private fun checkReadStoragePermissions(): Boolean { + if (!permissionsHelper.hasReadStoragePermission()) { + Log.d(tag, "requesting read permission") + requestPermissions(PermissionsHelper.permissionForStorage(), PermissionsHelper.PERMISSIONS_STORAGE_REQUEST) + return true + } else { + Log.d(tag, "read permission granted") + } + return false + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + when (requestCode) { + PermissionsHelper.PERMISSIONS_STORAGE_REQUEST -> { + if (PermissionsHelper.resultForCallingPermissions(permissions, grantResults)) { + Log.d(tag, "read permission granted") + openFileExplorer() + } else { + Toast.makeText(this@MessageComposerActivity, getString(R.string.post_message_attach_permission_error), Toast.LENGTH_LONG).show() + } + } + } + } + + class UploadAttachmentsAdapter(private val onAttachmentCrossClick: (File) -> Unit) : RecyclerView.Adapter() { + var attachedFiles: MutableList = mutableListOf() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UploadAttachmentsAdapter.AttachmentViewHolder { + val binding = ListItemUploadAttachmentBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return AttachmentViewHolder(binding, onAttachmentCrossClick) + } + + override fun getItemCount(): Int { + return attachedFiles.size + } + + override fun onBindViewHolder(holder: AttachmentViewHolder, position: Int) { + holder.bind(attachedFiles[position]) + } + + inner class AttachmentViewHolder(private val binding: ListItemUploadAttachmentBinding, private val onAttachmentCrossClick: (File) -> Unit) : RecyclerView.ViewHolder(binding.root) { + init { + binding.buttonLayout.setOnClickListener { + onAttachmentCrossClick(attachedFiles[adapterPosition]) + } + } + + fun bind(file: File) { + binding.name.text = file.name + binding.path.text = file.path + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerRepository.kt new file mode 100644 index 0000000..8f709bf --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerRepository.kt @@ -0,0 +1,77 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.composer + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.message.LocalFile +import com.ciscowebex.androidsdk.message.Mention +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.utils.EmailAddress +import io.reactivex.Observable +import io.reactivex.Single + + +open class MessageComposerRepository(private val webex: Webex) { + + fun postToSpace(spaceId: String, message: String, plainText: Boolean, mentions: ArrayList?, files: ArrayList?): Observable { + return Single.create { emitter -> + val text: Message.Text? = if (plainText) { + Message.Text.plain(message) + } else { + Message.Text.markdown(message, null, null) + } + webex.messages.postToSpace(spaceId, text, mentions, files, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data!!) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun postToPerson(email: EmailAddress, message: String, plainText: Boolean, files: ArrayList?): Observable { + return Single.create { emitter -> + val text: Message.Text? = if (plainText) { + Message.Text.plain(message) + } else { + Message.Text.markdown(message, null, null) + } + webex.messages.postToPerson(email, text, files, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data!!) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun postToPerson(id: String, message: String, plainText: Boolean, files: ArrayList?): Observable { + return Single.create { emitter -> + val text: Message.Text? = if (plainText) { + Message.Text.plain(message) + } else { + Message.Text.markdown(message, null, null) + } + webex.messages.postToPerson(id, text, files, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data!!) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun postMessageDraft(target: String, draft: Message.Draft): Observable { + return Single.create { emitter -> + webex.messages.post(target, draft, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data!!) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerViewModel.kt new file mode 100644 index 0000000..c03204d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerViewModel.kt @@ -0,0 +1,95 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.composer + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipRepository +import com.ciscowebex.androidsdk.message.LocalFile +import com.ciscowebex.androidsdk.message.Mention +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.utils.EmailAddress +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlin.collections.ArrayList +import java.util.Date + + +class MessageComposerViewModel(private val composerRepo: MessageComposerRepository, private val membershipRepo: MembershipRepository, + private val spacesRepository: SpacesRepository) : BaseViewModel() { + + companion object { + val MINIMUM_MEMBERS_REQUIRED_FOR_MENTIONS = 2 + } + private val tag = "MessageComposerViewModel" + + private val _postMessages = MutableLiveData() + val postMessages: LiveData = _postMessages + + private val _postMessageError = MutableLiveData() + val postMessageError: LiveData = _postMessageError + + private val _fetchMembershipsLiveData = MutableLiveData>() + val fetchMembershipsLiveData: LiveData> = _fetchMembershipsLiveData + + private val _editMessage = MutableLiveData() + val editMessage: LiveData = _editMessage + + private var membersList = mutableListOf() + + val labelAll = "All" + + fun postToSpace(spaceId: String, message: String, plainText: Boolean, mentions: ArrayList?, files: ArrayList? = null) { + composerRepo.postToSpace(spaceId, message, plainText, mentions, files).observeOn(AndroidSchedulers.mainThread()).subscribe({ result -> + _postMessages.postValue(result) + }, { error -> _postMessageError.postValue(error.message) }).autoDispose() + } + + fun postToPerson(email: EmailAddress, message: String, plainText: Boolean, files: ArrayList? = null) { + composerRepo.postToPerson(email, message, plainText, files).observeOn(AndroidSchedulers.mainThread()).subscribe({ result -> + _postMessages.postValue(result) + }, { error -> _postMessageError.postValue(error.message) }).autoDispose() + } + + fun postToPerson(id: String, message: String, plainText: Boolean, files: ArrayList? = null) { + composerRepo.postToPerson(id, message, plainText, files).observeOn(AndroidSchedulers.mainThread()).subscribe({ result -> + Log.d(tag, "postToPersonID result: $result") + _postMessages.postValue(result) + }, { error -> _postMessageError.postValue(error.message) }).autoDispose() + } + + fun postMessageDraft(target: String, draft: Message.Draft) { + composerRepo.postMessageDraft(target, draft).observeOn(AndroidSchedulers.mainThread()).subscribe({ result -> + _postMessages.postValue(result) + }, { error -> _postMessageError.postValue(error.message) }).autoDispose() + } + + fun editMessage(messageId: String, messageText: Message.Text, mentions: ArrayList?) { + spacesRepository.editMessage(messageId, messageText, mentions).observeOn(AndroidSchedulers.mainThread()).subscribe({ result -> + _editMessage.postValue(result) + }, { error -> _postMessageError.postValue(error.message) }).autoDispose() + } + + fun fetchAllMembersInSpace(spaceId: String?, max: Int? = null) { + membershipRepo.getMembersInSpace(spaceId, max).observeOn(AndroidSchedulers.mainThread()).subscribe({ memberships -> + // Crete a membership model indicating all members + val allMember = MembershipModel(labelAll, "", "", labelAll, "", false, false, Date(), "", labelAll, "") + membersList.add(allMember) + membersList.addAll(memberships) + _fetchMembershipsLiveData.postValue(memberships) + }, { membersList.clear() }).autoDispose() + } + + fun search(filter: String): List { + return membersList.filter { + if (filter.isNotEmpty() && filter[0] == '@') { + it.personDisplayName.startsWith(filter.substring(1)) + } else { + it.personDisplayName.startsWith(filter) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/MessagingSearchActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/MessagingSearchActivity.kt new file mode 100644 index 0000000..523886b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/MessagingSearchActivity.kt @@ -0,0 +1,23 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.ciscowebex.androidsdk.kitchensink.R + +/** + * Simple search activity that has Search Fragment inside + */ +class MessagingSearchActivity : AppCompatActivity() { + companion object { + fun getIntent(context: Context): Intent { + return Intent(context, MessagingSearchActivity::class.java) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_search_messaging) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragment.kt new file mode 100644 index 0000000..493c8db --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragment.kt @@ -0,0 +1,163 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SearchView +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentCommonBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemPersonsBinding +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import kotlinx.android.synthetic.main.fragment_common.* +import org.koin.android.ext.android.inject + +class SearchPeopleFragment : Fragment() { + private val searchPeopleViewModel: SearchPeopleViewModel by inject() + lateinit var personAdapter: SearchPersonAdapter + var listItemSize: Int = 0 + + companion object { + val TAG = SearchPeopleFragment::class.java.simpleName + + fun getInstance(): SearchPeopleFragment { + return SearchPeopleFragment() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return FragmentCommonBinding.inflate(inflater, container, false).apply { + recyclerView.itemAnimator = DefaultItemAnimator() + personAdapter = SearchPersonAdapter { selectedPerson -> + finishActivityAndReturnValue(selectedPerson) + } + recyclerView.adapter = personAdapter + listItemSize = resources.getInteger(R.integer.space_list_size) + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + progressBar.visibility = View.VISIBLE + searchPeopleViewModel.loadData(newText, listItemSize) + return false + } + + }) + + setUpViewModelObservers() + + }.root + + } + + private fun finishActivityAndReturnValue(selectedPerson: PersonModel) { + val returnIntent = Intent() + returnIntent.putExtra(Constants.Intent.PERSON, selectedPerson) + activity?.setResult(RESULT_OK, returnIntent) + activity?.finish() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + searchPeopleViewModel.loadData("", listItemSize) + progress_bar.visibility = View.VISIBLE + } + + + private fun setUpViewModelObservers() { + // TODO: Put common code inside a function + searchPeopleViewModel.persons.observe(viewLifecycleOwner, Observer { personsList -> + personsList?.let { + if (it.isNotEmpty()) { + updateEmptyListUI(false) + personAdapter.personsList = it + personAdapter.notifyDataSetChanged() + } else { + updateEmptyListUI(true) + personAdapter.personsList = emptyList() + personAdapter.notifyDataSetChanged() + } + } + }) + searchPeopleViewModel.peopleError.observe(viewLifecycleOwner, Observer { error -> + error?.let { + personAdapter.personsList = emptyList() + personAdapter.notifyDataSetChanged() + showDialogWithMessage(R.string.error_occurred, it) + } + }) + } + + private fun showDialogWithMessage(titleResourceId: Int?, message: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(titleResourceId ?: R.string.message) + val tvMessage = TextView(requireContext()) + tvMessage.setPadding(10, 10, 10, 10) + tvMessage.text = message + + builder.setView(tvMessage) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } + + private fun updateEmptyListUI(listEmpty: Boolean) { + progress_bar.visibility = View.GONE + if (listEmpty) { + tv_empty_data.visibility = View.VISIBLE + recycler_view.visibility = View.GONE + } else { + tv_empty_data.visibility = View.GONE + recycler_view.visibility = View.VISIBLE + } + } + + class SearchPersonAdapter(private val listItemClick: (PersonModel) -> Unit) : + RecyclerView.Adapter() { + var personsList: List = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, i: Int): ViewHolder { + return ViewHolder(ListItemPersonsBinding.inflate(LayoutInflater.from(parent.context), parent, false)) { position -> + listItemClick(personsList[position]) + } + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + viewHolder.bind(personsList[position]) + } + + override fun getItemCount(): Int { + return personsList.size + } + + inner class ViewHolder(val binding: ListItemPersonsBinding, val listItemClicked: (Int) -> Unit) : + RecyclerView.ViewHolder(binding.root) { + init { + binding.rootListItemPersonsView.setOnClickListener { + listItemClicked(adapterPosition) + } + } + + fun bind(itemModel: PersonModel) { + binding.listItem = itemModel + binding.executePendingBindings() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleModule.kt new file mode 100644 index 0000000..07e3957 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleModule.kt @@ -0,0 +1,9 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import com.ciscowebex.androidsdk.kitchensink.person.PersonRepository +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val searchPeopleModule = module { + viewModel { SearchPeopleViewModel(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleViewModel.kt new file mode 100644 index 0000000..00e1565 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleViewModel.kt @@ -0,0 +1,33 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.kitchensink.person.PersonRepository +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.isValidEmail +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers + +class SearchPeopleViewModel(private val peopleRepository: PersonRepository) : BaseViewModel() { + private val tag = "SearchPeopleViewModel" + private val _persons = MutableLiveData>() + val persons: LiveData> = _persons + private val _peopleError = MutableLiveData() + val peopleError: LiveData = _peopleError + + private val _searchResult = MutableLiveData>() + + fun loadData(key: String?, maxPeopleCount: Int) { + val observable: Observable> = if (key.isValidEmail()) + peopleRepository.getPeopleList(key, null, null, null, maxPeopleCount) + else + peopleRepository.getPeopleList(null, key, null, null, maxPeopleCount) + observable.observeOn(AndroidSchedulers.mainThread()).subscribe({ + _persons.postValue(it) + }, { + _peopleError.postValue(it.message) + }).autoDispose() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/AddPersonBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/AddPersonBottomSheetFragment.kt new file mode 100644 index 0000000..f91bdda --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/AddPersonBottomSheetFragment.kt @@ -0,0 +1,35 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetAddMemberOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class AddPersonBottomSheetFragment(private val onOptionSelected: (Options) -> Unit) : BottomSheetDialogFragment() { + companion object { + val TAG = AddPersonBottomSheetFragment::class.java.simpleName + enum class Options { + ADD_BY_PERSON_ID, + ADD_BY_EMAIL_ID + } + } + + private lateinit var binding: BottomSheetAddMemberOptionsBinding + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetAddMemberOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + binding.addPersonByIdLabel.setOnClickListener { + onOptionSelected(Options.ADD_BY_PERSON_ID) + dismiss() + } + binding.addPersonByEmailLabel.setOnClickListener { + onOptionSelected(Options.ADD_BY_EMAIL_ID) + dismiss() + } + binding.cancel.setOnClickListener { + dismiss() + } + }.root + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/ReplyMessageModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/ReplyMessageModel.kt new file mode 100644 index 0000000..339b8dc --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/ReplyMessageModel.kt @@ -0,0 +1,58 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import android.os.Parcelable +import com.ciscowebex.androidsdk.message.Message +import kotlinx.android.parcel.Parcelize + +class InternalMessage(private val model: ReplyMessageModel) : Message() { + override fun getId(): String? { + return model.messageId + } + + override fun getParentId(): String? { + return model.parentId + } + + override fun getSpaceId(): String? { + return model.spaceId + } + + override fun getCreated(): Long { + return model.created + } + + override fun isSelfMentioned(): Boolean { + return model.isSelfMentioned + } + + override fun isReply(): Boolean { + return model.isReply + } + + override fun getPersonId(): String? { + return model.personId + } + + override fun getPersonEmail(): String? { + return model.personEmail + } + + override fun getToPersonId(): String? { + return model.toPersonId + } + + override fun getToPersonEmail(): String? { + return model.toPersonEmail + } +} + +@Parcelize +class ReplyMessageModel(val spaceId: String, val messageId: String, + val created: Long, val isSelfMentioned: Boolean, val parentId: String, + val isReply: Boolean, val personId: String, + val personEmail: String, val toPersonId: String, val toPersonEmail: String) : Parcelable { + + fun getMessage(): Message { + return InternalMessage(this) + } +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceActionBottomSheetFragment.kt new file mode 100644 index 0000000..8c11d64 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceActionBottomSheetFragment.kt @@ -0,0 +1,58 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetSpaceOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class SpaceActionBottomSheetFragment(val editClickListener: (String, String) -> Unit, + val getMeetingInfoClickListener: (String) -> Unit, + val listMembersInSpaceClickListener: (String) -> Unit, + val deleteSpaceClickListener: (String, String) -> Unit, + val markSpaceReadClickListener: (String) -> Unit, + val showSpaceMembersWithReadStatusClickListener: (String) -> Unit) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetSpaceOptionsBinding + var spaceId: String = "" + var spaceTitle: String = "" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetSpaceOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + editSpaceName.setOnClickListener { + dismiss() + editClickListener(spaceId, spaceTitle) + } + + getMeetingInfo.setOnClickListener { + dismiss() + getMeetingInfoClickListener(spaceId) + } + + listMembersInSpace.setOnClickListener { + dismiss() + listMembersInSpaceClickListener(spaceId) + } + + showSpaceMembersWithReadStatus.setOnClickListener { + dismiss() + showSpaceMembersWithReadStatusClickListener(spaceId) + } + + markSpaceRead.setOnClickListener { + dismiss() + markSpaceReadClickListener(spaceId) + } + + deleteSpace.setOnClickListener { + dismiss() + deleteSpaceClickListener(spaceId, spaceTitle) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMeetingInfo.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMeetingInfo.kt new file mode 100644 index 0000000..d4302e5 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMeetingInfo.kt @@ -0,0 +1,39 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import com.ciscowebex.androidsdk.space.SpaceMeetingInfo + + +data class SpaceMeetingInfoModel(val spaceId: String, val meetingLink: String, val sipAddress: String, val meetingNumber: String, val callInTollFreeNumber: String, val callInTollNumber: String) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SpaceMeetingInfo + + return spaceId == other.spaceId + } + + override fun hashCode(): Int { + var result = spaceId.hashCode() + result = 31 * result + meetingLink.hashCode() + result = 31 * result + sipAddress.hashCode() + result = 31 * result + meetingNumber.hashCode() + result = 31 * result + callInTollFreeNumber.hashCode() + result = 31 * result + callInTollNumber.hashCode() + return result + } + + override fun toString(): String { + return "Space Id: $spaceId\n\nMeeting Link: $meetingLink\n\nSIP Address: $sipAddress\n\nMeeting Number: $meetingNumber\n\nCall In Toll Free Number: $callInTollFreeNumber\n\nCall In Toll Number: $callInTollNumber" + } + + companion object { + fun convertToSpaceMeetingInfoModel(spaceMeetingInfo: SpaceMeetingInfo?): SpaceMeetingInfoModel { + return SpaceMeetingInfoModel(spaceMeetingInfo?.spaceId.orEmpty(), + spaceMeetingInfo?.meetingLink.orEmpty(), spaceMeetingInfo?.sipAddress.orEmpty(), + spaceMeetingInfo?.meetingNumber.orEmpty(), spaceMeetingInfo?.callInTollFreeNumber.orEmpty(), + spaceMeetingInfo?.callInTollNumber.orEmpty()) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMessageModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMessageModel.kt new file mode 100644 index 0000000..3e61fbf --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMessageModel.kt @@ -0,0 +1,26 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import com.ciscowebex.androidsdk.space.Space.SpaceType +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.message.RemoteFile +import java.util.Date + +data class SpaceMessageModel(val spaceId: String, val messageId: String, val messageBody: Message.Text, + val created: Long, val isSelfMentioned: Boolean, val parentId: String, + val isReply: Boolean, val conversationType: SpaceType, val personId: String, + val personEmail: String, val toPersonId: String, val toPersonEmail: String, val attachments : List) { + + val createdDateTimeString: String = Date(created).toString() + var mMessage: Message? = null + companion object { + fun convertToSpaceMessageModel(message: Message?): SpaceMessageModel { + + val model = SpaceMessageModel(message?.getSpaceId().orEmpty(), message?.getId().orEmpty(), message?.getTextAsObject()?: Message.Text(), + message?.getCreated() ?: 0, message?.isSelfMentioned() ?: false, message?.getParentId().orEmpty(), + message?.isReply() ?: false, SpaceType.valueOf(message?.getSpaceType().toString()), message?.getPersonId().orEmpty(), + message?.getPersonEmail().orEmpty(), message?.getToPersonId().orEmpty(), message?.getToPersonEmail().orEmpty(), message?.getFiles().orEmpty()) + model.mMessage = message + return model + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceModel.kt new file mode 100644 index 0000000..fc19471 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceModel.kt @@ -0,0 +1,45 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import com.ciscowebex.androidsdk.space.Space +import com.ciscowebex.androidsdk.space.Space.SpaceType +import java.util.* + +data class SpaceModel(val id: String, val title: String, val spaceType: SpaceType, val isLocked: Boolean, val lastActivity: Date, val created: Date, val teamId: String, val sipAddress: String) { + + val createdDateTimeString: String = created.toString() + val lastActivityTimestampString: String = lastActivity.toString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SpaceModel + + return id == other.id + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + title.hashCode() + result = 31 * result + spaceType.hashCode() + result = 31 * result + isLocked.hashCode() + result = 31 * result + lastActivity.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + teamId.hashCode() + result = 31 * result + sipAddress.hashCode() + result = 31 * result + createdDateTimeString.hashCode() + result = 31 * result + lastActivityTimestampString.hashCode() + return result + } + + companion object { + fun convertToSpaceModel(space: Space?): SpaceModel { + return SpaceModel(space?.id.orEmpty(), space?.title.orEmpty(), space?.type + ?: SpaceType.NONE, + space?.isLocked ?: false, space?.lastActivity + ?: Date(), space?.created ?: Date(), + space?.teamId.orEmpty(), space?.sipAddress.orEmpty()) + } + } + +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceReadStatusModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceReadStatusModel.kt new file mode 100644 index 0000000..36af047 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceReadStatusModel.kt @@ -0,0 +1,37 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import com.ciscowebex.androidsdk.space.SpaceReadStatus +import com.ciscowebex.androidsdk.space.Space.SpaceType +import java.util.* + +data class SpaceReadStatusModel(val spaceId: String, val spaceType: SpaceType, val lastActivityDate: Date, val lastSeenDate: Date) { + val spaceTypeString: String = spaceType.name + val lastSeenDateTimeString: String = lastSeenDate.toString() + val lastActivityTimestampString: String = lastActivityDate.toString() + val isSpaceUnread: Boolean = lastActivityDate > lastSeenDate + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SpaceReadStatus + + return spaceId == other.id + } + + override fun hashCode(): Int { + var result = spaceId.hashCode() + result = 31 * result + spaceType.hashCode() + result = 31 * result + lastActivityDate.hashCode() + result = 31 * result + lastSeenDate.hashCode() + return result + } + + companion object { + fun convertToSpaceReadStatusModel(spaceReadStatus: SpaceReadStatus?): SpaceReadStatusModel { + return SpaceReadStatusModel(spaceReadStatus?.id.orEmpty(),spaceReadStatus?.type + ?: SpaceType.NONE, spaceReadStatus?.lastActivityDate ?: Date(), + spaceReadStatus?.lastSeenDate ?: Date()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragment.kt new file mode 100644 index 0000000..939b3eb --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragment.kt @@ -0,0 +1,313 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.util.Log +import android.view.* +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogCreateSpaceBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentSpacesBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.search.MessagingSearchActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.adapters.SpaceReadStatusClientAdapter +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.adapters.SpacesClientAdapter +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus.MembershipReadStatusActivity +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import com.ciscowebex.androidsdk.space.Space +import org.koin.android.ext.android.inject + +class SpacesFragment : Fragment() { + private val TAG = SpacesFragment::class.java.simpleName + private val requestCodeSearchPersonToAddToSpace = 1919 + private lateinit var binding: FragmentSpacesBinding + private lateinit var spacesClientAdapter: SpacesClientAdapter + private val spacesReadClientAdapter: SpaceReadStatusClientAdapter = SpaceReadStatusClientAdapter() + + private val spacesViewModel: SpacesViewModel by inject() + + private var selectedSpaceListItem: SpaceModel? = null + private val addOnCallSuffix = "(On Call)" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return FragmentSpacesBinding.inflate(inflater, container, false).also { binding = it }.apply { + val optionsDialogFragment = SpaceActionBottomSheetFragment({ id, title -> showEditSpaceDialog(id, title) }, { id -> spacesViewModel.getMeetingInfo(id) }, + { id -> showMembersInSpace(id) }, { id, title -> showDeleteSpaceConfirmationDialog(id, title) }, { id -> markSpaceRead(id) }, { id -> showSpaceMembersWithReadStatus(id) }) + + spacesClientAdapter = SpacesClientAdapter(optionsDialogFragment, requireActivity().supportFragmentManager) { listItem -> + selectedSpaceListItem = listItem + startActivityForResult(context?.let { MessagingSearchActivity.getIntent(it) }, requestCodeSearchPersonToAddToSpace) + } + + setHasOptionsMenu(true) + + swipeContainer.setOnRefreshListener { + spacesViewModel.getSpacesList(resources.getInteger(R.integer.space_list_size)) + } + + setUpObservers() + + addSpacesFAB.setOnClickListener { + showAddSpaceDialog() + } + + spacesViewModel.getSpaceEvent()?.observe(viewLifecycleOwner, Observer { + when (it.first) { + WebexRepository.SpaceEvent.Updated -> { + if (it.second is Space) { + Log.d(TAG, "Space event ${(it.second as Space).title} is updated") + val space = SpaceModel.convertToSpaceModel(it.second as Space?) + val index = spacesClientAdapter.getPositionById(space.id) + if (!spacesClientAdapter.spaces.isNullOrEmpty() && index != -1) { + spacesClientAdapter.spaces[index] = space + spacesClientAdapter.notifyDataSetChanged() + } + } + } + WebexRepository.SpaceEvent.Created -> { + if (it.second is Space) { + val space = SpaceModel.convertToSpaceModel(it.second as Space?) + spacesClientAdapter.spaces.add(0, space) + spacesClientAdapter.notifyItemInserted(0) + Log.d(TAG, "Space event ${(it.second as Space).title} is created") + } + } + WebexRepository.SpaceEvent.CallStarted -> { + if (it.second is String?) { + val spaceId = it.second as String? + spaceId?.let { + val index = spacesClientAdapter.getPositionById(it) + if (!spacesClientAdapter.spaces.isNullOrEmpty() && index != -1) { + val space = spacesClientAdapter.spaces[index] + Log.d(TAG, "Space event ${space} is CallStarted") + val inCallSpace = SpaceModel(spaceId, space.title + " " + addOnCallSuffix, space.spaceType, space.isLocked, space.lastActivity, space.created, space.teamId, space.sipAddress) + spacesClientAdapter.spaces[index] = inCallSpace + spacesClientAdapter.notifyItemChanged(index) + } + } + } + } + WebexRepository.SpaceEvent.CallEnded -> { + if (it.second is String?) { + val spaceId = it.second as String? + spaceId?.let { + val index = spacesClientAdapter.getPositionById(it) + if (!spacesClientAdapter.spaces.isNullOrEmpty() && index != -1) { + val space = spacesClientAdapter.spaces[index] + Log.d(TAG, "Space event ${space.title} is CallEnded") + val inCallSpace = SpaceModel(spaceId, space.title.removeSuffix(addOnCallSuffix), space.spaceType, space.isLocked, space.lastActivity, space.created, space.teamId, space.sipAddress) + spacesClientAdapter.spaces[index] = inCallSpace + spacesClientAdapter.notifyItemChanged(index) + } + } + } + } + } + }) + }.root + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.messaging_menu, menu) + menu.getItem(0).isChecked = binding.spacesRecyclerView.adapter is SpaceReadStatusClientAdapter + + super.onCreateOptionsMenu(menu, inflater) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + item.isChecked = !item.isChecked + if (item.isChecked) { + binding.spacesRecyclerView.adapter = spacesReadClientAdapter + } else { + binding.spacesRecyclerView.adapter = spacesClientAdapter + } + return super.onOptionsItemSelected(item) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val maxSpaces = resources.getInteger(R.integer.space_list_size) + binding.spacesRecyclerView.adapter = spacesClientAdapter + spacesViewModel.getSpacesList(maxSpaces) + spacesViewModel.getSpaceReadStatusList(maxSpaces) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == requestCodeSearchPersonToAddToSpace && resultCode == Activity.RESULT_OK) { + val person = data?.getParcelableExtra(Constants.Intent.PERSON) + if (person != null) { + showAddMembersOptionDialog(person) + } else { + Log.d(TAG, "No person selected!") + } + + } else { + Log.d(TAG, "Person could not be found!") + } + } + + private fun setUpObservers() { + spacesViewModel.readStatusList.observe(this@SpacesFragment.viewLifecycleOwner, Observer { list -> + list?.let { + spacesReadClientAdapter.spaceReadStatusList = it + spacesReadClientAdapter.notifyDataSetChanged() + } + }) + + spacesViewModel.spaces.observe(this@SpacesFragment.viewLifecycleOwner, Observer { spaces -> + spaces?.let { + binding.swipeContainer.isRefreshing = false + + spacesClientAdapter.spaces.clear() + spacesClientAdapter.spaces.addAll(it) + spacesClientAdapter.notifyDataSetChanged() + } + }) + + spacesViewModel.addSpace.observe(this@SpacesFragment.viewLifecycleOwner, Observer { addspace -> + addspace?.let { + spacesClientAdapter.spaces.add(it) + spacesClientAdapter.notifyDataSetChanged() + } + }) + + spacesViewModel.spaceMeetingInfo.observe(this@SpacesFragment.viewLifecycleOwner, Observer { info -> + info?.let { + showGetMeetingInfoDialog(it) + } + }) + + spacesViewModel.spaceError.observe(this@SpacesFragment.viewLifecycleOwner, Observer { error -> + error?.let { + binding.progressLayout.visibility = View.GONE + showDialogWithMessage(requireContext(), R.string.error_occurred, it) + } + }) + + spacesViewModel.createMemberData.observe(this@SpacesFragment.viewLifecycleOwner, Observer { data -> + data?.let { + binding.progressLayout.visibility = View.GONE + val message = "${it.personDisplayName} added to ${it.spaceId}" + showDialogWithMessage(requireContext(), R.string.success, message) + } + }) + + spacesViewModel.markSpaceRead.observe(this@SpacesFragment.viewLifecycleOwner, Observer { + binding.progressLayout.visibility = View.GONE + showDialogWithMessage(requireContext(), R.string.success, getString(R.string.space_marked_as_read)) + }) + + spacesViewModel.deleteSpace.observe(this@SpacesFragment.viewLifecycleOwner, Observer { spaceId -> + spaceId?.let { + binding.progressLayout.visibility = View.GONE + val index = spacesClientAdapter.getPositionById(it) + spacesClientAdapter.spaces.removeAt(index) + spacesClientAdapter.notifyItemRemoved(index) + } + }) + } + + // Dialog to display various options of adding person to space + private fun showAddMembersOptionDialog(person: PersonModel) { + val addMembersOptionDialog = AddPersonBottomSheetFragment { option -> + when (option) { + AddPersonBottomSheetFragment.Companion.Options.ADD_BY_PERSON_ID -> selectedSpaceListItem?.id?.let { + binding.progressLayout.visibility = View.VISIBLE + spacesViewModel.createMembershipWithId(it, person.personId) + } + AddPersonBottomSheetFragment.Companion.Options.ADD_BY_EMAIL_ID -> selectedSpaceListItem?.id?.let { + binding.progressLayout.visibility = View.VISIBLE + spacesViewModel.createMembershipWithEmailId(it, person.emails.first()) + } + } + } + activity?.supportFragmentManager?.let { addMembersOptionDialog.show(it, AddPersonBottomSheetFragment.TAG) } + } + + private fun showGetMeetingInfoDialog(spaceMeetingInfoModel: SpaceMeetingInfoModel) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.meeting_info) + val message = TextView(requireContext()) + message.setPadding(10, 10, 10, 10) + message.text = spaceMeetingInfoModel.toString() + + builder.setView(message) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } + + private fun showEditSpaceDialog(spaceId: String, title: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.edit_space) + val input = EditText(requireContext()) + input.text = SpannableStringBuilder(title) + input.requestFocus() + + builder.setView(input) + + builder.setPositiveButton(android.R.string.ok) { _, _ -> spacesViewModel.updateSpace(spaceId, input.text.toString()) } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + + private fun showAddSpaceDialog() { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.add_space) + + DialogCreateSpaceBinding.inflate(layoutInflater) + .apply { + spaceTeamIdText.visibility = View.GONE + spaceTeamIdLabel.visibility = View.GONE + + + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { _, _ -> + spacesViewModel.addSpace(spaceTitleEditText.text.toString(), null) + } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + } + + private fun showDeleteSpaceConfirmationDialog(spaceId: String, spaceTitle: String) { + showDialogWithMessage(requireContext(), getString(R.string.delete_space), String.format(getString(R.string.delete_space_message, spaceTitle)), + onPositiveButtonClick = { dialog, _ -> + dialog.dismiss() + binding.progressLayout.visibility = View.VISIBLE + spacesViewModel.delete(spaceId) + }, + onNegativeButtonClick = { dialog, _ -> + dialog.dismiss() + }) + } + + private fun showMembersInSpace(spaceId: String) { + startActivity(MembershipActivity.getIntent(requireContext(), spaceId)) + } + + private fun showSpaceMembersWithReadStatus(spaceId: String) { + startActivity(MembershipReadStatusActivity.getIntent(requireContext(), spaceId)) + } + + private fun markSpaceRead(spaceId: String) { + binding.progressLayout.visibility = View.VISIBLE + spacesViewModel.markSpaceRead(spaceId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesRepository.kt new file mode 100644 index 0000000..dbc5b2b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesRepository.kt @@ -0,0 +1,139 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingRepository +import com.ciscowebex.androidsdk.space.Space.SpaceType +import com.ciscowebex.androidsdk.space.SpaceClient.SortBy +import io.reactivex.Observable +import io.reactivex.Single + +class SpacesRepository(private val webex: Webex) : MessagingRepository(webex) { + fun fetchSpacesList(teamId: String?, maxSpaces: Int): Observable> { + return Single.create> { emitter -> + webex.spaces.list(teamId, maxSpaces, SpaceType.NONE, SortBy.ID, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { + SpaceModel.convertToSpaceModel(it) + } ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun fetchSpaceById(spaceId: String): Observable { + return Single.create { emitter -> + webex.spaces.get(spaceId, CompletionHandler { result -> + if (result.isSuccessful) { + val space = result.data + emitter.onSuccess(SpaceModel.convertToSpaceModel(space)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun updateSpace(spaceId: String, spaceName: String): Observable { + return Single.create { emitter -> + webex.spaces.update(spaceId, spaceName, CompletionHandler { result -> + if (result.isSuccessful) { + val space = result.data + emitter.onSuccess(SpaceModel.convertToSpaceModel(space)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun fetchSpaceReadStatusList(maxSpaces: Int): Observable> { + return Single.create> { emitter -> + webex.spaces.listWithReadStatus(maxSpaces, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { + SpaceReadStatusModel.convertToSpaceReadStatusModel(it) + } ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getMeetingInfo(spaceId: String): Observable { + return Single.create { emitter -> + webex.spaces.getMeetingInfo(spaceId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(SpaceMeetingInfoModel.convertToSpaceMeetingInfoModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getSpaceReadStatusById(spaceId: String): Observable { + return Single.create { emitter -> + webex.spaces.getWithReadStatus(spaceId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(SpaceReadStatusModel.convertToSpaceReadStatusModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun listMessages(spaceId: String): Observable> { + return Single.create> { emitter -> + webex.messages.list(spaceId, null, 50, null, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { SpaceMessageModel.convertToSpaceMessageModel(it) }.orEmpty()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun delete(spaceId: String): Observable { + return Single.create { emitter -> + webex.spaces.delete(spaceId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun listSpacesWithActiveCalls(): Observable> { + return Single.create> { emitter -> + webex.spaces.listWithActiveCalls(CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data.orEmpty()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + // Commenting out but keeping this code for now in order to test encoding/decoding related code changes. + // We can delete once encoding/decoding code is put in proper format and returns proper error state(IN FORM OF ENUM) from Omnius layer + /*fun encodeDecodeTest() { + webex.base64Encode(ResourceType.Memberships, "Rohit Sharma", CompletionHandler { result -> + if(result.isSuccessful){ + Log.d("Enc/Dec Test", "Encoded String : ${result.data}") + val decodedString = webex.base64Decode(result.data?: "Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi9lZGI2OWJlOS1hMDNiLTQ4YzUtYWFmYi1lMmE2MjE0N2Q0NmM") + Log.d("Enc/Dec Test", "Decoded String : $decodedString") + }else { + Log.d("Enc/Dec Test", "Error in encoding : ${result.error?.errorMessage}") + } + }) + }*/ +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesViewModel.kt new file mode 100644 index 0000000..8966d16 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesViewModel.kt @@ -0,0 +1,130 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsRepository +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxkotlin.subscribeBy + +class SpacesViewModel(private val spacesRepo: SpacesRepository, + private val membershipRepo: MembershipRepository, + private val messagingRepo: TeamsRepository, private val webexRepository: WebexRepository) : BaseViewModel() { + private val _spaces = MutableLiveData>() + val spaces: LiveData> = _spaces + + private val _readStatusList = MutableLiveData>() + val readStatusList: LiveData> = _readStatusList + + private val _addSpace = MutableLiveData() + val addSpace: LiveData = _addSpace + + private val _spaceMeetingInfo = MutableLiveData() + val spaceMeetingInfo: LiveData = _spaceMeetingInfo + + private val _spaceError = MutableLiveData() + val spaceError: LiveData = _spaceError + + private val _createMemberData = MutableLiveData() + val createMemberData: LiveData = _createMemberData + + private val _markSpaceRead = MutableLiveData() + val markSpaceRead: LiveData = _markSpaceRead + + private val _deleteSpace = MutableLiveData() + val deleteSpace: LiveData = _deleteSpace + + private val _spaceEventLiveData = MutableLiveData>() + + private val addOnCallSuffix = " (On Call)" + + init { + webexRepository._spaceEventLiveData = _spaceEventLiveData + } + + override fun onCleared() { + webexRepository.clearSpaceData() + } + + private fun getSpacesWithActiveCalls() { + val allSpaces = arrayListOf() + spacesRepo.listSpacesWithActiveCalls().observeOn(AndroidSchedulers.mainThread()).subscribe({ spaceIds -> + spaces.value?.forEach { space -> + if(spaceIds.contains(space.id)) { + val tempSpace = SpaceModel(space.id, space.title + addOnCallSuffix, space.spaceType, space.isLocked, space.lastActivity, space.created, space.teamId, space.sipAddress) + allSpaces.add(tempSpace) + } else { + allSpaces.add(space) + } + } + _spaces.postValue(allSpaces) + }) { _spaces.postValue(spaces.value)}.autoDispose() + } + + fun getSpaceEvent() = webexRepository._spaceEventLiveData + + fun getSpacesList(maxSpaces: Int) { + spacesRepo.fetchSpacesList(null, maxSpaces).observeOn(AndroidSchedulers.mainThread()).subscribe({ spacesList -> + _spaces.postValue(spacesList) + getSpacesWithActiveCalls() + }, { _spaces.postValue(emptyList()) }).autoDispose() + } + + fun addSpace(title: String, teamId: String?) { + spacesRepo.addSpace(title, teamId).observeOn(AndroidSchedulers.mainThread()).subscribe({ createdSpace -> + _addSpace.postValue(createdSpace) + }, { _addSpace.postValue(null) }).autoDispose() + + } + + fun getSpaceReadStatusList(maxSpaces: Int) { + spacesRepo.fetchSpaceReadStatusList(maxSpaces).observeOn(AndroidSchedulers.mainThread()).subscribe({ listReadStatus -> + _readStatusList.postValue(listReadStatus) + }, { _readStatusList.postValue(null) }).autoDispose() + } + + fun updateSpace(spaceId: String, spaceName: String) { + spacesRepo.updateSpace(spaceId, spaceName).observeOn(AndroidSchedulers.mainThread()).subscribe({ + Log.d(SpacesViewModel::class.java.simpleName, "Space title is updated") + }, { error -> _spaceError.postValue(error.message) }).autoDispose() + } + + fun delete(spaceId: String) { + spacesRepo.delete(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _deleteSpace.postValue(spaceId) + }, {error -> _spaceError.postValue(error.message) }).autoDispose() + } + + fun getMeetingInfo(spaceId: String) { + spacesRepo.getMeetingInfo(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ meetingInfo -> + _spaceMeetingInfo.postValue(meetingInfo) + }, { error -> _spaceError.postValue(error.message) }).autoDispose() + } + + fun createMembershipWithId(spaceId: String, personId: String, isModerator: Boolean = false) { + membershipRepo.createMembershipWithId(spaceId, personId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _createMemberData.postValue(membership) + }, { error -> _spaceError.postValue(error.message) }).autoDispose() + } + + fun createMembershipWithEmailId(spaceId: String, emailId: String, isModerator: Boolean = false) { + membershipRepo.createMembershipWithEmail(spaceId, emailId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _createMemberData.postValue(membership) + }, { error -> _spaceError.postValue(error.message) }).autoDispose() + } + + fun markSpaceRead(spaceId: String) { + messagingRepo.markMessageAsRead(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ success -> + _markSpaceRead.postValue(success) + }, { error -> _spaceError.postValue(error.message) }).autoDispose() + } + + private fun refreshSpaces() { + getSpacesList(0) + } +} + diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpaceReadStatusClientAdapter.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpaceReadStatusClientAdapter.kt new file mode 100644 index 0000000..8ebacd7 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpaceReadStatusClientAdapter.kt @@ -0,0 +1,37 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.* +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemSpacesReadClientBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceReadStatusModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.readStatusDetails.SpaceReadStatusDetailActivity + +class SpaceReadStatusClientAdapter : RecyclerView.Adapter() { + var spaceReadStatusList: List = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SpacesReadClientViewHolder { + return SpacesReadClientViewHolder(ListItemSpacesReadClientBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun getItemCount(): Int = spaceReadStatusList.size + + override fun onBindViewHolder(holder: SpacesReadClientViewHolder, position: Int) { + holder.bind(spaceReadStatusList[position]) + } + +} + +class SpacesReadClientViewHolder(private val binding: ListItemSpacesReadClientBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(spaceReadStatus: SpaceReadStatusModel) { + binding.spaceReadStatus = spaceReadStatus + + binding.spaceReadStatusClientLayout.setOnClickListener {view -> + ContextCompat.startActivity(view.context ,SpaceReadStatusDetailActivity.getIntent(view.context, spaceReadStatus.spaceId), null) + } + + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpacesClientAdapter.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpacesClientAdapter.kt new file mode 100644 index 0000000..69171b3 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpacesClientAdapter.kt @@ -0,0 +1,77 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemSpacesClientBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceActionBottomSheetFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail.SpaceDetailActivity + + +class SpacesClientAdapter(private val optionsDialogFragment: SpaceActionBottomSheetFragment, val supportFragmentManager: FragmentManager, + val onAddToSpaceButtonClicked: (SpaceModel) -> Unit) : RecyclerView.Adapter() { + var spaces: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SpacesClientViewHolder { + return SpacesClientViewHolder(ListItemSpacesClientBinding.inflate(LayoutInflater.from(parent.context), parent, false), + optionsDialogFragment, supportFragmentManager) { position -> + onAddToSpaceButtonClicked(spaces[position]) + } + } + + override fun getItemCount(): Int = spaces.size + + override fun onBindViewHolder(holder: SpacesClientViewHolder, position: Int) { + holder.bind(spaces[position]) + } + + fun getPositionById(spaceId: String): Int { + return spaces.indexOfFirst { it.id == spaceId } + } + +} + +class SpacesClientViewHolder(private val binding: ListItemSpacesClientBinding, + private val optionsDialogFragment: SpaceActionBottomSheetFragment, + private val supportFragmentManager: FragmentManager, + private val onAddToSpaceButtonClicked: (Int) -> Unit) : RecyclerView.ViewHolder(binding.root) { + init { + binding.ivAddToSpace.setOnClickListener { + onAddToSpaceButtonClicked(adapterPosition) + } + } + + fun bind(space: SpaceModel) { + binding.space = space + binding.spaceTitleLabel.setOnClickListener { view -> + startSpaceDetailActivity(view, space) + } + binding.spaceTitleTextView.setOnClickListener { view -> + startSpaceDetailActivity(view, space) + } + binding.spaceTitleLabel.setOnLongClickListener { view -> + showSpaceOptions(space, view) + } + binding.spaceTitleTextView.setOnLongClickListener { view -> + showSpaceOptions(space, view) + } + binding.executePendingBindings() + } + + private fun showSpaceOptions(space: SpaceModel, view: View): Boolean { + optionsDialogFragment.spaceId = space.id + optionsDialogFragment.spaceTitle = space.title + + optionsDialogFragment.show(supportFragmentManager, "Space Options") + + return true + } + + private fun startSpaceDetailActivity(view: View, space: SpaceModel) { + ContextCompat.startActivity(view.context, SpaceDetailActivity.getIntent(view.context, space.id), null) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/FileViewerActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/FileViewerActivity.kt new file mode 100644 index 0000000..a2b3c73 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/FileViewerActivity.kt @@ -0,0 +1,146 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.View +import android.webkit.MimeTypeMap +import android.widget.Toast +import androidx.core.content.FileProvider +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.BuildConfig +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityFileViewerBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.RemoteModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.FileUtils.getFile +import com.ciscowebex.androidsdk.kitchensink.utils.FileUtils.getThumbnailFile +import kotlinx.android.synthetic.main.activity_file_viewer.* +import org.koin.android.ext.android.inject +import java.io.File +import java.util.Locale + + +class FileViewerActivity : BaseActivity() { + + private var remoteModel: RemoteModel? = null + private lateinit var binding: ActivityFileViewerBinding + private val messageViewModel: MessageViewModel by inject() + + companion object { + fun getIntent(context: Context, remoteFile: RemoteModel): Intent { + val intent = Intent(context, FileViewerActivity::class.java) + intent.putExtra(Constants.Bundle.REMOTE_FILE, remoteFile) + return intent + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "FileViewerActivity" + + remoteModel = intent.getParcelableExtra(Constants.Bundle.REMOTE_FILE) + + DataBindingUtil.setContentView(this, R.layout.activity_file_viewer).also { + binding = it + remoteModel?.let { _remoteModel -> + val text = "${_remoteModel.getRemoteFile().getSize()} " + resources.getString(R.string.total_bytes) + totalBytesLabel.text = text + messageViewModel.downloadThumbnail(_remoteModel.getRemoteFile(), getThumbnailFile(applicationContext)) + setUpObservers() + } + }.apply { + btnDownload.setOnClickListener { + hideThumbnailView() + progressBar.visibility = View.VISIBLE + remoteModel?.let { _remoteModel -> + messageViewModel.downloadFile(_remoteModel.getRemoteFile(), getFile(applicationContext)) + } + } + } + } + + private fun setUpObservers() { + messageViewModel.error.observe(this, Observer { error -> + Toast.makeText(this, "Unable to get thumbnail, error: $error", Toast.LENGTH_LONG).show() + }) + + messageViewModel.thumbnailUri.observe(this, Observer { uri -> + uri?.let { + Log.d(tag, "thumbnail uri: $it") + progressBar.visibility = View.GONE + imgThumbnail.setImageURI(it) + } + }) + + messageViewModel.downloadFileCompletionLiveData.observe(this, Observer { + it?.let { _pair -> + downloadComplete(_pair) + } + }) + + messageViewModel.downloadFileProgressLiveData.observe(this, Observer { + it?.let { bytes -> + val text = "$bytes " + resources.getString(R.string.bytes_downloaded) + progressLabel.text = text + } + }) + } + + private fun downloadComplete(_pair: Pair) { + runOnUiThread { + progressBar.visibility = View.GONE + when (_pair.first) { + MessagingRepository.FileDownloadEvent.DOWNLOAD_COMPLETE -> { + Log.d(tag, "file downloaded at ${_pair.second}") + showDownloadedFile(_pair.second) + } + MessagingRepository.FileDownloadEvent.DOWNLOAD_FAILED -> { + val errorMsg = "file download failed :${_pair.second}" + Log.d(tag, errorMsg) + Toast.makeText(applicationContext, errorMsg, Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun showDownloadedFile(fileUrl: String?) { + fileUrl?.let { + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(fileUrl).extension.toLowerCase(Locale.US)) + Log.d(tag, "mimetype: $mimeType") + if (mimeType != null) { + finish() + displayPdf(fileUrl, mimeType) + } + } + } + + private fun hideThumbnailView() { + imgThumbnail.visibility = View.GONE + btnDownload.visibility = View.GONE + } + + private fun getFileUri(context: Context, fileName: String): Uri? { + val file = File(fileName) + return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file) + } + + private fun displayPdf(fileName: String, mimeType: String) { + val uri = getFileUri(this, fileName) + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(uri, mimeType) + + // FLAG_GRANT_READ_URI_PERMISSION is needed on API 24+ so the activity opening the file can read it + intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_GRANT_READ_URI_PERMISSION + if (intent.resolveActivity(packageManager) == null) { + // Show an error + } else { + startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageActionBottomSheetFragment.kt new file mode 100644 index 0000000..14d56ae --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageActionBottomSheetFragment.kt @@ -0,0 +1,62 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetMessageOptionsBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.message.Message +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class MessageActionBottomSheetFragment(val deleteMessageClickListener: (SpaceMessageModel) -> Unit, + val markMessageAsReadClickListener: (SpaceMessageModel) -> Unit, + val replyMessageClickListener: (SpaceMessageModel) -> Unit, + val editMessageClickListener: (SpaceMessageModel) -> Unit) : BottomSheetDialogFragment() { + companion object { + val TAG = "MessageActionBottomSheetFragment" + var selfPersonId : String? = null + } + + private lateinit var binding: BottomSheetMessageOptionsBinding + lateinit var message: SpaceMessageModel + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetMessageOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + if(message.personId == selfPersonId) { + // Show delete message option for self messages + deleteMessage.visibility = View.VISIBLE + deleteMessage.setOnClickListener { + dismiss() + deleteMessageClickListener(message) + } + // Hide Mark Message Read option for self messages, as they would be in read status be default + markMessageAsRead.visibility = View.GONE + // Edit message allowed for self messages only + editMessage.visibility = View.VISIBLE + editMessage.setOnClickListener { + dismiss() + editMessageClickListener(message) + } + }else { + editMessage.visibility = View.GONE + deleteMessage.visibility = View.GONE + replyMessageSeparator.visibility = View.GONE + markMessageAsRead.visibility = View.VISIBLE + } + + markMessageAsRead.setOnClickListener { + dismiss() + markMessageAsReadClickListener(message) + } + + replyMessage.setOnClickListener { + dismiss() + replyMessageClickListener(message) + } + + cancel.setOnClickListener { dismiss() } + }.root + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageDetailsDialogFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageDetailsDialogFragment.kt new file mode 100644 index 0000000..3f47bc3 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageDetailsDialogFragment.kt @@ -0,0 +1,131 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.os.Bundle +import android.text.Html +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogMessageDetailsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemAttachmentsBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.BaseDialogFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.RemoteModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.message.RemoteFile +import kotlinx.android.synthetic.main.dialog_message_details.* +import org.koin.android.ext.android.inject + +class MessageDetailsDialogFragment : BaseDialogFragment() { + + companion object { + fun newInstance(messageId: String): MessageDetailsDialogFragment { + val args = Bundle() + args.putString(Constants.Bundle.MESSAGE_ID, messageId) + + val fragment = MessageDetailsDialogFragment() + fragment.arguments = args + + return fragment + } + } + + private val messageViewModel: MessageViewModel by inject() + private lateinit var messageId: String + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + messageId = arguments?.getString(Constants.Bundle.MESSAGE_ID) ?: "" + + return DialogMessageDetailsBinding.inflate(inflater, container, false) + .apply { + progressLayout.visibility = View.VISIBLE + + messageViewModel.message.observe(viewLifecycleOwner, Observer { _msg -> + _msg?.let { + progressLayout.visibility = View.GONE + message = it + setMessageBody(it.messageBody) + setUpAttachments(it.attachments) + } + }) + + close.setOnClickListener { dialog?.dismiss() } + }.root + } + + private fun setMessageBody(msg: Message.Text) { + var text = "" + when { + msg.getMarkdown() != null -> { + text = msg.getMarkdown()!! + } + msg.getPlain() != null -> { + text = msg.getPlain()!! + } + msg.getHtml() != null -> { + text = msg.getHtml()!! + } + } + messageBodyTextView.text = Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY) + } + + private fun setUpAttachments(attachments: List) { + attachmentTextView.text = getString(R.string.attachments_label, attachments.size) + + val dividerItemDecoration = DividerItemDecoration(requireContext(), + LinearLayoutManager.VERTICAL) + attachmentList.addItemDecoration(dividerItemDecoration) + val onAttachmentClick: (RemoteFile) -> Unit = { remoteFile -> + val remoteModel = RemoteModel(remoteFile.getDisplayName().orEmpty(), + remoteFile.getMimeType(), + remoteFile.getSize(), + remoteFile.getUrl(), + remoteFile.getConversationId(), + remoteFile.getMessageId(), + remoteFile.getContentIndex(), + remoteFile.getThumbnail()?.getWidth(), + remoteFile.getThumbnail()?.getHeight(), + remoteFile.getThumbnail()?.getMimeType(), + remoteFile.getThumbnail()?.getUrl()) + activity?.startActivity(FileViewerActivity.getIntent(requireContext(), remoteModel)) + } + attachmentList.adapter = MessageAttachmentsAdapter(attachments, onAttachmentClick) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + messageViewModel.getMessageDetail(messageId) + } + + class MessageAttachmentsAdapter(private val attachments: List, private val onAttachmentClick: (RemoteFile) -> Unit) : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding = ListItemAttachmentsBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return AttachmentViewHolder(binding, onAttachmentClick) + } + + override fun getItemCount(): Int { + return attachments.size + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as AttachmentViewHolder).bind(attachments[position]) + } + + inner class AttachmentViewHolder(private val binding: ListItemAttachmentsBinding, private val onAttachmentClick: (RemoteFile) -> Unit) : RecyclerView.ViewHolder(binding.root) { + init { + binding.root.setOnClickListener { + onAttachmentClick(attachments[adapterPosition]) + } + } + + fun bind(remoteFile: RemoteFile) { + binding.remoteFile = remoteFile + binding.executePendingBindings() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageViewModel.kt new file mode 100644 index 0000000..e5f1807 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageViewModel.kt @@ -0,0 +1,72 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import io.reactivex.Emitter +import io.reactivex.Observable +import io.reactivex.ObservableOnSubscribe +import io.reactivex.android.schedulers.AndroidSchedulers +import com.ciscowebex.androidsdk.message.RemoteFile +import java.io.File + +class MessageViewModel(private val spaceRepo: SpacesRepository) : BaseViewModel() { + private val tag = "MessageViewModel" + + private val _message = MutableLiveData() + val message: LiveData = _message + + private val _error = MutableLiveData() + val error: LiveData = _error + + private val _uri = MutableLiveData() + val thumbnailUri: LiveData = _uri + + private val _downloadFileCompletionLiveData = MutableLiveData>() + val downloadFileCompletionLiveData: LiveData> = _downloadFileCompletionLiveData + + private val _downloadFileProgressLiveData = MutableLiveData() + val downloadFileProgressLiveData: LiveData = _downloadFileProgressLiveData + + fun getMessageDetail(messageId: String) { + spaceRepo.getMessage(messageId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _message.postValue(it) + }, { error -> _error.postValue(error.message) }).autoDispose() + } + + fun downloadThumbnail(remoteFile: RemoteFile, file: File) { + spaceRepo.downloadThumbnail(remoteFile, file).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _uri.postValue(it) + }, { error -> _error.postValue(error.message) }).autoDispose() + } + + fun downloadFile(remoteFile: RemoteFile, file: File) { + lateinit var progressEmitter: Emitter + val progressObserver = Observable.create(ObservableOnSubscribe { emitter -> + progressEmitter = emitter + }) + + lateinit var completionEmitter: Emitter> + val completionObserver = Observable.create(ObservableOnSubscribe> { emitter -> + completionEmitter = emitter + }) + + progressObserver.observeOn(AndroidSchedulers.mainThread()).subscribe { + it?.let { + _downloadFileProgressLiveData.postValue(it) + } + }.autoDispose() + + completionObserver.observeOn(AndroidSchedulers.mainThread()).subscribe { + it?.let { + _downloadFileCompletionLiveData.postValue(it) + } + }.autoDispose() + + spaceRepo.downloadFile(remoteFile, file, progressEmitter, completionEmitter) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailActivity.kt new file mode 100644 index 0000000..76e5bc8 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailActivity.kt @@ -0,0 +1,282 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.Html +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivitySpaceDetailBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemSpaceMessageBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.ReplyMessageModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.message.RemoteFile +import org.koin.android.ext.android.inject + +class SpaceDetailActivity : BaseActivity() { + + companion object { + fun getIntent(context: Context, spaceId: String): Intent { + val intent = Intent(context, SpaceDetailActivity::class.java) + intent.putExtra(Constants.Intent.SPACE_ID, spaceId) + return intent + } + } + + lateinit var messageClientAdapter: MessageClientAdapter + lateinit var binding: ActivitySpaceDetailBinding + + private val spaceDetailViewModel: SpaceDetailViewModel by inject() + private lateinit var spaceId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "SpaceDetailActivity" + + spaceId = intent.getStringExtra(Constants.Intent.SPACE_ID) ?: "" + spaceDetailViewModel.spaceId = spaceId + DataBindingUtil.setContentView(this, R.layout.activity_space_detail) + .also { binding = it } + .apply { + val messageActionBottomSheetFragment = MessageActionBottomSheetFragment({ message -> spaceDetailViewModel.deleteMessage(message) }, + { message -> spaceDetailViewModel.markMessageAsRead(message) }, + { message -> replyMessageListener(message) }, + { message -> editMessage(message)}) + + messageClientAdapter = MessageClientAdapter(messageActionBottomSheetFragment, supportFragmentManager) + spaceMessageRecyclerView.adapter = messageClientAdapter + + setUpObservers() + + swipeContainer.setOnRefreshListener { + spaceDetailViewModel.getMessages() + } + postMessageFAB.setOnClickListener { + ContextCompat.startActivity(this@SpaceDetailActivity, + MessageComposerActivity.getIntent(this@SpaceDetailActivity, MessageComposerActivity.Companion.ComposerType.POST_SPACE, spaceDetailViewModel.spaceId, null), null) + } + } + } + + private fun replyMessageListener(message: SpaceMessageModel) { + val model = ReplyMessageModel( + message.spaceId, + message.messageId, + message.created, + message.isSelfMentioned, + message.parentId, + message.isReply, + message.personId, + message.personEmail, + message.toPersonId, + message.toPersonEmail) + ContextCompat.startActivity(this@SpaceDetailActivity, + MessageComposerActivity.getIntent(this@SpaceDetailActivity, MessageComposerActivity.Companion.ComposerType.POST_SPACE, spaceDetailViewModel.spaceId, model), null) + } + + private fun editMessage(message: SpaceMessageModel) { + startActivity(MessageComposerActivity.getIntent(this@SpaceDetailActivity, MessageComposerActivity.Companion.ComposerType.POST_SPACE, + spaceDetailViewModel.spaceId, null, message.messageId)) + } + + override fun onResume() { + super.onResume() + spaceDetailViewModel.getSpaceById() + getMessages() + } + + private fun getMessages() { + binding.noMessagesLabel.visibility = View.GONE + binding.progressLayout.visibility = View.VISIBLE + spaceDetailViewModel.getMessages() + } + + private fun setUpObservers() { + spaceDetailViewModel.space.observe(this@SpaceDetailActivity, Observer { + binding.space = it + }) + + spaceDetailViewModel.spaceMessages.observe(this@SpaceDetailActivity, Observer { list -> + list?.let { + binding.progressLayout.visibility = View.GONE + binding.swipeContainer.isRefreshing = false + + if (it.isEmpty()) { + binding.noMessagesLabel.visibility = View.VISIBLE + } else { + binding.noMessagesLabel.visibility = View.GONE + } + + messageClientAdapter.messages.clear() + messageClientAdapter.messages.addAll(it) + messageClientAdapter.notifyDataSetChanged() + } + }) + + spaceDetailViewModel.deleteMessage.observe(this@SpaceDetailActivity, Observer { model -> + model?.let { + val position = messageClientAdapter.messages.indexOf(it) + messageClientAdapter.messages.removeAt(position) + messageClientAdapter.notifyItemRemoved(position) + } + }) + + spaceDetailViewModel.messageError.observe(this@SpaceDetailActivity, Observer { errorMessage -> + errorMessage?.let { + showErrorDialog(it) + } + }) + + spaceDetailViewModel.markMessageAsReadStatus.observe(this@SpaceDetailActivity, Observer { model -> + model?.let { + showDialogWithMessage(this@SpaceDetailActivity, R.string.success, "Message with id ${it.messageId} marked as read") + } + }) + + spaceDetailViewModel.getMeData.observe(this@SpaceDetailActivity, Observer { model -> + model?.let { + MessageActionBottomSheetFragment.selfPersonId = it.personId + } + }) + + spaceDetailViewModel.messageEventLiveData.observe(this@SpaceDetailActivity, Observer { pair -> + if(pair != null) { + when (pair.first) { + WebexRepository.MessageEvent.Received -> { + Log.d(tag, "Message Received event fired!") + if(pair.second is Message) { + val message = pair.second as Message + // For replies, find parent and add to replies list at bottom. + if(message.isReply()){ + val parentMessagePosition = messageClientAdapter.getPositionById(message.getParentId()?: "") + // Ignore case when parent is not found, as parent might not be present in the list + if(parentMessagePosition != -1) { + if(parentMessagePosition == messageClientAdapter.messages.size - 1 ){ + messageClientAdapter.messages.add(SpaceMessageModel.convertToSpaceMessageModel(message)) + messageClientAdapter.notifyItemInserted(messageClientAdapter.messages.size - 1) + }else { + var positionToInsert = parentMessagePosition + 1 + for(i in (parentMessagePosition + 1) until messageClientAdapter.messages.size - 1) { + if (!messageClientAdapter.messages[i].isReply){ + positionToInsert = i; + break; + } + } + messageClientAdapter.messages.add(positionToInsert, SpaceMessageModel.convertToSpaceMessageModel(message)) + messageClientAdapter.notifyItemInserted(positionToInsert) + } + } + }else { + messageClientAdapter.messages.add(SpaceMessageModel.convertToSpaceMessageModel(message)) + messageClientAdapter.notifyItemInserted(messageClientAdapter.messages.size - 1) + } + } + } + WebexRepository.MessageEvent.Deleted -> { + if (pair.second is String?) { + Log.d(tag, "Message Deleted event fired!") + val position = messageClientAdapter.getPositionById(pair.second as String? ?: "") + if (!messageClientAdapter.messages.isNullOrEmpty() && position != -1) { + messageClientAdapter.messages.removeAt(position) + messageClientAdapter.notifyItemRemoved(position) + } + } + } + WebexRepository.MessageEvent.MessageThumbnailUpdated -> { + Log.d(tag, "Message ThumbnailUpdated event fired!") + val fileList: List? = pair.second as? List + if(!fileList.isNullOrEmpty()){ + for( thumbnail in fileList){ + Log.d(tag, "Message Updated thumbnail : ${thumbnail.getDisplayName()}") + } + } + + } + WebexRepository.MessageEvent.Edited -> { + if (pair.second is Message) { + val message = pair.second as Message + val position = messageClientAdapter.getPositionById(message.getId() ?: "") + if (!messageClientAdapter.messages.isNullOrEmpty() && position != -1) { + messageClientAdapter.messages[position] = SpaceMessageModel.convertToSpaceMessageModel(message) + messageClientAdapter.notifyItemChanged(position) + } + } + } + } + } + }) + } + +} + + +class MessageClientAdapter(private val messageActionBottomSheetFragment: MessageActionBottomSheetFragment, private val fragmentManager: FragmentManager) : RecyclerView.Adapter() { + var messages: MutableList = mutableListOf() + + fun getPositionById(messageId: String): Int { + return messages.indexOfFirst { it.messageId == messageId } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageClientViewHolder { + return MessageClientViewHolder(ListItemSpaceMessageBinding.inflate(LayoutInflater.from(parent.context), parent, false), + messageActionBottomSheetFragment, fragmentManager) + } + + override fun getItemCount(): Int = messages.size + + override fun onBindViewHolder(holder: MessageClientViewHolder, position: Int) { + holder.bind(messages[position]) + } + +} + +class MessageClientViewHolder(private val binding: ListItemSpaceMessageBinding, private val messageActionBottomSheetFragment: MessageActionBottomSheetFragment, private val fragmentManager: FragmentManager) : RecyclerView.ViewHolder(binding.root) { + var messageItem: SpaceMessageModel? = null + val tag = "MessageClientViewHolder" + + init { + binding.membershipContainer.setOnClickListener { + messageItem?.let { message -> + MessageDetailsDialogFragment.newInstance(message.messageId).show(fragmentManager, "MessageDetailsDialogFragment") + } + } + } + + fun bind(message: SpaceMessageModel) { + binding.message = message + messageItem = message + binding.membershipContainer.setOnLongClickListener { view -> + messageActionBottomSheetFragment.message = message + messageActionBottomSheetFragment.show(fragmentManager, MessageActionBottomSheetFragment.TAG) + true + } + + when { + message.messageBody.getMarkdown() != null -> { + binding.messageTextView.text = Html.fromHtml(message.messageBody.getMarkdown(), Html.FROM_HTML_MODE_LEGACY) + } + message.messageBody.getPlain() != null -> { + binding.messageTextView.text = message.messageBody.getPlain() + } + else -> { + binding.messageTextView.text = "" + } + } + + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailViewModel.kt new file mode 100644 index 0000000..c155686 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailViewModel.kt @@ -0,0 +1,91 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.kitchensink.person.PersonRepository +import com.ciscowebex.androidsdk.message.Message +import io.reactivex.android.schedulers.AndroidSchedulers + +class SpaceDetailViewModel(private val spacesRepo: SpacesRepository, private val personRepo: PersonRepository, private val webexRepository: WebexRepository) : BaseViewModel() { + private val tag = "SpaceDetailViewModel" + lateinit var spaceId: String + private var person: PersonModel? = null + private val _deleteMessage = MutableLiveData() + val deleteMessage: LiveData = _deleteMessage + + private val _markMessageAsReadStatus = MutableLiveData() + val markMessageAsReadStatus: LiveData = _markMessageAsReadStatus + + private val _messageEventLiveData = MutableLiveData>() + val messageEventLiveData: LiveData> = _messageEventLiveData + + private val _getMeData = MutableLiveData() + val getMeData: LiveData = _getMeData + + init { + getMe() + webexRepository._messageEventLiveData = _messageEventLiveData + } + + override fun onCleared() { + super.onCleared() + webexRepository._messageEventLiveData = null + } + + private fun getMe() { + personRepo.getMe().observeOn(AndroidSchedulers.mainThread()).subscribe { + person = it + _getMeData.postValue(person) +// getMessages() + }.autoDispose() + } + + + private val _space = MutableLiveData() + val space: LiveData = _space + + private val _messageError = MutableLiveData() + val messageError: LiveData = _messageError + + private val _spaceMessages = MutableLiveData>() + val spaceMessages: LiveData> = _spaceMessages + + fun isSelfMessage(personId: String): Boolean { + return personId == person?.personId ?: false + } + + fun getPersonId(): String? { + return person?.personId + } + + fun getSpaceById() { + spacesRepo.fetchSpaceById(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ spaceModel -> + _space.postValue((spaceModel)) + }, { _space.postValue(null) }).autoDispose() + } + + fun getMessages() { + spacesRepo.listMessages(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ messageModels -> + _spaceMessages.postValue(messageModels) + }, { _spaceMessages.postValue(emptyList()) }).autoDispose() + } + + fun deleteMessage(message: SpaceMessageModel) { + spacesRepo.deleteMessage(message.messageId).observeOn(AndroidSchedulers.mainThread()).subscribe({ success -> + _deleteMessage.postValue(message) + }, { error -> _messageError.postValue(error?.message ?: "") }).autoDispose() + } + + fun markMessageAsRead(message: SpaceMessageModel) { + spacesRepo.markMessageAsRead(spaceId, message.messageId).observeOn(AndroidSchedulers.mainThread()).subscribe({ success -> + _markMessageAsReadStatus.postValue(message) + }, { error -> _messageError.postValue(error?.message ?: "") }).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipActivity.kt new file mode 100644 index 0000000..1994efd --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipActivity.kt @@ -0,0 +1,42 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityMembershipBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants + +class MembershipActivity : BaseActivity() { + + companion object { + fun getIntent(context: Context, spaceId: String): Intent { + val intent = Intent(context, MembershipActivity::class.java) + intent.putExtra(Constants.Intent.SPACE_ID, spaceId) + return intent + } + } + + lateinit var binding: ActivityMembershipBinding + + private lateinit var spaceId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + spaceId = intent.getStringExtra(Constants.Intent.SPACE_ID) ?: "" + + + DataBindingUtil.setContentView(this, R.layout.activity_membership) + .apply { + val fragmentManager = supportFragmentManager + val fragmentTransaction = fragmentManager.beginTransaction() + + val fragment = MembershipFragment.newInstance(spaceId) + fragmentTransaction.add(R.id.membershipFragment, fragment) + fragmentTransaction.commit() + } + } +} + diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipFragment.kt new file mode 100644 index 0000000..fa9423d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipFragment.kt @@ -0,0 +1,210 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogMembershipDetailsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentMembershipBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemMembershipClientBinding +import com.ciscowebex.androidsdk.kitchensink.person.PersonDialogFragment +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import org.koin.android.ext.android.inject + +class MembershipFragment : Fragment() { + + lateinit var binding: FragmentMembershipBinding + + private val membershipViewModel: MembershipViewModel by inject() + private var spaceId: String? = null + + companion object { + fun newInstance(spaceId: String): MembershipFragment { + val args = Bundle() + args.putString(Constants.Bundle.SPACE_ID, spaceId) + + val fragment = MembershipFragment() + fragment.arguments = args + + return fragment + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + spaceId = arguments?.getString(Constants.Bundle.SPACE_ID) + + return FragmentMembershipBinding.inflate(inflater, container, false) + .also { binding = it } + .apply { + lifecycleOwner = this@MembershipFragment + val spaceMembershipActionBottomSheetFragment = SpaceMembershipActionBottomSheetFragment( + { membershipId -> membershipViewModel.getMembership(membershipId) }, + { membershipId -> membershipViewModel.updateMembershipWith(membershipId, true) }, + { membershipId -> membershipViewModel.updateMembershipWith(membershipId, false) }, + { personId -> showPersonDetails(personId) }, + { membershipId, position -> + showDialogWithMessage(requireContext(), getString(R.string.delete_membership), getString(R.string.confirm_delete_space_membership_action), + onPositiveButtonClick = { dialog, _ -> + dialog.dismiss() + membershipViewModel.deleteMembership(position, membershipId) + }, + onNegativeButtonClick = { dialog, _ -> + dialog.dismiss() + }) + }) + + val membershipClientAdapter = MembershipClientAdapter(spaceMembershipActionBottomSheetFragment, spaceId) + membershipsRecyclerView.adapter = membershipClientAdapter + membershipsRecyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + + membershipViewModel.memberships.observe(this@MembershipFragment.viewLifecycleOwner, Observer { model -> + model?.let { + binding.progressLayout.visibility = View.GONE + membershipClientAdapter.memberships.clear() + membershipClientAdapter.memberships.addAll(it) + membershipClientAdapter.notifyDataSetChanged() + } + }) + + membershipViewModel.membershipDetail.observe(this@MembershipFragment.viewLifecycleOwner, Observer { model -> + model?.let { + getMembers() + displayMembershipDetail(it) + } + }) + + membershipViewModel.membershipError.observe(this@MembershipFragment.viewLifecycleOwner, Observer { error -> + error?.let { + showErrorDialog(it) + } + }) + + membershipViewModel.membershipEventLiveData.observe(this@MembershipFragment.viewLifecycleOwner, Observer { + if(it.second?.spaceId == spaceId) { + when (it.first) { + WebexRepository.MembershipEvent.Created -> { + membershipClientAdapter.memberships.add(0, MembershipModel.convertToMembershipModel(it.second)) + membershipClientAdapter.notifyItemInserted(0) + } + WebexRepository.MembershipEvent.Updated -> { + + val position = membershipClientAdapter.getPositionById(it.second?.id.orEmpty()) + if (!membershipClientAdapter.memberships.isNullOrEmpty() && position != -1) { + membershipClientAdapter.memberships[position] = MembershipModel.convertToMembershipModel(it.second) + membershipClientAdapter.notifyItemChanged(position) + } + Log.d(tag, "MembershipEvent - Update -> MembershipID : ${it.second?.id} , PersonID : ${it.second?.personId} ") + + } + WebexRepository.MembershipEvent.Deleted -> { + val position = membershipClientAdapter.getPositionById(it.second?.id.orEmpty()) + if (!membershipClientAdapter.memberships.isNullOrEmpty() && position != -1) { + membershipClientAdapter.memberships.removeAt(position) + membershipClientAdapter.notifyItemRemoved(position) + } + Log.d(tag, "MembershipEvent - Delete -> MembershipID : ${it.second?.id} , PersonID : ${it.second?.personId} ") + } + } + } + }) + }.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + getMembers() + } + + private fun getMembers() { + binding.progressLayout.visibility = View.VISIBLE + val maxMemberships = resources.getInteger(R.integer.membership_list_size) + membershipViewModel.getMembersIn(spaceId, maxMemberships) + } + + private fun displayMembershipDetail(membershipModel: MembershipModel) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.members_details) + + DialogMembershipDetailsBinding.inflate(layoutInflater) + .apply { + membership = membershipModel + + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + } + + builder.show() + } + } + + private fun showErrorDialog(errorMessage: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.error_occurred) + val message = TextView(requireContext()) + message.setPadding(10, 10, 10, 10) + message.text = errorMessage + + builder.setView(message) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } + + private fun showPersonDetails(personId: String) { + PersonDialogFragment.newInstance(personId).show(childFragmentManager, getString(R.string.person_detail)) + } +} + +class MembershipClientAdapter(private val spaceMembershipActionBottomSheetFragment: SpaceMembershipActionBottomSheetFragment, private val spaceId: String?) : RecyclerView.Adapter() { + var memberships: MutableList = mutableListOf() + + fun getPositionById(membershipId: String): Int { + return memberships.indexOfFirst { it.membershipId == membershipId } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MembershipClientViewHolder { + return MembershipClientViewHolder(ListItemMembershipClientBinding.inflate(LayoutInflater.from(parent.context), parent, false), spaceMembershipActionBottomSheetFragment, spaceId) + } + + override fun getItemCount(): Int = memberships.size + + override fun onBindViewHolder(holder: MembershipClientViewHolder, position: Int) { + holder.bind(memberships[position]) + } + +} + +class MembershipClientViewHolder(private val binding: ListItemMembershipClientBinding, private val spaceMembershipActionBottomSheetFragment: SpaceMembershipActionBottomSheetFragment, private val spaceId: String?) : RecyclerView.ViewHolder(binding.root) { + fun bind(membership: MembershipModel) { + binding.membership = membership + + if (!spaceId.isNullOrEmpty()) { + binding.membershipContainer.setOnLongClickListener { view -> + spaceMembershipActionBottomSheetFragment.membershipId = membership.membershipId + spaceMembershipActionBottomSheetFragment.personId = membership.personId + spaceMembershipActionBottomSheetFragment.position = adapterPosition + + val activity = view.context as AppCompatActivity + activity.supportFragmentManager.let { spaceMembershipActionBottomSheetFragment.show(it, "Membership Options") } + + true + } + } + + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipModel.kt new file mode 100644 index 0000000..03cc465 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipModel.kt @@ -0,0 +1,45 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.membership.Membership +import java.util.* + +data class MembershipModel(val membershipId: String, val personId: String, val personEmail: String, + val personDisplayName: String, val spaceId: String, val isModerator: Boolean, + val isMonitor: Boolean, val created: Date, val personOrgId: String, val personFirstName: String, val personLastName: String) { + + val createdDateTimeString: String = created.toString() + val isModeratorString: String = isModerator.toString() + val isMonitorString: String = isMonitor.toString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SpaceModel + + return membershipId == other.id + } + + override fun hashCode(): Int { + var result = membershipId.hashCode() + result = 31 * result + personId.hashCode() + result = 31 * result + personEmail.hashCode() + result = 31 * result + personDisplayName.hashCode() + result = 31 * result + spaceId.hashCode() + result = 31 * result + isModerator.hashCode() + result = 31 * result + isMonitor.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + personOrgId.hashCode() + return result + } + + companion object { + fun convertToMembershipModel(membership: Membership?): MembershipModel { + return MembershipModel(membership?.id.orEmpty(), membership?.personId.orEmpty(), membership?.personEmail.orEmpty(), + membership?.personDisplayName.orEmpty(), membership?.spaceId.orEmpty(), membership?.isModerator ?: false, + membership?.isMonitor ?: false, membership?.created ?: Date(), membership?.personOrgId.orEmpty(), + membership?.personFirstName.orEmpty(), membership?.personLastName.orEmpty()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipRepository.kt new file mode 100644 index 0000000..bfa8fca --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipRepository.kt @@ -0,0 +1,98 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus.MembershipReadStatusModel +import com.ciscowebex.androidsdk.CompletionHandler +import io.reactivex.Observable +import io.reactivex.Single + +class MembershipRepository(private val webex: Webex) { + fun getMembersInSpace(spaceId: String?, max: Int?): Observable> { + return Single.create> { emitter -> + webex.memberships.list(spaceId, null, null, max, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { + MembershipModel.convertToMembershipModel(it) + } ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getMembership(membershipId: String): Observable { + return Single.create { emitter -> + webex.memberships.get(membershipId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(MembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun updateMembershipWith(membershipId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.memberships.update(membershipId, isModerator, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(MembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun delete(membershipId: String): Observable { + return Single.create { emitter -> + webex.memberships.delete(membershipId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun createMembershipWithId(spaceId: String, personId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.memberships.create(spaceId, personId, null, false, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(MembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun createMembershipWithEmail(spaceId: String, emailId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.memberships.create(spaceId, null, emailId, isModerator, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(MembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun listMembershipsWithReadStatus(spaceId: String): Observable> { + return Single.create> { emitter -> + webex.memberships.listWithReadStatus(spaceId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { + MembershipReadStatusModel.convertToMembershipReadStatusModel(it) + } ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipViewModel.kt new file mode 100644 index 0000000..52cb76d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipViewModel.kt @@ -0,0 +1,59 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.membership.Membership +import io.reactivex.android.schedulers.AndroidSchedulers + +class MembershipViewModel(private val membershipRepo: MembershipRepository, private val webexRepository: WebexRepository) : BaseViewModel() { + private val _memberships = MutableLiveData>() + val memberships: LiveData> = _memberships + + private val _membershipDetail = MutableLiveData() + val membershipDetail: LiveData = _membershipDetail + + private val _deleteMembership = MutableLiveData>() + val deleteMembership: LiveData> = _deleteMembership + + private val _membershipError = MutableLiveData() + val membershipError: LiveData = _membershipError + + private val _membershipEventLiveData = MutableLiveData>() + val membershipEventLiveData: LiveData> = _membershipEventLiveData + + init { + webexRepository._membershipEventLiveData = _membershipEventLiveData + } + + override fun onCleared() { + super.onCleared() + webexRepository._membershipEventLiveData = null + } + + fun getMembersIn(spaceId: String?, max: Int?) { + membershipRepo.getMembersInSpace(spaceId, max).observeOn(AndroidSchedulers.mainThread()).subscribe({ memberships -> + _memberships.postValue(memberships) + }, { _memberships.postValue(emptyList()) }).autoDispose() + } + + fun getMembership(membershipId: String) { + membershipRepo.getMembership(membershipId).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _membershipDetail.postValue(membership) + }, { error -> _membershipError.postValue(error.message) }).autoDispose() + } + + fun updateMembershipWith(membershipId: String, isModerator : Boolean) { + membershipRepo.updateMembershipWith(membershipId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _membershipDetail.postValue(membership) + }, { error -> _membershipError.postValue(error.message) }).autoDispose() + } + + fun deleteMembership(itemPosition: Int, membershipId: String) { + membershipRepo.delete(membershipId).observeOn(AndroidSchedulers.mainThread()).subscribe({ response -> + _deleteMembership.postValue(Pair(response, itemPosition)) + }, { error -> _membershipError.postValue(error.message) }).autoDispose() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/SpaceMembershipActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/SpaceMembershipActionBottomSheetFragment.kt new file mode 100644 index 0000000..fdb7410 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/SpaceMembershipActionBottomSheetFragment.kt @@ -0,0 +1,50 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetSpaceMemberOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class SpaceMembershipActionBottomSheetFragment(val membershipDetailsClickListener: (String) -> Unit, val membershipSetModeratorClickListener: (String) -> Unit, + val membershipRemoveModeratorClickListener: (String) -> Unit, val showPersonDetails: (String) -> Unit, val deleteMembership: (String, Int) -> Unit) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetSpaceMemberOptionsBinding + var membershipId : String = "" + var personId: String = "" + var position: Int = -1 + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetSpaceMemberOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + getMembershipDetails.setOnClickListener { + dismiss() + membershipDetailsClickListener(membershipId) + } + + setMembershipModerator.setOnClickListener { + dismiss() + membershipSetModeratorClickListener(membershipId) + } + + removeMembershipModerator.setOnClickListener { + dismiss() + membershipRemoveModeratorClickListener(membershipId) + } + + getPersonDetails.setOnClickListener { + dismiss() + showPersonDetails(personId) + } + + deleteMembership.setOnClickListener { + dismiss() + deleteMembership(membershipId, position) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusActivity.kt new file mode 100644 index 0000000..0b9275b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusActivity.kt @@ -0,0 +1,41 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityMembershipReadStatusBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants + +class MembershipReadStatusActivity : BaseActivity() { + + companion object { + fun getIntent(context: Context, spaceId: String): Intent { + val intent = Intent(context, MembershipReadStatusActivity::class.java) + intent.putExtra(Constants.Intent.SPACE_ID, spaceId) + return intent + } + } + + lateinit var binding: ActivityMembershipReadStatusBinding + + private lateinit var spaceId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + spaceId = intent.getStringExtra(Constants.Intent.SPACE_ID) ?: "" + + + DataBindingUtil.setContentView(this, R.layout.activity_membership_read_status) + .apply { + val fragmentManager = supportFragmentManager + val fragmentTransaction = fragmentManager.beginTransaction() + + val fragment = MembershipReadStatusFragment.newInstance(spaceId) + fragmentTransaction.add(R.id.fragment, fragment) + fragmentTransaction.commit() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusFragment.kt new file mode 100644 index 0000000..de842ca --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusFragment.kt @@ -0,0 +1,104 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentMembershipReadStatusBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemMembershipReadStatusBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import org.koin.android.ext.android.inject + +class MembershipReadStatusFragment : Fragment() { + + lateinit var binding: FragmentMembershipReadStatusBinding + + private val membershipReadStatusViewModel: MembershipReadStatusViewModel by inject() + private var spaceId: String? = null + + companion object { + fun newInstance(spaceId: String): MembershipReadStatusFragment { + val args = Bundle() + args.putString(Constants.Bundle.SPACE_ID, spaceId) + val fragment = MembershipReadStatusFragment() + fragment.arguments = args + return fragment + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + spaceId = arguments?.getString(Constants.Bundle.SPACE_ID) + + return FragmentMembershipReadStatusBinding.inflate(inflater, container, false) + .also { binding = it } + .apply { + + + val membershipsReadStatusAdapter = MembershipReadStatusAdapter() + recyclerView.adapter = membershipsReadStatusAdapter + recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + + membershipReadStatusViewModel.membershipsReadStatus.observe(this@MembershipReadStatusFragment.viewLifecycleOwner, Observer { + membershipsReadStatusAdapter.membershipsReadStatus.clear() + membershipsReadStatusAdapter.membershipsReadStatus.addAll(it) + membershipsReadStatusAdapter.notifyDataSetChanged() + binding.progressBar.visibility = View.GONE + }) + + membershipReadStatusViewModel.membershipReadStatusError.observe(this@MembershipReadStatusFragment.viewLifecycleOwner, Observer { + showDialogWithMessage(requireContext(), R.string.error_occurred, it) + binding.progressBar.visibility = View.GONE + }) + membershipReadStatusViewModel.membershipEventLiveData.observe(this@MembershipReadStatusFragment.viewLifecycleOwner, Observer { + if (it.second?.spaceId == spaceId) { + when (it.first) { + WebexRepository.MembershipEvent.MessageSeen -> { + getList() + } + + } + } + }) + }.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.progressBar.visibility = View.VISIBLE + getList() + } + + fun getList() { + membershipReadStatusViewModel.getMembershipsWithReadStatus(spaceId) + } + +} + +class MembershipReadStatusAdapter : RecyclerView.Adapter() { + var membershipsReadStatus: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MembershipReadStatusViewHolder { + return MembershipReadStatusViewHolder(ListItemMembershipReadStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun getItemCount(): Int = membershipsReadStatus.size + + override fun onBindViewHolder(holder: MembershipReadStatusViewHolder, position: Int) { + holder.bind(membershipsReadStatus[position]) + } + +} + +class MembershipReadStatusViewHolder(private val binding: ListItemMembershipReadStatusBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(membershipReadStatus: MembershipReadStatusModel) { + binding.membershipReadStatus = membershipReadStatus + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusModel.kt new file mode 100644 index 0000000..e598e40 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusModel.kt @@ -0,0 +1,37 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus + +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipModel +import com.ciscowebex.androidsdk.membership.MembershipReadStatus + +data class MembershipReadStatusModel(val member: MembershipModel, val lastSeenId: String, val lastSeenDate: Long) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MembershipModel + + return member.membershipId == other.membershipId + } + + override fun hashCode(): Int { + var result = member.membershipId.hashCode() + result = 31 * result + member.personId.hashCode() + result = 31 * result + member.spaceId.hashCode() + result = 31 * result + member.personDisplayName.hashCode() + result = 31 * result + member.created.hashCode() + result = 31 * result + member.personOrgId.hashCode() + result = 31 * result + member.isModerator.hashCode() + result = 31 * result + member.isMonitor.hashCode() + result = 31 * result + member.personEmail.hashCode() + return result + } + + companion object { + fun convertToMembershipReadStatusModel(membershipReadStatus: MembershipReadStatus?): MembershipReadStatusModel { + return MembershipReadStatusModel(MembershipModel.convertToMembershipModel(membershipReadStatus?.membership), + membershipReadStatus?.lastSeenId.orEmpty(), membershipReadStatus?.lastSeenDate + ?: 0) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusViewModel.kt new file mode 100644 index 0000000..09770c9 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusViewModel.kt @@ -0,0 +1,36 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipRepository +import com.ciscowebex.androidsdk.membership.Membership +import io.reactivex.android.schedulers.AndroidSchedulers + +class MembershipReadStatusViewModel(private val membershipRepo: MembershipRepository, private val webexRepository: WebexRepository) : BaseViewModel() { + private val _membershipsReadStatus = MutableLiveData>() + val membershipsReadStatus: LiveData> = _membershipsReadStatus + + private val _membershipReadStatusError = MutableLiveData() + val membershipReadStatusError: LiveData = _membershipReadStatusError + + private val _membershipEventLiveData = MutableLiveData>() + val membershipEventLiveData: LiveData> = _membershipEventLiveData + + init { + webexRepository._membershipEventLiveData = _membershipEventLiveData + } + + override fun onCleared() { + super.onCleared() + webexRepository._membershipEventLiveData = null + } + + fun getMembershipsWithReadStatus(spaceId: String?) { + membershipRepo.listMembershipsWithReadStatus(spaceId + ?: "").observeOn(AndroidSchedulers.mainThread()).subscribe({ membershipsReadStatus -> + _membershipsReadStatus.postValue(membershipsReadStatus) + }, { _membershipReadStatusError.postValue(it.message) }).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailActivity.kt new file mode 100644 index 0000000..66e752f --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailActivity.kt @@ -0,0 +1,50 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.readStatusDetails + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivitySpaceReadStatusDetailBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.koin.android.ext.android.inject + +class SpaceReadStatusDetailActivity : BaseActivity() { + + companion object { + fun getIntent(context: Context, spaceId: String) : Intent { + val intent = Intent(context, SpaceReadStatusDetailActivity::class.java) + intent.putExtra(Constants.Intent.SPACE_ID, spaceId) + return intent + } + } + + private val spaceReadStatusDetailViewModel : SpaceReadStatusDetailViewModel by inject() + private lateinit var spaceId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + spaceId = intent.getStringExtra(Constants.Intent.SPACE_ID) ?: "" + + DataBindingUtil.setContentView(this, R.layout.activity_space_read_status_detail) + .apply { + progressLayout.visibility = View.VISIBLE + + spaceReadStatusDetailViewModel.spaceReadStatus.observe(this@SpaceReadStatusDetailActivity, Observer { model -> + model?.let { + progressLayout.visibility = View.GONE + spaceReadStatus = it + } + }) + } + } + + override fun onResume() { + super.onResume() + spaceReadStatusDetailViewModel.getSpaceReadStatusById(spaceId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailViewModel.kt new file mode 100644 index 0000000..dcd1d3b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailViewModel.kt @@ -0,0 +1,19 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.readStatusDetails + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceReadStatusModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import io.reactivex.android.schedulers.AndroidSchedulers + +class SpaceReadStatusDetailViewModel(private val spacesRepo: SpacesRepository) : BaseViewModel() { + private val _spaceReadStatus = MutableLiveData() + val spaceReadStatus : LiveData = _spaceReadStatus + + fun getSpaceReadStatusById(spaceId: String){ + spacesRepo.getSpaceReadStatusById(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _spaceReadStatus.postValue(it) + }, {_spaceReadStatus.postValue(null)}).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamActionBottomSheetFragment.kt new file mode 100644 index 0000000..af3edb5 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamActionBottomSheetFragment.kt @@ -0,0 +1,47 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetTeamOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class TeamActionBottomSheetFragment( + val editClickListener: (String, String) -> Unit, + val addSpaceClickListener : (String) -> Unit, + val deleteTeamClickListener : (String, String) -> Unit, + val getMembersClickListener : (String) -> Unit +) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetTeamOptionsBinding + var teamId : String = "" + var teamTitle: String = "" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetTeamOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + getMembers.setOnClickListener { + dismiss() + getMembersClickListener(teamId) + } + editTeamName.setOnClickListener { + dismiss() + editClickListener(teamId, teamTitle) + } + + addSpaceFromTeam.setOnClickListener { + dismiss() + addSpaceClickListener(teamId) + } + + deleteTeam.setOnClickListener { + dismiss() + deleteTeamClickListener(teamId, teamTitle) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamModel.kt new file mode 100644 index 0000000..74831b6 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamModel.kt @@ -0,0 +1,26 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams + +import java.util.Date + +data class TeamModel(val id : String, val name : String, val createdDateTime : Date){ + + val createdDateTimeString : String = createdDateTime.toString() + + override fun equals(other: Any?): Boolean { + if(this === other) return true + if(javaClass != other?.javaClass) return false + + other as TeamModel + + return id == other.id + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + createdDateTime.hashCode() + result = 31 * result + createdDateTimeString.hashCode() + return result + } + +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsFragment.kt new file mode 100644 index 0000000..c527498 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsFragment.kt @@ -0,0 +1,254 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogCreateSpaceBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentTeamsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemTeamsClientBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.search.MessagingSearchActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.AddPersonBottomSheetFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.detail.TeamDetailActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipActivity +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.koin.android.ext.android.inject + + +class TeamsFragment : Fragment() { + private lateinit var binding: FragmentTeamsBinding + private lateinit var teamsClientAdapter: TeamsClientAdapter + + private val teamsViewModel: TeamsViewModel by inject() + private val TAG = TeamsFragment::class.java.name + private val requestCodeSearchPersonToAddToTeam = 31321 + private var selectedTeamListItem: TeamModel? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return FragmentTeamsBinding.inflate(inflater, container, false).also { binding = it }.apply { + val optionsDialogFragment = TeamActionBottomSheetFragment( + { id, title -> showEditTeamDialog(id, title) }, + { id -> showAddSpaceDialog(id) }, + { id, title -> showDeleteTeamConfirmationDialog(id, title) }, + { id -> showMembers(id) } + ) + teamsClientAdapter = TeamsClientAdapter(optionsDialogFragment) { position -> + selectedTeamListItem = teamsClientAdapter.teams[position] + startActivityForResult(context?.let { MessagingSearchActivity.getIntent(it) }, requestCodeSearchPersonToAddToTeam) + } + + teamsRecyclerView.adapter = teamsClientAdapter + lifecycleOwner = this@TeamsFragment + + swipeContainer.setOnRefreshListener { + teamsViewModel.getTeamsList(resources.getInteger(R.integer.team_list_size)) + } + + teamsViewModel.teams.observe(this@TeamsFragment.viewLifecycleOwner, Observer { list -> + list?.let { + swipeContainer.isRefreshing = false + + teamsClientAdapter.teams.clear() + teamsClientAdapter.teams.addAll(it) + teamsClientAdapter.notifyDataSetChanged() + } + }) + + teamsViewModel.teamAdded.observe(this@TeamsFragment.viewLifecycleOwner, Observer { model -> + model?.let { + teamsClientAdapter.teams.add(it) + teamsClientAdapter.notifyDataSetChanged() + } + }) + + teamsViewModel.teamError.observe(this@TeamsFragment.viewLifecycleOwner, Observer { error -> + error?.let { + showErrorDialog(it) + } + }) + + addTeamsFAB.setOnClickListener { + showAddTeamDialog() + } + + }.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + teamsViewModel.getTeamsList(resources.getInteger(R.integer.team_list_size)) + } + + private fun showAddTeamDialog() { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.add_team) + val input = EditText(requireContext()) + input.hint = getString(R.string.team_name_hint) + input.requestFocus() + + builder.setView(input) + + builder.setPositiveButton(android.R.string.ok) { _, _ -> teamsViewModel.addTeam(input.text.toString()) } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + + private fun showErrorDialog(errorMessage: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.error_occurred) + val message = TextView(requireContext()) + message.setPadding(10, 10, 10, 10) + message.text = errorMessage + + builder.setView(message) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } + + private fun showEditTeamDialog(teamID: String, title: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.edit_team) + val input = EditText(requireContext()) + input.text = SpannableStringBuilder(title) + input.requestFocus() + + builder.setView(input) + + builder.setPositiveButton(android.R.string.ok) { _, _ -> teamsViewModel.updateTeam(teamID, input.text.toString()) } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + + private fun showAddSpaceDialog(teamId: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.add_space) + + DialogCreateSpaceBinding.inflate(layoutInflater) + .apply { + spaceTeamIdText.text = teamId + + spaceTitleEditText.requestFocus() + + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { _, _ -> + teamsViewModel.addSpaceFromTeam(spaceTitleEditText.text.toString(), teamId) + } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + } + + private fun showMembers(teamId: String) { + startActivity(TeamMembershipActivity.getIntent(requireContext(), teamId)) + } + + private fun showDeleteTeamConfirmationDialog(teamId: String, teamTitle: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.delete_team_confirm) + val message = TextView(requireContext()) + message.setPadding(10, 10, 10, 10) + message.text = String.format(getString(R.string.delete_team_message), teamTitle) + + builder.setView(message) + + builder.setPositiveButton(android.R.string.ok) { _, _ -> teamsViewModel.deleteTeamWithId(teamId) } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + builder.show() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == requestCodeSearchPersonToAddToTeam && resultCode == Activity.RESULT_OK) { + val person = data?.getParcelableExtra(Constants.Intent.PERSON) + if (person != null) { + showAddMembersOptionDialog(person) + } else { + Log.d(TAG, "Person data is null ") + } + + } else { + Log.d(TAG, "Person could not be found!") + } + } + + private fun showAddMembersOptionDialog(person: PersonModel) { + val addMembersOptionDialog = AddPersonBottomSheetFragment { option -> + when (option) { + AddPersonBottomSheetFragment.Companion.Options.ADD_BY_PERSON_ID -> selectedTeamListItem?.id?.let { + teamsViewModel.createMembershipWithId(it, person.personId, false) + } + AddPersonBottomSheetFragment.Companion.Options.ADD_BY_EMAIL_ID -> selectedTeamListItem?.id?.let { + teamsViewModel.createMembershipWithEmailId(it, person.emails.first(), false) + } + } + } + activity?.supportFragmentManager?.let { addMembersOptionDialog.show(it, AddPersonBottomSheetFragment.TAG) } + } +} + +class TeamsClientAdapter(private val optionsDialogFragment: TeamActionBottomSheetFragment, private val onAddToTeamButtonClicked: (Int) -> Unit) : RecyclerView.Adapter() { + var teams: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TeamsClientViewHolder { + return TeamsClientViewHolder(ListItemTeamsClientBinding.inflate(LayoutInflater.from(parent.context), parent, false), optionsDialogFragment, onAddToTeamButtonClicked) + } + + override fun getItemCount(): Int = teams.size + + override fun onBindViewHolder(holder: TeamsClientViewHolder, position: Int) { + holder.bind(teams[position]) + } +} + +class TeamsClientViewHolder(private val binding: ListItemTeamsClientBinding, private val optionsDialogFragment: TeamActionBottomSheetFragment, + private val onAddToTeamButtonClicked: (Int) -> Unit) : RecyclerView.ViewHolder(binding.root) { + init { + binding.ivAddToTeam.setOnClickListener { + onAddToTeamButtonClicked(adapterPosition) + } + } + + fun bind(team: TeamModel) { + binding.team = team + + binding.teamsClientLayout.setOnClickListener { view -> + ContextCompat.startActivity(view.context, TeamDetailActivity.getIntent(view.context, team.id), null) + } + + binding.teamsClientLayout.setOnLongClickListener { view -> + optionsDialogFragment.teamId = team.id + optionsDialogFragment.teamTitle = team.name + + val activity = view.context as AppCompatActivity + activity.supportFragmentManager.let { optionsDialogFragment.show(it, "Team Options") } + + true + } + + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsRepository.kt new file mode 100644 index 0000000..0aaf1ea --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsRepository.kt @@ -0,0 +1,78 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingRepository +import com.ciscowebex.androidsdk.CompletionHandler +import io.reactivex.Completable +import io.reactivex.Observable +import io.reactivex.Single +import java.util.* + +class TeamsRepository(private val webex: Webex) : MessagingRepository(webex) { + fun fetchTeamsList(maxTeams: Int): Observable> { + return Single.create> { emitter -> + webex.teams.list(maxTeams, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.filter { !it.isDeleted }?.map { TeamModel(it.id.orEmpty(), it.name.orEmpty(), it.created) } + ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun fetchTeamById(teamId: String): Observable { + return Single.create { emitter -> + webex.teams.get(teamId, CompletionHandler { result -> + if (result.isSuccessful) { + val team = result.data + emitter.onSuccess(TeamModel(team?.id.orEmpty(), team?.name.orEmpty(), team?.created + ?: Date())) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun createTeam(teamName: String): Observable { + return Single.create { emitter -> + webex.teams.create(teamName, CompletionHandler { result -> + if (result.isSuccessful) { + val team = result.data + emitter.onSuccess(TeamModel(team?.id.orEmpty(), team?.name.orEmpty(), team?.created + ?: Date())) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun updateTeam(teamId: String, teamName: String): Observable { + return Single.create { emitter -> + webex.teams.update(teamId, teamName, CompletionHandler { result -> + if (result.isSuccessful) { + val team = result.data + emitter.onSuccess(TeamModel(team?.id.orEmpty(), team?.name.orEmpty(), team?.created + ?: Date())) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun deleteTeamWithId(teamId: String): Observable { + return Completable.create { emitter -> + webex.teams.delete(teamId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onComplete() + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsViewModel.kt new file mode 100644 index 0000000..b6b6460 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsViewModel.kt @@ -0,0 +1,68 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipRepository +import io.reactivex.android.schedulers.AndroidSchedulers + +class TeamsViewModel(private val teamsRepo: TeamsRepository, private val membershipRepo: TeamMembershipRepository) : BaseViewModel() { + private val _teams = MutableLiveData>() + val teams: LiveData> = _teams + + private val _teamAdded = MutableLiveData() + val teamAdded: LiveData = _teamAdded + + private val _createMemberData = MutableLiveData() + val createMemberData: LiveData = _createMemberData + + private val _teamError = MutableLiveData() + val teamError : LiveData = _teamError + + fun getTeamsList(maxTeams: Int) { + teamsRepo.fetchTeamsList(maxTeams).observeOn(AndroidSchedulers.mainThread()).subscribe({ teamsList -> + _teams.postValue(teamsList) + }, { _teams.postValue(emptyList()) }).autoDispose() + } + + fun addTeam(teamName: String) { + teamsRepo.createTeam(teamName).observeOn(AndroidSchedulers.mainThread()).subscribe({ addedTeam -> + _teamAdded.postValue(addedTeam) + }, { _teamAdded.postValue(null) }).autoDispose() + } + + fun updateTeam(teamId: String, teamName: String) { + teamsRepo.updateTeam(teamId, teamName).observeOn(AndroidSchedulers.mainThread()).subscribe({ + refreshTeams() + }, { error -> _teamError.postValue(error.message) }).autoDispose() + } + + fun deleteTeamWithId(teamId: String) { + teamsRepo.deleteTeamWithId(teamId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + refreshTeams() + }, { error -> _teamError.postValue(error.message) }).autoDispose() + } + + fun addSpaceFromTeam(spaceTitle: String, teamId: String){ + teamsRepo.addSpace(spaceTitle, teamId).observeOn(AndroidSchedulers.mainThread()).subscribe ({ + refreshTeams() + }, {}).autoDispose() + } + + private fun refreshTeams() { + getTeamsList(0) + } + + fun createMembershipWithEmailId(spaceId: String, emailId: String, isModerator: Boolean) { + membershipRepo.createMembershipWithEmail(spaceId, emailId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _createMemberData.postValue(membership) + }, { error -> _teamError.postValue(error.message) }).autoDispose() + } + + fun createMembershipWithId(spaceId: String, personId: String, isModerator: Boolean) { + membershipRepo.createMembershipWithId(spaceId, personId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _createMemberData.postValue(membership) + }, { error -> _teamError.postValue(error.message) }).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailActivity.kt new file mode 100644 index 0000000..8c4fd42 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailActivity.kt @@ -0,0 +1,52 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.detail + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityTeamDetailBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.koin.android.ext.android.inject + +class TeamDetailActivity : BaseActivity() { + lateinit var binding: ActivityTeamDetailBinding + + private val teamDetailViewModel : TeamDetailViewModel by inject() + private lateinit var teamId: String + + companion object { + fun getIntent(context: Context, teamId: String): Intent { + val intent = Intent(context, TeamDetailActivity::class.java) + intent.putExtra(Constants.Intent.TEAM_ID, teamId) + return intent + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + teamId = intent.getStringExtra(Constants.Intent.TEAM_ID) ?: "" + + DataBindingUtil.setContentView(this, R.layout.activity_team_detail) + .also { binding = it } + .apply { + binding.progressLayout.visibility = View.VISIBLE + + teamDetailViewModel.team.observe(this@TeamDetailActivity, Observer { model -> + model?.let { + progressLayout.visibility = View.GONE + binding.team = it + } + }) + } + } + + override fun onResume() { + super.onResume() + teamDetailViewModel.getTeamById(teamId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailViewModel.kt new file mode 100644 index 0000000..cf376c3 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailViewModel.kt @@ -0,0 +1,19 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.detail + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsRepository +import io.reactivex.android.schedulers.AndroidSchedulers + +class TeamDetailViewModel(private val teamsRepo: TeamsRepository) : BaseViewModel() { + private val _team = MutableLiveData() + val team : LiveData = _team + + fun getTeamById(teamId: String){ + teamsRepo.fetchTeamById(teamId).observeOn(AndroidSchedulers.mainThread()).subscribe({ teamModel -> + _team.postValue((teamModel)) + }, { _team.postValue(null)}).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActionBottomSheetFragment.kt new file mode 100644 index 0000000..4e889a9 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActionBottomSheetFragment.kt @@ -0,0 +1,45 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetTeamMemberOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class TeamMembershipActionBottomSheetFragment(val membershipDetailsClickListener: (String) -> Unit, + val deleteMembershipClickListener: (String) -> Unit, + val membershipSetModeratorClickListener: (String) -> Unit, + val membershipRemoveModeratorClickListener: (String) -> Unit) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetTeamMemberOptionsBinding + var teamMembershipId: String = "" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetTeamMemberOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + getMembershipDetails.setOnClickListener { + dismiss() + membershipDetailsClickListener(teamMembershipId) + } + + setMembershipModerator.setOnClickListener { + dismiss() + membershipSetModeratorClickListener(teamMembershipId) + } + + removeMembershipModerator.setOnClickListener { + dismiss() + membershipRemoveModeratorClickListener(teamMembershipId) + } + + deleteMembership.setOnClickListener { + dismiss() + deleteMembershipClickListener(teamMembershipId) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActivity.kt new file mode 100644 index 0000000..a46d778 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActivity.kt @@ -0,0 +1,42 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityMembershipBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants + +class TeamMembershipActivity : BaseActivity() { + + companion object { + fun getIntent(context: Context, teamId: String): Intent { + val intent = Intent(context, TeamMembershipActivity::class.java) + intent.putExtra(Constants.Intent.TEAM_ID, teamId) + return intent + } + } + + lateinit var binding: ActivityMembershipBinding + + private lateinit var teamId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + teamId = intent.getStringExtra(Constants.Intent.TEAM_ID) ?: "" + + + DataBindingUtil.setContentView(this, R.layout.activity_membership) + .apply { + val fragmentManager = supportFragmentManager + val fragmentTransaction = fragmentManager.beginTransaction() + + val fragment = TeamMembershipFragment.newInstance(teamId) + fragmentTransaction.add(R.id.membershipFragment, fragment) + fragmentTransaction.commit() + } + } +} + diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipFragment.kt new file mode 100644 index 0000000..c759bad --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipFragment.kt @@ -0,0 +1,171 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogTeamMembershipDetailsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentMembershipBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemTeamMembershipClientBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import org.koin.android.ext.android.inject + +class TeamMembershipFragment : Fragment() { + + lateinit var binding: FragmentMembershipBinding + + private val membershipViewModel: TeamMembershipViewModel by inject() + private var teamId: String? = null + + companion object { + fun newInstance(teamId: String): TeamMembershipFragment { + val args = Bundle() + args.putString(Constants.Bundle.TEAM_ID, teamId) + + val fragment = TeamMembershipFragment() + fragment.arguments = args + + return fragment + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + teamId = arguments?.getString(Constants.Bundle.TEAM_ID) + membershipViewModel.teamId = teamId + return FragmentMembershipBinding.inflate(inflater, container, false) + .also { binding = it } + .apply { + val teamMembershipActionBottomSheet = TeamMembershipActionBottomSheetFragment({ teamMembershipId -> membershipViewModel.getTeamMembership(teamMembershipId) }, + { teamMembershipId -> + showDialogWithMessage(requireContext(), getString(R.string.delete_membership), getString(R.string.confirm_delete_membership_action), + onPositiveButtonClick = { dialog, _ -> + dialog.dismiss() + membershipViewModel.deleteMembership(teamMembershipId, resources.getInteger(R.integer.membership_list_size)) + }, + onNegativeButtonClick = { dialog, _ -> + dialog.dismiss() + }) + }, + + { teamMembershipId -> + membershipViewModel.updateMembership(teamMembershipId, true) + }, + { teamMembershipId -> + membershipViewModel.updateMembership(teamMembershipId, false) + } + ) + + val membershipClientAdapter = TeamMembershipClientAdapter(teamMembershipActionBottomSheet, requireActivity().supportFragmentManager) + membershipsRecyclerView.adapter = membershipClientAdapter + membershipsRecyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + + membershipViewModel.memberships.observe(viewLifecycleOwner, Observer { list -> + list?.let { + binding.progressLayout.visibility = View.GONE + membershipClientAdapter.memberships.clear() + membershipClientAdapter.memberships.addAll(it) + membershipClientAdapter.notifyDataSetChanged() + } + }) + + membershipViewModel.membershipDetails.observe(viewLifecycleOwner, Observer { model -> + model?.let { + displayMembershipDetails(it) + } + }) + + membershipViewModel.membershipError.observe(viewLifecycleOwner, Observer { error -> + error?.let { + showErrorDialog(it) + } + }) + + }.root + } + + private fun displayMembershipDetails(teamMembershipDetails: TeamMembershipModel?) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.members_details) + + DialogTeamMembershipDetailsBinding.inflate(layoutInflater) + .apply { + membership = teamMembershipDetails + + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + } + + builder.show() + } + } + + private fun showErrorDialog(errorMessage: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.error_occurred) + builder.setMessage(errorMessage) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + getTeamMembers() + } + + private fun getTeamMembers() { + binding.progressLayout.visibility = View.VISIBLE + val maxMemberships = resources.getInteger(R.integer.membership_list_size) + membershipViewModel.getTeamMembersIn(maxMemberships) + } +} + +class TeamMembershipClientAdapter(private val teamMembershipActionBottomSheet: TeamMembershipActionBottomSheetFragment, + private val supportFragmentManager: FragmentManager) : RecyclerView.Adapter() { + var memberships: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TeamMembershipClientViewHolder { + return TeamMembershipClientViewHolder(teamMembershipActionBottomSheet, ListItemTeamMembershipClientBinding.inflate(LayoutInflater.from(parent.context), parent, false), + supportFragmentManager) + } + + override fun getItemCount(): Int = memberships.size + + override fun onBindViewHolder(holder: TeamMembershipClientViewHolder, position: Int) { + holder.bind(memberships[position]) + } + +} + +class TeamMembershipClientViewHolder(private val teamMembershipActionBottomSheet: TeamMembershipActionBottomSheetFragment, + private val binding: ListItemTeamMembershipClientBinding, + supportFragmentManager: FragmentManager) : RecyclerView.ViewHolder(binding.root) { + var membership: TeamMembershipModel? = null + + init { + binding.root.setOnLongClickListener { _ -> + membership?.let { + teamMembershipActionBottomSheet.teamMembershipId = it.teamMembershipId + teamMembershipActionBottomSheet.show(supportFragmentManager, "Team Membership Options") + } + true + } + } + + fun bind(membership: TeamMembershipModel) { + this.membership = membership + binding.membership = membership + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipModel.kt new file mode 100644 index 0000000..7f6aad1 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipModel.kt @@ -0,0 +1,41 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.team.TeamMembership +import java.util.* + +data class TeamMembershipModel(val teamMembershipId: String, val personId: String, val personEmail: String, + val personDisplayName: String, val isModerator: Boolean, val created: Date, + val personOrgId: String) { + + val createdDateTimeString: String = created.toString() + val isModeratorString: String = isModerator.toString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SpaceModel + + return teamMembershipId == other.id + } + + override fun hashCode(): Int { + var result = teamMembershipId.hashCode() + result = 31 * result + personId.hashCode() + result = 31 * result + personEmail.hashCode() + result = 31 * result + personDisplayName.hashCode() + result = 31 * result + isModerator.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + personOrgId.hashCode() + return result + } + + companion object { + fun convertToMembershipModel(membership: TeamMembership?): TeamMembershipModel { + return TeamMembershipModel(membership?.id.orEmpty(), membership?.personId.orEmpty(), membership?.personEmail.orEmpty(), + membership?.personDisplayName.orEmpty(), membership?.isModerator ?: false, + membership?.created ?: Date(), membership?.personOrgId.orEmpty()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipRepository.kt new file mode 100644 index 0000000..91a3d49 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipRepository.kt @@ -0,0 +1,85 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import android.util.Log +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.CompletionHandler +import io.reactivex.Observable +import io.reactivex.Single + +class TeamMembershipRepository(private val webex: Webex) { + fun getTeamMemberships(teamId: String?, max: Int): Observable> { + return Single.create> { emitter -> + webex.teamMembershipClient.list(teamId, max, CompletionHandler { result -> + Log.d(TeamMembershipRepository::class.java.name, "result: " + result.data) + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { + TeamMembershipModel.convertToMembershipModel(it) + } ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getTeamMembership(teamMembershipId: String): Observable { + return Single.create { emitter -> + webex.teamMembershipClient.get(teamMembershipId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(TeamMembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun delete(teamMembershipId: String): Observable { + return Single.create { emitter -> + webex.teamMembershipClient.delete(teamMembershipId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun updateMembership(teamMembershipId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.teamMembershipClient.update(teamMembershipId, isModerator, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(TeamMembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun createMembershipWithId(teamId: String, personId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.teamMembershipClient.create(teamId, personId, null, isModerator, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(TeamMembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun createMembershipWithEmail(teamId: String, emailId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.teamMembershipClient.create(teamId, null, emailId, isModerator, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(TeamMembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipViewModel.kt new file mode 100644 index 0000000..166d5c4 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipViewModel.kt @@ -0,0 +1,44 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import io.reactivex.android.schedulers.AndroidSchedulers + +class TeamMembershipViewModel(private val membershipRepo: TeamMembershipRepository) : BaseViewModel() { + var teamId: String? = null + private val _memberships = MutableLiveData>() + val memberships: LiveData> = _memberships + + private val _membershipDetail = MutableLiveData() + val membershipDetails: LiveData = _membershipDetail + + private val _membershipError = MutableLiveData() + val membershipError: LiveData = _membershipError + + fun getTeamMembersIn(max: Int) { + membershipRepo.getTeamMemberships(teamId, max).observeOn(AndroidSchedulers.mainThread()).subscribe({ memberships -> + _memberships.postValue(memberships) + }, { _memberships.postValue(emptyList()) }).autoDispose() + } + + fun getTeamMembership(teamMembershipId: String){ + membershipRepo.getTeamMembership(teamMembershipId).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _membershipDetail.postValue(membership) + }, { error -> _membershipError.postValue(error.message)}).autoDispose() + } + + fun deleteMembership(teamMembershipId: String, max: Int) { + membershipRepo.delete(teamMembershipId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + // refresh list + getTeamMembersIn(max) + }, {error -> _membershipError.postValue(error.message)}).autoDispose() + } + + fun updateMembership(teamMembershipId: String, isModerator: Boolean) { + membershipRepo.updateMembership(teamMembershipId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _membershipDetail.postValue(membership) + }, {error -> _membershipError.postValue(error.message)}).autoDispose() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleActionBottomSheetFragment.kt new file mode 100644 index 0000000..b618b0d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleActionBottomSheetFragment.kt @@ -0,0 +1,42 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetPeopleOptionsBinding +import com.ciscowebex.androidsdk.utils.EmailAddress +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class PeopleActionBottomSheetFragment(val postToPersonID: (String?, String?, PersonModel) -> Unit, + val postToPersonEmail: (String?, EmailAddress?, PersonModel) -> Unit, + val fetchPersonByID: (String) -> Unit) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetPeopleOptionsBinding + lateinit var model: PersonModel + lateinit var personId: String + lateinit var email: EmailAddress + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetPeopleOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + postMessageByID.setOnClickListener { + dismiss() + postToPersonID(personId, null, model) + } + + postMessageByEmail.setOnClickListener { + dismiss() + postToPersonEmail(null, email, model) + } + + fetchPersonByID.setOnClickListener { + dismiss() + fetchPersonByID(personId) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleFragment.kt new file mode 100644 index 0000000..72ecfd1 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleFragment.kt @@ -0,0 +1,128 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SearchView +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentPersonBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemPersonBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerActivity +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.isValidEmail +import com.ciscowebex.androidsdk.utils.EmailAddress +import org.koin.android.ext.android.inject + + +class PeopleFragment : Fragment() { + private lateinit var binding: FragmentPersonBinding + private lateinit var peopleClientAdapter: PeopleClientAdapter + + private val personViewModel: PersonViewModel by inject() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return FragmentPersonBinding.inflate(inflater, container, false).also { binding = it }.apply { + val optionsDialogFragment = PeopleActionBottomSheetFragment( + { personId, _, model -> showPostMessageDialog(personId, null, model) }, + { _, email, model -> showPostMessageDialog(null, email, model) }, + { personId -> fetchDetailsById(personId) }) + + peopleClientAdapter = PeopleClientAdapter(optionsDialogFragment, requireActivity().supportFragmentManager) + + recyclerView.adapter = peopleClientAdapter + lifecycleOwner = this@PeopleFragment + + personViewModel.personList.observe(this@PeopleFragment.viewLifecycleOwner, Observer { list -> + list?.let { + peopleClientAdapter.persons.clear() + peopleClientAdapter.persons.addAll(it) + peopleClientAdapter.notifyDataSetChanged() + } + }) + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + if (newText.isValidEmail()) { + personViewModel.getPeopleList(newText, null, null, null, resources.getInteger(R.integer.person_list_size)) + } else { + personViewModel.getPeopleList(null, newText, null, null, resources.getInteger(R.integer.person_list_size)) + } + return false + } + + }) + }.root + } + + override fun onResume() { + super.onResume() + personViewModel.getPeopleList(null, null, null, null, resources.getInteger(R.integer.person_list_size)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + } + + private fun fetchDetailsById(personId: String) { + PersonDialogFragment.newInstance(personId).show(childFragmentManager, getString(R.string.person_detail)) + } + + private fun showPostMessageDialog(id: String?, email: EmailAddress?, model: PersonModel) { + id?.let { + val composerType = MessageComposerActivity.Companion.ComposerType.POST_PERSON_ID + ContextCompat.startActivity(requireActivity(), + MessageComposerActivity.getIntent(requireActivity(), composerType, it, null), null) + } ?: run { + email?.let { + val composerType = MessageComposerActivity.Companion.ComposerType.POST_PERSON_EMAIL + ContextCompat.startActivity(requireActivity(), + MessageComposerActivity.getIntent(requireActivity(), composerType, it.toString(), null), null) + } + } + } +} + +class PeopleClientAdapter(private val optionsDialogFragment: PeopleActionBottomSheetFragment, private val fragmentManager: FragmentManager) : RecyclerView.Adapter() { + var persons: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PeopleClientViewHolder { + return PeopleClientViewHolder(ListItemPersonBinding.inflate(LayoutInflater.from(parent.context), parent, false), optionsDialogFragment, fragmentManager) + } + + override fun getItemCount(): Int = persons.size + + override fun onBindViewHolder(holder: PeopleClientViewHolder, position: Int) { + holder.bind(persons[position]) + } +} + +class PeopleClientViewHolder(private val binding: ListItemPersonBinding, private val optionsDialogFragment: PeopleActionBottomSheetFragment, private val fragmentManager: FragmentManager) : RecyclerView.ViewHolder(binding.root) { + fun bind(person: PersonModel) { + binding.person = person + + binding.personClientLayout.setOnLongClickListener { view -> + optionsDialogFragment.personId = person.personId + if (person.emails.isEmpty()) { + optionsDialogFragment.email = EmailAddress.fromString("") + } else { + optionsDialogFragment.email = EmailAddress.fromString(person.emails[0]) + } + optionsDialogFragment.model = person + + optionsDialogFragment.show(fragmentManager, "People Options") + + true + } + + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonDialogFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonDialogFragment.kt new file mode 100644 index 0000000..4aa74c8 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonDialogFragment.kt @@ -0,0 +1,65 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentDialogPersonBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.koin.android.ext.android.inject + +class PersonDialogFragment : DialogFragment() { + + companion object { + fun newInstance(personId: String) : PersonDialogFragment { + val args = Bundle() + args.putString(Constants.Bundle.PERSON_ID, personId) + + val fragment = PersonDialogFragment() + fragment.arguments = args + + return fragment + } + } + + private val personViewModel : PersonViewModel by inject() + private lateinit var personId : String + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + personId = arguments?.getString(Constants.Bundle.PERSON_ID) ?: "" + + return FragmentDialogPersonBinding.inflate(inflater, container, false) + .apply { + progressLayout.visibility = View.VISIBLE + + personViewModel.person.observe(this@PersonDialogFragment, Observer { model -> + model?.let { + progressLayout.visibility = View.GONE + person = it + } + }) + + dialogOk.setOnClickListener { dismiss() } + }.root + } + + override fun onResume() { + super.onResume() + if(personId.isEmpty()) { + personViewModel.getMe() + } else { + personViewModel.getPersonDetail(personId) + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModel.kt new file mode 100644 index 0000000..c6cb751 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModel.kt @@ -0,0 +1,50 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import android.os.Parcelable +import com.ciscowebex.androidsdk.people.Person +import kotlinx.android.parcel.Parcelize +import java.util.* + +@Parcelize +data class PersonModel(val personId: String, val emails: List, val displayName: String, + val nickName: String, val firstName: String, val lastName: String, + val avatar: String, val orgId: String, val created: Date, + val lastActivity: String, val status: String, val type: String) : Parcelable { + + val createdString: String = created.toString() + val emailList = emails.joinToString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PersonModel + + return personId == other.personId + } + + override fun hashCode(): Int { + var result = personId.hashCode() + result = 31 * result + emails.hashCode() + result = 31 * result + displayName.hashCode() + result = 31 * result + nickName.hashCode() + result = 31 * result + firstName.hashCode() + result = 31 * result + lastName.hashCode() + result = 31 * result + avatar.hashCode() + result = 31 * result + orgId.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + lastActivity.hashCode() + result = 31 * result + status.hashCode() + result = 31 * result + type.hashCode() + return result + } + + companion object { + fun convertToPersonModel(person: Person?): PersonModel { + return PersonModel(person?.id.orEmpty(), person?.emails.orEmpty(), person?.displayName.orEmpty(), + person?.nickName.orEmpty(), person?.firstName.orEmpty(), person?.lastName.orEmpty(), + person?.avatar.orEmpty(), person?.orgId.orEmpty(), person?.created ?: Date(), + person?.lastActivity.orEmpty(), person?.status.orEmpty(), person?.type.orEmpty()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModule.kt new file mode 100644 index 0000000..f7d96fc --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModule.kt @@ -0,0 +1,10 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val personModule = module { + viewModel { PersonViewModel(get()) } + + single { PersonRepository(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonRepository.kt new file mode 100644 index 0000000..ce2b8a8 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonRepository.kt @@ -0,0 +1,94 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import android.util.Log +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.CompletionHandler +import io.reactivex.Observable +import io.reactivex.Single + +class PersonRepository(private val webex: Webex) { + + fun getMe(): Observable { + return Single.create { emitter -> + webex.people.getMe(CompletionHandler { result -> + if (result.isSuccessful) { + val person = result.data + emitter.onSuccess(PersonModel.convertToPersonModel(person)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getPersonDetail(personId: String): Observable { + return Single.create { emitter -> + webex.people.get(personId, CompletionHandler { result -> + if (result.isSuccessful) { + val person = result.data + emitter.onSuccess(PersonModel.convertToPersonModel(person)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getPeopleList(email: String?, displayName: String?, id: String?, orgId: String?, max: Int): Observable> { + return Single.create> { emitter -> + webex.people.list(email, displayName, id, orgId, max, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { PersonModel.convertToPersonModel(it) }.orEmpty()) + Log.d("CRUD_TEST", "Listed persons successfully"); + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + Log.d("CRUD_TEST", result.error?.errorMessage ?: ""); + } + }) + }.toObservable() + } + + fun createPerson(email: String, displayName: String?, firstName: String?, lastName: String?, avatar: String?, orgId: String?, roles: String?, licenses: String?): Observable { + return Single.create { emitter -> + webex.people.create(email, displayName, firstName, lastName, avatar, orgId, roles, licenses, CompletionHandler { result -> + if (result.isSuccessful) { + val person = result.data + emitter.onSuccess(PersonModel.convertToPersonModel(person)) + Log.d("CRUD_TEST", "Created person successfully"); + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + Log.d("CRUD_TEST", result.error?.errorMessage ?: ""); + } + }) + }.toObservable() + } + + fun updatePerson(personId: String, email: String?, displayName: String?, firstName: String?, lastName: String?, avatar: String?, orgId: String?, roles: String?, licenses: String?): Observable { + return Single.create { emitter -> + webex.people.update(personId, email, displayName, firstName, lastName, avatar, orgId, roles, licenses, CompletionHandler { result -> + if (result.isSuccessful) { + val person = result.data + emitter.onSuccess(PersonModel.convertToPersonModel(person)) + Log.d("CRUD_TEST", "Updated person details successfully"); + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + Log.d("CRUD_TEST", result.error?.errorMessage ?: ""); + } + }) + }.toObservable() + } + + fun deletePerson(personId: String): Observable { + return Single.create { emitter -> + webex.people.delete(personId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + Log.d("CRUD_TEST", "Deleted person successfully"); + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + Log.d("CRUD_TEST", result.error?.errorMessage ?: ""); + } + }) + }.toObservable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonViewModel.kt new file mode 100644 index 0000000..27b931a --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonViewModel.kt @@ -0,0 +1,34 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import io.reactivex.android.schedulers.AndroidSchedulers + +class PersonViewModel(private val personRepo: PersonRepository) : BaseViewModel() { + private val tag = "PersonViewModel" + + private val _person = MutableLiveData() + val person: LiveData = _person + + private val _personList = MutableLiveData>() + val personList: LiveData> = _personList + + fun getMe() { + personRepo.getMe().observeOn(AndroidSchedulers.mainThread()).subscribe({ + _person.postValue(it) + }, { _person.postValue(null) }).autoDispose() + } + + fun getPersonDetail(personId: String) { + personRepo.getPersonDetail(personId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _person.postValue(it) + }, { _person.postValue(null) }).autoDispose() + } + + fun getPeopleList(email: String?, displayName: String?, id: String?, orgId: String?, max: Int) { + personRepo.getPeopleList(email, displayName, id, orgId, max).observeOn(AndroidSchedulers.mainThread()).subscribe({ personModels -> + _personList.postValue(personModels) + }, { _personList.postValue(emptyList()) }).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchActivity.kt new file mode 100644 index 0000000..7ac937f --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchActivity.kt @@ -0,0 +1,73 @@ +package com.ciscowebex.androidsdk.kitchensink.search + +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.calling.DialFragment +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivitySearchBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.HorizontalFlipTransformation +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy +import org.koin.android.viewmodel.ext.android.viewModel + +class SearchActivity : BaseActivity() { + lateinit var binding: ActivitySearchBinding + + private val searchViewModel: SearchViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_search) + .also { binding = it } + .apply { + viewPager.adapter = ViewPagerFragmentAdapter(this@SearchActivity, searchViewModel.titles) + viewPager.setPageTransformer(HorizontalFlipTransformation()) + TabLayoutMediator(tabLayout, viewPager, + TabConfigurationStrategy { tab: TabLayout.Tab, position: Int -> + tab.text = searchViewModel.titles[position] + } + ).attach() + } + } + + private class ViewPagerFragmentAdapter(fragmentActivity: FragmentActivity, val titles: List) : + FragmentStateAdapter(fragmentActivity) { + override fun createFragment(position: Int): Fragment { + when (position) { + 0 -> return DialFragment() + 1 -> { + val bundle = Bundle() + bundle.putString(Constants.Bundle.KEY_TASK_TYPE, SearchCommonFragment.Companion.TaskType.TaskSearchSpace) + val searchFragment = SearchCommonFragment() + searchFragment.arguments = bundle + return searchFragment + } + 2 -> { + val bundle = Bundle() + bundle.putString(Constants.Bundle.KEY_TASK_TYPE, SearchCommonFragment.Companion.TaskType.TaskCallHistory) + val callHistoryFragment = SearchCommonFragment() + callHistoryFragment.arguments = bundle + return callHistoryFragment + } + 3 -> { + val bundle = Bundle() + bundle.putString(Constants.Bundle.KEY_TASK_TYPE, SearchCommonFragment.Companion.TaskType.TaskListSpaces) + val spaceListFragment = SearchCommonFragment() + spaceListFragment.arguments = bundle + return spaceListFragment + } + } + return SearchCommonFragment() + } + + override fun getItemCount(): Int { + return titles.size + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchCommonFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchCommonFragment.kt new file mode 100644 index 0000000..2be9af5 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchCommonFragment.kt @@ -0,0 +1,234 @@ +package com.ciscowebex.androidsdk.kitchensink.search + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SearchView +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.databinding.CommonFragmentItemListBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentCommonBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.space.Space +import kotlinx.android.synthetic.main.fragment_common.* +import org.koin.android.ext.android.inject + + +class SearchCommonFragment : Fragment() { + private val searchViewModel: SearchViewModel by inject() + private var adapter: CustomAdapter = CustomAdapter() + private val itemModelList = mutableListOf() + lateinit var taskType: String + + companion object { + object TaskType { + const val TaskSearchSpace = "SearchSpace" + const val TaskCallHistory = "CallHistory" + const val TaskListSpaces = "ListSpaces" + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return FragmentCommonBinding.inflate(inflater, container, false).apply { + lifecycleOwner = this@SearchCommonFragment + + recyclerView.itemAnimator = DefaultItemAnimator() + + recyclerView.adapter = adapter + + taskType = arguments?.getString(Constants.Bundle.KEY_TASK_TYPE) + ?: TaskType.TaskListSpaces + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + progress_bar.visibility = View.VISIBLE + searchViewModel.search(newText) + return false + } + + }) + + setUpViewModelObservers() + + }.root + + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + updateSearchInputViewVisibility() + progress_bar.visibility = View.VISIBLE + } + + override fun onResume() { + super.onResume() + searchViewModel.loadData(taskType, resources.getInteger(R.integer.space_list_size)) + } + + private fun setUpViewModelObservers() { + // TODO: Put common code inside a function + searchViewModel.spaces.observe(viewLifecycleOwner, Observer { list -> + list?.let { + if (taskType == TaskType.TaskCallHistory) it.sortedBy { it.created } else it.sortedByDescending { it.lastActivity } + + if (it.isEmpty()) { + updateEmptyListUI(true) + } else { + updateEmptyListUI(false) + itemModelList.clear() + for (i in it.indices) { + val id = it[i].id + val item = itemModelList.find { listItem -> listItem.callerId == id } + if (item == null) { + val itemModel = ItemModel() + itemModel.name = it[i].title + itemModel.image = R.drawable.ic_call + itemModel.callerId = id + itemModel.ongoing = searchViewModel.isSpaceCallStarted() && searchViewModel.spaceCallId() == id + //add in array list + itemModelList.add(itemModel) + } + } + adapter.itemList = itemModelList + adapter.notifyDataSetChanged() + } + } + }) + + searchViewModel.searchResult.observe(viewLifecycleOwner, Observer { list -> + list?.let { + if (it.isEmpty()) { + updateEmptyListUI(true) + } else { + updateEmptyListUI(false) + itemModelList.clear() + for (i in it.indices) { + val itemModel = ItemModel() + val space = it[i] + itemModel.name = space.title.orEmpty() + itemModel.image = R.drawable.ic_call + itemModel.callerId = space.id.orEmpty() + itemModelList.add(itemModel) + } + adapter.itemList = itemModelList + adapter.notifyDataSetChanged() + } + } + }) + + searchViewModel.getSpaceEvent()?.observe(viewLifecycleOwner, Observer { + when (it.first) { + WebexRepository.SpaceEvent.CallStarted -> { + if (it.second is String?) { + val spaceId = it.second as String? + spaceId?.let { id -> + updateSpaceCallStatus(id, true) + } + } + } + WebexRepository.SpaceEvent.CallEnded -> { + if (it.second is String?) { + val spaceId = it.second as String? + spaceId?.let { id -> + updateSpaceCallStatus(id, false) + } + } + } + else -> {} + } + }) + } + + private fun updateSpaceCallStatus(spaceId: String, callStarted: Boolean) { + val index = adapter.getPositionById(spaceId) + if (index != -1) { + val model = adapter.itemList[index] + model.ongoing = callStarted + adapter.notifyItemChanged(index) + } + } + + private fun updateEmptyListUI(listEmpty: Boolean) { + progress_bar.visibility = View.GONE + if (listEmpty) { + tv_empty_data.visibility = View.VISIBLE + recycler_view.visibility = View.GONE + } else { + tv_empty_data.visibility = View.GONE + recycler_view.visibility = View.VISIBLE + } + } + + private fun updateSearchInputViewVisibility() { + when (taskType) { + TaskType.TaskSearchSpace -> { + search_view.visibility = View.VISIBLE + } + else -> { + search_view.visibility = View.GONE + } + } + } + + class ItemModel { + var image = 0 + lateinit var name: String + lateinit var callerId: String + var ongoing = false + } + + class CustomAdapter() : + RecyclerView.Adapter() { + var itemList: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, i: Int): ViewHolder { + return ViewHolder(CommonFragmentItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + viewHolder.bind(itemList[position]) + } + + override fun getItemCount(): Int { + return itemList.size + } + + inner class ViewHolder(val binding: CommonFragmentItemListBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(itemModel: ItemModel) { + binding.listItem = itemModel + binding.image.setOnClickListener { + it.context.startActivity(CallActivity.getOutgoingIntent(it.context, itemModel.callerId)) + } + + if (itemModel.ongoing) { + binding.ongoing.visibility = View.VISIBLE + } else { + binding.ongoing.visibility = View.GONE + } + binding.executePendingBindings() + } + } + + fun getPositionById(spaceId: String): Int { + return itemList.indexOfFirst { it.callerId == spaceId } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchModule.kt new file mode 100644 index 0000000..8848c5a --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchModule.kt @@ -0,0 +1,11 @@ +package com.ciscowebex.androidsdk.kitchensink.search + +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val searchModule = module { + viewModel { + SearchViewModel(get(), get(), get()) + } + single { SearchRepository(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchRepository.kt new file mode 100644 index 0000000..de90fe0 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchRepository.kt @@ -0,0 +1,35 @@ +package com.ciscowebex.androidsdk.kitchensink.search + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.space.Space +import io.reactivex.Observable +import io.reactivex.Single + +class SearchRepository(private val webex: Webex) { + + fun getCallHistory(): Observable?> { + val space = webex.phone.getCallHistory() + + return Observable.just( + space?.map { + SpaceModel(it.id.orEmpty(), it.title.orEmpty(), it.type, + it.isLocked, it.lastActivity, it.created, + it.teamId.orEmpty(), it.sipAddress.orEmpty()) + } ?: emptyList() + ) + } + + fun search(query: String): Observable> { + return Single.create> { emitter -> + webex.spaces.filter(query, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchViewModel.kt new file mode 100644 index 0000000..4016a1a --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchViewModel.kt @@ -0,0 +1,78 @@ +package com.ciscowebex.androidsdk.kitchensink.search + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import com.ciscowebex.androidsdk.space.Space +import io.reactivex.android.schedulers.AndroidSchedulers + +class SearchViewModel(private val searchRepo: SearchRepository, private val spacesRepo: SpacesRepository, private val webexRepo: WebexRepository) : BaseViewModel() { + private val tag = "SearchViewModel" + private val _spaces = MutableLiveData>() + val spaces: LiveData> = _spaces + + private val _searchResult = MutableLiveData>() + val searchResult: LiveData> = _searchResult + + private val _spaceEventLiveData = MutableLiveData>() + + val titles = + listOf("Call", "Search", "History", "Spaces") + + var name = listOf( + "Bharath Balan", + "Adam Ranganathan", + "Rohit Sharma", + "Manoj Nuthakki", + "Linda Nixon", + "Akshay Agarwal", + "Lalit Sharma", + "Manu Jain", + "Ankit Batra", + "Jasna Ibrahim" + ) + + init { + webexRepo._spaceEventLiveData = _spaceEventLiveData + } + + fun getSpaceEvent() = webexRepo._spaceEventLiveData + + fun isSpaceCallStarted() = webexRepo.isSpaceCallStarted + fun spaceCallId() = webexRepo.spaceCallId + + fun loadData(taskType: String, maxSpaceCount: Int) { + when (taskType) { + SearchCommonFragment.Companion.TaskType.TaskCallHistory -> { + searchRepo.getCallHistory().observeOn(AndroidSchedulers.mainThread()).subscribe({ + Log.d(tag, "Size of $taskType is ${it?.size?.or(0)}") + _spaces.postValue(it) + }, { + _spaces.postValue(emptyList()) + }).autoDispose() + } + SearchCommonFragment.Companion.TaskType.TaskSearchSpace -> { + search("") + } + SearchCommonFragment.Companion.TaskType.TaskListSpaces -> { + spacesRepo.fetchSpacesList(null, maxSpaceCount).observeOn(AndroidSchedulers.mainThread()).subscribe({ spacesList -> + _spaces.postValue(spacesList) + }, { _spaces.postValue(emptyList()) }).autoDispose() + } + } + } + + fun search(query: String?) { + query?.let { searchQuery -> + searchRepo.search(searchQuery).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _searchResult.postValue(it) + }, { + _searchResult.postValue(emptyList()) + }).autoDispose() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/setup/SetupActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/setup/SetupActivity.kt new file mode 100644 index 0000000..7e09918 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/setup/SetupActivity.kt @@ -0,0 +1,196 @@ +package com.ciscowebex.androidsdk.kitchensink.setup + +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.AdapterView +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivitySetupBinding +import com.ciscowebex.androidsdk.phone.Phone + +class SetupActivity: BaseActivity() { + + enum class CameraCap { + Front, + Back, + Close + } + + lateinit var binding: ActivitySetupBinding + private var cameraCap: CameraCap = CameraCap.Close + private lateinit var callCap: WebexRepository.CallCap + private lateinit var streamMode: Phone.VideoStreamMode + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "SetupActivity" + + DataBindingUtil.setContentView(this, R.layout.activity_setup) + .also { binding = it } + .apply { + cameraCap = getDefaultCamera() + + callCap = webexViewModel.callCapability + + when (callCap) { + WebexRepository.CallCap.Audio_Only -> { + audioCallOnly.isChecked = true + } + WebexRepository.CallCap.Audio_Video -> { + audioVideoCall.isChecked = true + } + } + + when (cameraCap) { + CameraCap.Front -> { + frontCamera.isChecked = true + setAndStartFrontCamera() + } + CameraCap.Back -> { + backCamera.isChecked = true + setAndStartBackCamera() + } + CameraCap.Close -> { + closePreview() + } + } + + streamMode = webexViewModel.streamMode + + when (streamMode) { + Phone.VideoStreamMode.COMPOSITED -> { + composited.isChecked = true + } + Phone.VideoStreamMode.AUXILIARY -> { + multiStream.isChecked = true + } + } + + cameraRadioGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.closePreview -> { + closePreview() + } + R.id.frontCamera -> { + setAndStartFrontCamera() + } + R.id.backCamera -> { + setAndStartBackCamera() + } + } + } + + callCapabilityRadioGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.audioCallOnly -> { + webexViewModel.callCapability = WebexRepository.CallCap.Audio_Only + } + R.id.audioVideoCall -> { + webexViewModel.callCapability = WebexRepository.CallCap.Audio_Video + } + } + } + + enableBgStreamToggle.isChecked = webexViewModel.enableBgStreamtoggle + + enableBgStreamToggle.setOnCheckedChangeListener { _, checked -> + webexViewModel.enableBgStreamtoggle = checked + webexViewModel.enableBackgroundStream(checked) + } + + streamModeRadioGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.composited -> { + webexViewModel.streamMode = Phone.VideoStreamMode.COMPOSITED + } + R.id.multiStream -> { + webexViewModel.streamMode = Phone.VideoStreamMode.AUXILIARY + } + } + + webexViewModel.setVideoStreamMode(webexViewModel.streamMode) + } + + enableBgConnectionToggle.isChecked = webexViewModel.enableBgConnectiontoggle + + enableBgConnectionToggle.setOnCheckedChangeListener { _, checked -> + webexViewModel.enableBgConnectiontoggle = checked + webexViewModel.enableBackgroundConnection(checked) + } + + enablePhonePermissionToggle.isChecked = webexViewModel.enablePhoneStatePermission + + enablePhonePermissionToggle.setOnCheckedChangeListener { _, checked -> + webexViewModel.enablePhoneStatePermission = checked + webexViewModel.enableAskingReadPhoneStatePermission(checked) + } + + logLevelSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onNothingSelected(p0: AdapterView<*>?) { + } + + override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) { + webexViewModel.logFilter = resources.getStringArray(R.array.logFilterArray)[position] + webexViewModel.setLogLevel(webexViewModel.logFilter) + Log.d(tag, "selected logLevel ${webexViewModel.logFilter}") + } + } + + logLevelSpinner.setSelection(resources.getStringArray(R.array.logFilterArray).indexOf(webexViewModel.logFilter)) + + switchConsoleLog.setOnCheckedChangeListener { _ , checked -> + webexViewModel.isConsoleLoggerEnabled = checked + webexViewModel.enableConsoleLogger(webexViewModel.isConsoleLoggerEnabled) + Log.d(tag, "enable console logger ${webexViewModel.isConsoleLoggerEnabled}") + } + switchConsoleLog.isChecked = webexViewModel.isConsoleLoggerEnabled + } + } + + private fun getDefaultCamera(): CameraCap { + if (cameraCap == CameraCap.Close) { + return cameraCap + } + + return if (webexViewModel.getDefaultFacingMode() == Phone.FacingMode.USER) { + CameraCap.Front + } else { + CameraCap.Back + } + } + + private fun closePreview() { + stopPreview() + } + + private fun setAndStartFrontCamera() { + webexViewModel.setDefaultFacingMode(Phone.FacingMode.USER) + cameraCap = CameraCap.Front + startPreview() + } + + private fun setAndStartBackCamera() { + webexViewModel.setDefaultFacingMode(Phone.FacingMode.ENVIROMENT) + cameraCap = CameraCap.Back + startPreview() + } + + private fun startPreview() { + binding.preview.visibility = View.VISIBLE + cameraCap = getDefaultCamera() + webexViewModel.startPreview(binding.preview) + } + + private fun stopPreview() { + webexViewModel.stopPreview() + binding.preview.visibility = View.GONE + } + + override fun onDestroy() { + webexViewModel.stopPreview() + super.onDestroy() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/AudioManagerUtils.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/AudioManagerUtils.kt new file mode 100644 index 0000000..f1515a7 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/AudioManagerUtils.kt @@ -0,0 +1,29 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothHeadset +import android.content.Context +import android.media.AudioManager +import android.util.Log + + +open class AudioManagerUtils(val context: Context) { + private var audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val tag = "AudioManagerUtils" + + val isWiredHeadsetOn: Boolean + get() { + val isWiredHeadsetOn = audioManager.isWiredHeadsetOn + Log.i(tag, "AudioManager.isWiredHeadsetOn = $isWiredHeadsetOn") + return isWiredHeadsetOn + } + + val isBluetoothHeadsetConnected: Boolean + get() { + val mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + val isBtConnected = (mBluetoothAdapter != null && mBluetoothAdapter.isEnabled + && mBluetoothAdapter.getProfileConnectionState(BluetoothHeadset.HEADSET) == BluetoothHeadset.STATE_CONNECTED) + Log.i(tag, "AudioManager.isBluetoothHeadsetConnected = $isBtConnected") + return isBtConnected + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Base64Utils.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Base64Utils.kt new file mode 100644 index 0000000..374d979 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Base64Utils.kt @@ -0,0 +1,17 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.util.Base64 +import android.util.Log +import com.ciscowebex.androidsdk.kitchensink.firebase.KitchenSinkFCMService + +object Base64Utils { + const val TAG = "Base64Utils" + + fun decodeString(encodedId: String?): String { + val decodedBytes: ByteArray = Base64.decode(encodedId, Base64.DEFAULT) + val decodedString = String(decodedBytes) + val decodedId = decodedString.substring(decodedString.lastIndexOf("/") + 1) + Log.d(TAG, "decodedString: $decodedString, decodedString: $decodedString, originalRoomId: $decodedId") + return decodedId + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/BindingAdapters.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/BindingAdapters.kt new file mode 100644 index 0000000..742d1a1 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/BindingAdapters.kt @@ -0,0 +1,10 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.widget.TextView +import androidx.databinding.BindingAdapter +import java.util.* + +@BindingAdapter("dateString") +fun setDateString(view: TextView, dateInLong: Long){ + view.text = Date(dateInLong).toString() +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/CallObjectStorage.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/CallObjectStorage.kt new file mode 100644 index 0000000..1ea3845 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/CallObjectStorage.kt @@ -0,0 +1,46 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import com.ciscowebex.androidsdk.phone.Call + +object CallObjectStorage { + private var callObjects: ArrayList = ArrayList() + + fun addCallObject(call: Call) { + synchronized(this) { + val callObj = getCallObject(call.getCallId() ?: "") + if (callObj == null) { + callObjects.add(call) + } + } + } + + fun removeCallObject(callId: String) { + synchronized(this) { + val itr = callObjects.iterator() + while (itr.hasNext()) { + val call = itr.next() + if (call.getCallId() == callId) { + itr.remove() + } + } + } + } + + fun getCallObject(callId: String): Call? { + synchronized(this) { + for (call in callObjects) { + if (call.getCallId() == callId) { + return call + } + } + + return null + } + } + + fun clearStorage() { + synchronized(this) { + callObjects.clear() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Constants.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Constants.kt new file mode 100644 index 0000000..b7c36ae --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Constants.kt @@ -0,0 +1,36 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +class Constants { + object Intent { + const val PERSON = "PERSON" + const val OUTGOING_CALL_CALLER_ID = "OUTGOING_CALL_CALLER_ID" + const val CALLING_ACTIVITY_ID = "CALLING_ACTIVITY_ID" + const val TEAM_ID = "teamId" + const val SPACE_ID = "spaceId" + const val COMPOSER_ID = "composerId" + const val COMPOSER_TYPE = "composerType" + const val COMPOSER_REPLY_PARENT_MESSAGE = "composerReplyParentMessage" + const val CALL_ID = "callid" + const val MESSAGE_ID = "MESSAGE_ID" + } + object Bundle { + const val MESSAGE_ID = "messageId" + const val PERSON_ID = "person_id" + const val KEY_TASK_TYPE = "task_type" + const val SPACE_ID = "spaceId" + const val IS_CALLING_ENABLED = "isCallingEnabled" + const val IS_MESSAGING_ENABLED = "isMessagingEnabled" + const val TEAM_ID = "teamId" + const val REMOTE_FILE = "remote_file" + } + object Action { + const val MESSAGE_ACTION = "MESSAGE_ACTION" + const val WEBEX_CALL_ACTION = "WEBEX_CALL_ACTION" + } + object Keys { + const val PushRestEncryptionKey = "PeShVmYq3s6v9yaBwE1H3McQfTjWnZr4" //256 bit AES key, use base64 encoded key to send to cucm endpoint + const val KitchenSinkSharedPref = "KSSharedPref" + const val LoginType = "LoginType" + const val Email = "Email" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Crypto.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Crypto.kt new file mode 100644 index 0000000..42904ef --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Crypto.kt @@ -0,0 +1,39 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWEHeader +import com.nimbusds.jose.JWEObject +import com.nimbusds.jose.Payload +import com.nimbusds.jose.crypto.DirectDecrypter +import com.nimbusds.jose.crypto.DirectEncrypter + +/** + * @param payload : The payload to encrypt, can be any string. + * @param encryptionKey : Symmetric encryption key to ecrypt the payload. Use 256 bit symmetric key for AES256GCM encryption algo + * Dummy key for example usage: "@McQfTjWnZr4u7x!A%D*G-KaNdRgUkXp" + */ +fun encryptPushRESTPayload(payload: String, encryptionKey: String = Constants.Keys.PushRestEncryptionKey): String { + // Create the header + val header = JWEHeader(JWEAlgorithm.DIR, EncryptionMethod.A256GCM) + + val keyBA = encryptionKey.toByteArray() + // Create the JWE object and encrypt it + val jweObject = JWEObject(header, Payload(payload)) + jweObject.encrypt(DirectEncrypter(keyBA)) + + // Serialise to compact JOSE form... + return jweObject.serialize() +} + +/** + * @param payload : JWE format string (https://tools.ietf.org/html/rfc7516) + * @param decryptionKey : AES 256 bit symmetric key. This is the same encryption key as was used to ecrypt the payload, + * As we use dir algorithm, so both encryption/decryption keys are same here. + */ +fun decryptPushRESTPayload(payload: String, decryptionKey: String = Constants.Keys.PushRestEncryptionKey): String { + val jweObject = JWEObject.parse(payload) + jweObject.decrypt(DirectDecrypter(decryptionKey.toByteArray())) + val decryptedPayload = jweObject.payload + return decryptedPayload.toString() +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/DialogUtils.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/DialogUtils.kt new file mode 100644 index 0000000..2adddcf --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/DialogUtils.kt @@ -0,0 +1,51 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.content.Context +import android.content.DialogInterface +import android.text.InputType +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import com.ciscowebex.androidsdk.kitchensink.R + + +fun showDialogWithMessage(context: Context, titleResourceId: Int?, message: String, positiveButtonText: Int = android.R.string.ok) { + val builder: AlertDialog.Builder = AlertDialog.Builder(context) + + builder.setTitle(titleResourceId ?: R.string.message) + builder.setMessage(message) + + builder.setPositiveButton(positiveButtonText) { dialog, _ -> + dialog.dismiss() + } + + builder.show() +} + +fun showDialogWithMessage(context: Context, title: String, message: String, positiveButtonText: Int = R.string.yes, cancelable: Boolean = true, + onPositiveButtonClick: (DialogInterface, Int) -> Unit, negativeButtonText: Int = R.string.no, onNegativeButtonClick: (DialogInterface, Int) -> Unit) { + + AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setCancelable(cancelable) + .setPositiveButton(positiveButtonText, onPositiveButtonClick) + .setNegativeButton(negativeButtonText, onNegativeButtonClick) + .show() +} + +fun showDialogForInputEmail(context: Context, title: String, positiveButtonText: Int = android.R.string.ok, + onPositiveButtonClick: (DialogInterface, String) -> Unit, negativeButtonText: Int = android.R.string.cancel, + onNegativeButtonClick: (DialogInterface, Int) -> Unit) { + val input = EditText(context) + input.inputType = InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS + AlertDialog.Builder(context) + .setTitle(title) + .setView(input) + .setPositiveButton(positiveButtonText) { dialogInterface: DialogInterface, i: Int -> + val email = input.text.toString(); + onPositiveButtonClick(dialogInterface, email) + } + .setNegativeButton(negativeButtonText, onNegativeButtonClick) + .show() +} + diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/FileUtils.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/FileUtils.kt new file mode 100644 index 0000000..3747c7d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/FileUtils.kt @@ -0,0 +1,127 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.util.Log +import com.ciscowebex.androidsdk.kitchensink.BuildConfig +import java.io.File + + +object FileUtils { + fun getThumbnailFile(context: Context): File { + val dirPath = context.cacheDir.absolutePath + File.separator + "Thumbnail" + File.separator + val dir = File(dirPath) + if (!dir.exists() && !dir.isDirectory) { + dir.mkdirs() + } + Log.d("FileUtils", dir.absolutePath) + return dir + } + + fun getFile(context: Context): File { + val dirPath = context.cacheDir.absolutePath + File.separator + "Files" + File.separator + val dir = File(dirPath) + if (!dir.exists() && !dir.isDirectory) { + dir.mkdirs() + } + Log.d("FileUtils", dir.absolutePath) + return dir + } + + fun getUploadUriPath(context: Context, uri: Uri): String? { + if (DocumentsContract.isDocumentUri(context, uri)) { + // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).toTypedArray() + val type = split[0] + if ("primary".equals(type, ignoreCase = true)) { + return getExternalFilesDirPath(context)?.let { it + "/" + split[1] } + } + } else if (isDownloadsDocument(uri)) { + val id = DocumentsContract.getDocumentId(uri) + val contentUri: Uri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id)) + return getDataColumn(context, contentUri, null, null) + } else if (isMediaDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).toTypedArray() + val type = split[0] + var contentUri: Uri? = null + if ("image" == type) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } else if ("video" == type) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else if ("audio" == type) { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + } + val selection = "_id=?" + val selectionArgs = arrayOf(split[1]) + return getDataColumn(context, contentUri, selection, selectionArgs) + } + } + return null + } + + private fun getExternalFilesDirPath(context: Context): String? { + val dir = context.getExternalFilesDir(null) + dir?.let { + val extraPortion = "/Android/data/" + BuildConfig.APPLICATION_ID + File.separator.toString() + "files" + return it.absolutePath.replace(extraPortion, "", true) + } + + return null + } + + private fun getDataColumn(context: Context, uri: Uri?, selection: String?, selectionArgs: Array?): String? { + var cursor: Cursor? = null + val column = "_data" + val projection = arrayOf(column) + try { + uri?.let { + cursor = context.contentResolver.query(it, projection, selection, selectionArgs, null) + cursor?.let { cur -> + if (cur.moveToFirst()) { + val index: Int = cur.getColumnIndexOrThrow(column) + return cur.getString(index) + } + } + } + } finally { + cursor?.close() + } + return null + } + + private fun isExternalStorageDocument(uri: Uri): Boolean { + return "com.android.externalstorage.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is DownloadsProvider. + */ + private fun isDownloadsDocument(uri: Uri): Boolean { + return "com.android.providers.downloads.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is MediaProvider. + */ + private fun isMediaDocument(uri: Uri): Boolean { + return "com.android.providers.media.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is Google Photos. + */ + private fun isGooglePhotosUri(uri: Uri): Boolean { + return "com.google.android.apps.photos.content" == uri.authority + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/HorizontalFlipTransformation.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/HorizontalFlipTransformation.kt new file mode 100644 index 0000000..fe0f6e8 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/HorizontalFlipTransformation.kt @@ -0,0 +1,29 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.view.View +import androidx.viewpager2.widget.ViewPager2 +import kotlin.math.abs + + +class HorizontalFlipTransformation : ViewPager2.PageTransformer { + override fun transformPage(page: View, position: Float) { + page.translationX = -position * page.width + page.cameraDistance = 12000F + if (position < 0.5 && position > -0.5) { + page.visibility = View.VISIBLE + } else { + page.visibility = View.INVISIBLE + } + if (position < -1) { // [-Infinity,-1) + page.alpha = 0F + } else if (position <= 0) { // [-1,0] + page.alpha = 1F + page.rotationY = 180 * (1 - abs(position) + 1) + } else if (position <= 1) { // (0,1] + page.alpha = 1F + page.rotationY = -180 * (1 - abs(position) + 1) + } else { + page.alpha = 0F + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/PermissionsHelper.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/PermissionsHelper.kt new file mode 100644 index 0000000..bb7e5cb --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/PermissionsHelper.kt @@ -0,0 +1,67 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.widget.Toast +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat + +class PermissionsHelper(private val context: Context) { + + fun hasCameraPermission(): Boolean { + return checkSelfPermission(Manifest.permission.CAMERA) + } + + fun hasMicrophonePermission(): Boolean { + return checkSelfPermission(Manifest.permission.RECORD_AUDIO) + } + + fun hasPhoneStatePermission(): Boolean { + return checkSelfPermission(Manifest.permission.READ_PHONE_STATE) + } + + fun hasReadStoragePermission(): Boolean { + return checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) + } + + private fun checkSelfPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + + companion object { + const val PERMISSIONS_CALLING_REQUEST = 0 + const val PERMISSIONS_STORAGE_REQUEST = 1 + + fun permissionsForCalling(): Array { + return arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO, Manifest.permission.READ_PHONE_STATE) + } + + fun permissionForStorage(): Array { + return arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + + fun resultForCallingPermissions(permissions: Array, grantResults: IntArray): Boolean { + var result = true + + for (permission in permissions) { + result = result and checkPermissionsResults(permission, permissions, grantResults) + } + + return result + } + + private fun checkPermissionsResults(permissionRequested: String, permissions: Array, grantResults: IntArray): Boolean { + for (index in permissions.indices) { + val permission = permissions[index] + val grantResult = grantResults[index] + + if (permissionRequested == permission) { + return grantResult == PackageManager.PERMISSION_GRANTED + } + } + + return false + } + } +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/SharedPrefUtils.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/SharedPrefUtils.kt new file mode 100644 index 0000000..461d61d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/SharedPrefUtils.kt @@ -0,0 +1,52 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.content.Context +import com.ciscowebex.androidsdk.kitchensink.auth.LoginActivity + +object SharedPrefUtils { + fun saveLoginTypePref(context:Context, type: LoginActivity.LoginType) { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + + pref?.let { + it.edit().putString(Constants.Keys.LoginType, type.value).apply() + } + } + + fun clearLoginTypePref(context:Context) { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + + pref?.let { + it.edit().remove(Constants.Keys.LoginType).apply() + } + } + + fun getLoginTypePref(context:Context): String? { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + + pref?.let { + return pref.getString(Constants.Keys.LoginType, null) + } + + return null + } + + fun saveEmailPref(context:Context, email: String) { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + pref?.edit()?.putString(Constants.Keys.Email, email)?.apply() + } + + fun clearEmailPref(context:Context) { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + pref?.edit()?.remove(Constants.Keys.Email)?.apply() + } + + fun getEmailPref(context:Context): String? { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + + pref?.let { + return pref.getString(Constants.Keys.Email, null) + } + + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/StringExtension.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/StringExtension.kt new file mode 100644 index 0000000..f20501a --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/StringExtension.kt @@ -0,0 +1,13 @@ +package com.ciscowebex.androidsdk.kitchensink.utils.extensions + +import android.util.Patterns + +fun CharSequence?.isValidEmail() = !isNullOrEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches() + + +/** + * Converts offset to the equivalent offset into this String encoded as UTF-8. + */ +fun String.utf8Offset(offset: Int): Int { + return substring(0, offset).toByteArray().size +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/ViewExtension.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/ViewExtension.kt new file mode 100644 index 0000000..d09ad59 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/ViewExtension.kt @@ -0,0 +1,19 @@ +package com.ciscowebex.androidsdk.kitchensink.utils.extensions + +import android.app.Activity +import android.content.Context +import android.util.Log +import android.view.View +import android.view.inputmethod.InputMethodManager + +fun View.showKeyboard() { + val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0) + Log.d("ViewExtensions", "showKeyboard()") +} + +fun Context.hideKeyboard(view: View) { + val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) + Log.d("ViewExtensions", "hideKeyboard()") +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookActionBottomSheetFragment.kt new file mode 100644 index 0000000..6285ab4 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookActionBottomSheetFragment.kt @@ -0,0 +1,42 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetWebhookActionBinding +import com.ciscowebex.androidsdk.webhook.Webhook +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + + +class WebhookActionBottomSheetFragment(val getDetails: (String) -> Unit, + val delete: (String) -> Unit, + val update: (String, Webhook?) -> Unit) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetWebhookActionBinding + lateinit var webhookId: String + var webhookModel: Webhook? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetWebhookActionBinding.inflate(inflater, container, false).also { binding = it }.apply { + + webhookGetDetails.setOnClickListener { + dismiss() + getDetails(webhookId) + } + + webhookDelete.setOnClickListener { + dismiss() + delete(webhookId) + } + + webhookUpdate.setOnClickListener { + dismiss() + update(webhookId, webhookModel) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksActivity.kt new file mode 100644 index 0000000..b45314b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksActivity.kt @@ -0,0 +1,216 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import android.os.Bundle +import android.text.Editable +import android.util.Log +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityWebhooksBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogWebhookCreateBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogWebhookUpdateBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentDialogWebhookDetailsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemWebhookBinding +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import com.ciscowebex.androidsdk.webhook.Webhook +import org.koin.android.ext.android.inject + + +class WebhooksActivity : AppCompatActivity() { + var tag = "WebhooksActivity" + private lateinit var binding: ActivityWebhooksBinding + private lateinit var webhookAdapter: WebhookListAdapter + private val webhookModel : WebhooksViewModel by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_webhooks) + .also { binding = it } + .apply { + val optionsDialogFragment = WebhookActionBottomSheetFragment ( + { webhookId -> webhookModel.get(webhookId)}, + { webhookId -> webhookModel.delete(webhookId)}, + { webhookId, model -> updateWebhookDialog(webhookId, model)}) + + swipeContainer.setOnRefreshListener { + updateList() + } + + addWebhookButton.setOnClickListener { + createWebhookDialog() + } + + val dividerItemDecoration = DividerItemDecoration(this@WebhooksActivity, LinearLayoutManager.VERTICAL) + webhookRecyclerView.addItemDecoration(dividerItemDecoration) + webhookAdapter = WebhookListAdapter(optionsDialogFragment, supportFragmentManager) + webhookRecyclerView.adapter = webhookAdapter + + webhookModel.webhooksList.observe(this@WebhooksActivity, Observer { + it?.let { + swipeContainer.isRefreshing = false + webhookAdapter.webhookList.clear() + webhookAdapter.webhookList.addAll(it) + webhookAdapter.notifyDataSetChanged() + } + }) + + webhookModel.webhooksError.observe(this@WebhooksActivity, Observer { + it?.let { + showDialogWithMessage(this@WebhooksActivity, R.string.webhook_error, it) + } ?: run { + showDialogWithMessage(this@WebhooksActivity, R.string.webhook_error, "") + } + }) + + webhookModel.webhookData.observe(this@WebhooksActivity, Observer { + it?.let { + when (WebhooksViewModel.WebhookEvent.valueOf(it.first.name)) { + WebhooksViewModel.WebhookEvent.CREATE -> { + Log.d(tag, "WebhookEvent.CREATE") + updateList() + } + WebhooksViewModel.WebhookEvent.GET -> { + Log.d(tag, "WebhookEvent.GET") + webhookDetails(it.second) + } + WebhooksViewModel.WebhookEvent.UPDATE -> { + Log.d(tag, "WebhookEvent.UPDATE") + webhookDetails(it.second) + } + } + } + }) + + webhookModel.deleteWebhook.observe(this@WebhooksActivity, Observer { delete -> + delete?.let { + updateList() + } + }) + + } + } + + private fun updateList() { + webhookModel.list(resources.getInteger(R.integer.webhook_list_max)) + } + + private fun createWebhookDialog() { + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + + DialogWebhookCreateBinding.inflate(layoutInflater) + .apply { + builder.setView(this.root) + + builder.setPositiveButton(getString(R.string.webhook_create)) { dialog, _ -> + val name = nameEditText.text.toString() + val targetUrl = targetUrlEditText.text.toString() + val resource = resourceEditText.text.toString() + val event = eventEditText.text.toString() + val filter: String? = if (filterEditText.text.isNotEmpty()) filterEditText.text.toString() else null + val secret: String? = if (secretEditText.text.isNotEmpty()) secretEditText.text.toString() else null + + webhookModel.create(name, targetUrl, resource, event, filter, secret) + dialog.dismiss() + } + + builder.show() + } + } + + private fun updateWebhookDialog(webhookId: String, model: Webhook?) { + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + + DialogWebhookUpdateBinding.inflate(layoutInflater) + .apply { + builder.setView(this.root) + + model?.let { webhook -> + nameEditText.text = Editable.Factory.getInstance().newEditable(webhook.name) + targetUrlEditText.text = Editable.Factory.getInstance().newEditable(webhook.targetUrl) + + webhook.status?.let { + statusEditText.text = Editable.Factory.getInstance().newEditable(webhook.status) + } + + webhook.secret?.let { + secretEditText.text = Editable.Factory.getInstance().newEditable(webhook.secret) + } + } + + builder.setPositiveButton(getString(R.string.webhook_update)) { dialog, _ -> + + val name = nameEditText.text.toString() + val targetUrl = targetUrlEditText.text.toString() + val status: String? = if (statusEditText.text.isNotEmpty()) statusEditText.text.toString() else null + val secret: String? = if (secretEditText.text.isNotEmpty()) secretEditText.text.toString() else null + + webhookModel.update(webhookId, name, targetUrl, secret, status) + dialog.dismiss() + } + + builder.show() + } + } + + private fun webhookDetails(_webhook: Webhook) { + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + + FragmentDialogWebhookDetailsBinding.inflate(layoutInflater) + .apply { + webhook = _webhook + + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + updateList() + dialog.dismiss() + } + + builder.show() + } + } + + class WebhookListAdapter(private val optionsDialogFragment: WebhookActionBottomSheetFragment, private val fragmentManager: FragmentManager) : RecyclerView.Adapter() { + var webhookList: MutableList = mutableListOf() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding = ListItemWebhookBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return webhookViewHolder(binding, optionsDialogFragment, fragmentManager) + } + + override fun getItemCount(): Int { + return webhookList.size + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as webhookViewHolder).bind(webhookList[position]) + } + + inner class webhookViewHolder(private val binding: ListItemWebhookBinding, private val optionsDialogFragment: WebhookActionBottomSheetFragment, private val fragmentManager: FragmentManager): RecyclerView.ViewHolder(binding.root) { + var webhook: Webhook? = null + + init { + binding.rootListItemLayout.setOnLongClickListener { _ -> + optionsDialogFragment.webhookId = webhook?.id ?: "" + optionsDialogFragment.webhookModel = webhook + + optionsDialogFragment.show(fragmentManager, "People Options") + + true + } + } + + fun bind(webhook: Webhook) { + this.webhook = webhook + binding.name.text = webhook.name + binding.path.text = webhook.targetUrl + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksModule.kt new file mode 100644 index 0000000..57f842b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksModule.kt @@ -0,0 +1,13 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import com.ciscowebex.androidsdk.kitchensink.person.PersonRepository +import com.ciscowebex.androidsdk.kitchensink.person.PersonViewModel +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + + +val webhooksModule = module { + single { WebhooksRepository(get()) } + + viewModel { WebhooksViewModel(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksRepository.kt new file mode 100644 index 0000000..c115e86 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksRepository.kt @@ -0,0 +1,91 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.webhook.Webhook +import io.reactivex.Observable +import io.reactivex.Single + + +class WebhooksRepository(private val webex: Webex) { + + fun list(max: Int) : Observable> { + return Single.create> { emitter -> + webex.webhooks.list(max, CompletionHandler { result -> + if (result.isSuccessful) { + val webhooksList = result.data + webhooksList?.let { + emitter.onSuccess(it) + } ?: run { + emitter.onSuccess(emptyList()) + } + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun create(name: String, targetUrl: String, resource: String, event: String, filter: String?, secret: String?) : Observable { + return Single.create { emitter -> + webex.webhooks.create(name, targetUrl, resource, event, filter, secret, CompletionHandler { result -> + if (result.isSuccessful) { + val webhook = result.data + webhook?.let { + emitter.onSuccess(it) + } ?: run { + emitter.onError(Throwable("")) + } + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun get(webhookId: String) : Observable { + return Single.create { emitter -> + webex.webhooks.get(webhookId, CompletionHandler { result -> + if (result.isSuccessful) { + val webhook = result.data + webhook?.let { + emitter.onSuccess(it) + } ?: run { + emitter.onError(Throwable("")) + } + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun update(webhookId: String, name: String, targetUrl: String, secret: String?, status: String?) : Observable { + return Single.create { emitter -> + webex.webhooks.update(webhookId, name, targetUrl, secret, status, CompletionHandler { result -> + if (result.isSuccessful) { + val webhook = result.data + webhook?.let { + emitter.onSuccess(it) + } ?: run { + emitter.onError(Throwable("")) + } + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun delete(webhookId: String) : Observable { + return Single.create { emitter -> + webex.webhooks.delete(webhookId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksViewModel.kt new file mode 100644 index 0000000..6874c30 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksViewModel.kt @@ -0,0 +1,60 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.webhook.Webhook +import io.reactivex.android.schedulers.AndroidSchedulers + + +class WebhooksViewModel(private val webhookRepo: WebhooksRepository) : BaseViewModel() { + private val tag = "WebhooksViewModel" + + enum class WebhookEvent { + CREATE, + GET, + UPDATE + } + + private val _webhooksList = MutableLiveData>() + val webhooksList: LiveData> = _webhooksList + + private val _webhooksError = MutableLiveData() + val webhooksError: LiveData = _webhooksError + + private val _webhookData = MutableLiveData>() + val webhookData: LiveData> = _webhookData + + private val _deleteWebhook = MutableLiveData() + val deleteWebhook: LiveData = _deleteWebhook + + fun list(max: Int) { + webhookRepo.list(max).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _webhooksList.postValue(it) + }, { error -> _webhooksError.postValue(error.message) }).autoDispose() + } + + fun create(name: String, targetUrl: String, resource: String, event: String, filter: String?, secret: String?) { + webhookRepo.create(name, targetUrl, resource, event, filter, secret).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _webhookData.postValue(Pair(WebhookEvent.CREATE, it)) + }, { error -> _webhooksError.postValue(error.message) }).autoDispose() + } + + fun get(webhookId: String) { + webhookRepo.get(webhookId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _webhookData.postValue(Pair(WebhookEvent.GET, it)) + }, { error -> _webhooksError.postValue(error.message) }).autoDispose() + } + + fun update(webhookId: String, name: String, targetUrl: String, secret: String?, status: String?) { + webhookRepo.update(webhookId, name, targetUrl, secret, status).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _webhookData.postValue(Pair(WebhookEvent.UPDATE, it)) + }, { error -> _webhooksError.postValue(error.message) }).autoDispose() + } + + fun delete(webhookId: String) { + webhookRepo.delete(webhookId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _deleteWebhook.postValue(it) + }, { error -> _webhooksError.postValue(error.message) }).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/res/anim/calling_animation.xml b/app/src/main/res/anim/calling_animation.xml new file mode 100644 index 0000000..5143328 --- /dev/null +++ b/app/src/main/res/anim/calling_animation.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/anim/cycle_animation.xml b/app/src/main/res/anim/cycle_animation.xml new file mode 100644 index 0000000..a697df1 --- /dev/null +++ b/app/src/main/res/anim/cycle_animation.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_notification_icon.png b/app/src/main/res/drawable/app_notification_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..236246231d61823db4958e77462e6664ee51539d GIT binary patch literal 2704 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 KMNUMnLSTaNGa}gl literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/audio_button.xml b/app/src/main/res/drawable/audio_button.xml new file mode 100644 index 0000000..d7fdb15 --- /dev/null +++ b/app/src/main/res/drawable/audio_button.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/audio_mute_active.xml b/app/src/main/res/drawable/audio_mute_active.xml new file mode 100644 index 0000000..4450ab4 --- /dev/null +++ b/app/src/main/res/drawable/audio_mute_active.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/audio_mute_default.xml b/app/src/main/res/drawable/audio_mute_default.xml new file mode 100644 index 0000000..0fd9302 --- /dev/null +++ b/app/src/main/res/drawable/audio_mute_default.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/audio_mute_disable.xml b/app/src/main/res/drawable/audio_mute_disable.xml new file mode 100644 index 0000000..d312fd0 --- /dev/null +++ b/app/src/main/res/drawable/audio_mute_disable.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_gray_border.xml b/app/src/main/res/drawable/bg_gray_border.xml new file mode 100644 index 0000000..98cf7fd --- /dev/null +++ b/app/src/main/res/drawable/bg_gray_border.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_blue.xml b/app/src/main/res/drawable/circle_filled_blue.xml new file mode 100644 index 0000000..d84788d --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_blue.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_dark_gray.xml b/app/src/main/res/drawable/circle_filled_dark_gray.xml new file mode 100644 index 0000000..ad4c447 --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_dark_gray.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_deep_yellow.xml b/app/src/main/res/drawable/circle_filled_deep_yellow.xml new file mode 100644 index 0000000..2615605 --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_deep_yellow.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_gray.xml b/app/src/main/res/drawable/circle_filled_gray.xml new file mode 100644 index 0000000..92190dd --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_gray.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_green.xml b/app/src/main/res/drawable/circle_filled_green.xml new file mode 100644 index 0000000..3fb728f --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_green.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_red.xml b/app/src/main/res/drawable/circle_filled_red.xml new file mode 100644 index 0000000..c561208 --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_red.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_yellow.xml b/app/src/main/res/drawable/circle_filled_yellow.xml new file mode 100644 index 0000000..895dabf --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_yellow.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dialog_bg.xml b/app/src/main/res/drawable/dialog_bg.xml new file mode 100644 index 0000000..c238783 --- /dev/null +++ b/app/src/main/res/drawable/dialog_bg.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/edit_text_bg.xml b/app/src/main/res/drawable/edit_text_bg.xml new file mode 100644 index 0000000..e82dd7b --- /dev/null +++ b/app/src/main/res/drawable/edit_text_bg.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/google_contacts_android.png b/app/src/main/res/drawable/google_contacts_android.png new file mode 100644 index 0000000000000000000000000000000000000000..224ea8149acb56fb522a1cc480a21404c8ed07b0 GIT binary patch literal 32790 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..43aecde --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_24.xml b/app/src/main/res/drawable/ic_arrow_24.xml new file mode 100644 index 0000000..7ae8672 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_attachment_cancel.xml b/app/src/main/res/drawable/ic_attachment_cancel.xml new file mode 100644 index 0000000..352a5ca --- /dev/null +++ b/app/src/main/res/drawable/ic_attachment_cancel.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_backspace.xml b/app/src/main/res/drawable/ic_backspace.xml new file mode 100644 index 0000000..54b2a22 --- /dev/null +++ b/app/src/main/res/drawable/ic_backspace.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml new file mode 100644 index 0000000..eb23254 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_close_24.xml b/app/src/main/res/drawable/ic_baseline_close_24.xml new file mode 100644 index 0000000..0ce140a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_close_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_btn_camera_swap_40.xml b/app/src/main/res/drawable/ic_btn_camera_swap_40.xml new file mode 100644 index 0000000..e92ce6e --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_camera_swap_40.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_call.xml b/app/src/main/res/drawable/ic_call.xml new file mode 100644 index 0000000..d323c9f --- /dev/null +++ b/app/src/main/res/drawable/ic_call.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_call_hold.xml b/app/src/main/res/drawable/ic_call_hold.xml new file mode 100644 index 0000000..794ffcc --- /dev/null +++ b/app/src/main/res/drawable/ic_call_hold.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_call_merge_24.xml b/app/src/main/res/drawable/ic_call_merge_24.xml new file mode 100644 index 0000000..ad5ecda --- /dev/null +++ b/app/src/main/res/drawable/ic_call_merge_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_call_transfer.xml b/app/src/main/res/drawable/ic_call_transfer.xml new file mode 100644 index 0000000..cc38ed2 --- /dev/null +++ b/app/src/main/res/drawable/ic_call_transfer.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_call_waiting.xml b/app/src/main/res/drawable/ic_call_waiting.xml new file mode 100644 index 0000000..97859b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_call_waiting.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_call_white.xml b/app/src/main/res/drawable/ic_call_white.xml new file mode 100644 index 0000000..c9dfb99 --- /dev/null +++ b/app/src/main/res/drawable/ic_call_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_camera_24.xml b/app/src/main/res/drawable/ic_camera_24.xml new file mode 100644 index 0000000..5911166 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_cancel.xml b/app/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 0000000..aca2682 --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_cisco_gray_logo.png b/app/src/main/res/drawable/ic_cisco_gray_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e00a156c1f57edbc7d2fbd55ad55704f2bdff3da GIT binary patch literal 8933 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 + + diff --git a/app/src/main/res/drawable/ic_feeback.xml b/app/src/main/res/drawable/ic_feeback.xml new file mode 100644 index 0000000..b9da529 --- /dev/null +++ b/app/src/main/res/drawable/ic_feeback.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_incoming_call_legacy_36.xml b/app/src/main/res/drawable/ic_incoming_call_legacy_36.xml new file mode 100644 index 0000000..85b7719 --- /dev/null +++ b/app/src/main/res/drawable/ic_incoming_call_legacy_36.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard.xml b/app/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 0000000..0ac5368 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_login.xml b/app/src/main/res/drawable/ic_login.xml new file mode 100644 index 0000000..0cdb259 --- /dev/null +++ b/app/src/main/res/drawable/ic_login.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..abc88eb --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000..4350ba9 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_message.xml b/app/src/main/res/drawable/ic_message.xml new file mode 100644 index 0000000..af796a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_message.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_24.xml b/app/src/main/res/drawable/ic_mic_24.xml new file mode 100644 index 0000000..554fe75 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_off_24.xml b/app/src/main/res/drawable/ic_mic_off_24.xml new file mode 100644 index 0000000..e29527b --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_off_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 0000000..88a14ad --- /dev/null +++ b/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outgoing_call.xml b/app/src/main/res/drawable/ic_outgoing_call.xml new file mode 100644 index 0000000..84497d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_outgoing_call.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_participant_add.xml b/app/src/main/res/drawable/ic_participant_add.xml new file mode 100644 index 0000000..f91af11 --- /dev/null +++ b/app/src/main/res/drawable/ic_participant_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_participant_list.xml b/app/src/main/res/drawable/ic_participant_list.xml new file mode 100644 index 0000000..0f10684 --- /dev/null +++ b/app/src/main/res/drawable/ic_participant_list.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_people_24.xml b/app/src/main/res/drawable/ic_people_24.xml new file mode 100644 index 0000000..bee216c --- /dev/null +++ b/app/src/main/res/drawable/ic_people_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..bd8dc80 --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_remote_end.xml b/app/src/main/res/drawable/ic_remote_end.xml new file mode 100644 index 0000000..8efe293 --- /dev/null +++ b/app/src/main/res/drawable/ic_remote_end.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_remote_mute.xml b/app/src/main/res/drawable/ic_remote_mute.xml new file mode 100644 index 0000000..a208ce1 --- /dev/null +++ b/app/src/main/res/drawable/ic_remote_mute.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_reply.xml b/app/src/main/res/drawable/ic_reply.xml new file mode 100644 index 0000000..f5f6230 --- /dev/null +++ b/app/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_screen_share.xml b/app/src/main/res/drawable/ic_screen_share.xml new file mode 100644 index 0000000..e5e7e5d --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_share.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_screen_sharing.xml b/app/src/main/res/drawable/ic_screen_sharing.xml new file mode 100644 index 0000000..cb6b4f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_sharing.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_speaker.xml b/app/src/main/res/drawable/ic_speaker.xml new file mode 100644 index 0000000..7c8e50e --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_turn_off_video.xml b/app/src/main/res/drawable/ic_turn_off_video.xml new file mode 100644 index 0000000..e703d40 --- /dev/null +++ b/app/src/main/res/drawable/ic_turn_off_video.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/remote_video_view_border.xml b/app/src/main/res/drawable/remote_video_view_border.xml new file mode 100644 index 0000000..ca99097 --- /dev/null +++ b/app/src/main/res/drawable/remote_video_view_border.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/screen_sharing_active.xml b/app/src/main/res/drawable/screen_sharing_active.xml new file mode 100644 index 0000000..d71ff32 --- /dev/null +++ b/app/src/main/res/drawable/screen_sharing_active.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/screen_sharing_default.xml b/app/src/main/res/drawable/screen_sharing_default.xml new file mode 100644 index 0000000..a565f68 --- /dev/null +++ b/app/src/main/res/drawable/screen_sharing_default.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/speaker_button_selector.xml b/app/src/main/res/drawable/speaker_button_selector.xml new file mode 100644 index 0000000..c928087 --- /dev/null +++ b/app/src/main/res/drawable/speaker_button_selector.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/surfaceview_border.xml b/app/src/main/res/drawable/surfaceview_border.xml new file mode 100644 index 0000000..15623a2 --- /dev/null +++ b/app/src/main/res/drawable/surfaceview_border.xml @@ -0,0 +1,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/surfaceview_transparent_border.xml b/app/src/main/res/drawable/surfaceview_transparent_border.xml new file mode 100644 index 0000000..e125d10 --- /dev/null +++ b/app/src/main/res/drawable/surfaceview_transparent_border.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/turn_off_video_active.xml b/app/src/main/res/drawable/turn_off_video_active.xml new file mode 100644 index 0000000..4a8f11b --- /dev/null +++ b/app/src/main/res/drawable/turn_off_video_active.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/turn_on_video_default.xml b/app/src/main/res/drawable/turn_on_video_default.xml new file mode 100644 index 0000000..6ab759d --- /dev/null +++ b/app/src/main/res/drawable/turn_on_video_default.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webex_teams_logo.png b/app/src/main/res/drawable/webex_teams_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..29cf4deae1172fec940dd37a4b9bec229329e322 GIT binary patch literal 20935 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 JQKe=d`9CJwxjFy< literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/activity_call.xml b/app/src/main/res/layout/activity_call.xml new file mode 100644 index 0000000..056f8c2 --- /dev/null +++ b/app/src/main/res/layout/activity_call.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_cucm_login.xml b/app/src/main/res/layout/activity_cucm_login.xml new file mode 100644 index 0000000..d4d5d6f --- /dev/null +++ b/app/src/main/res/layout/activity_cucm_login.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_dialer.xml b/app/src/main/res/layout/activity_dialer.xml new file mode 100644 index 0000000..b69b4d1 --- /dev/null +++ b/app/src/main/res/layout/activity_dialer.xml @@ -0,0 +1,34 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_extras.xml b/app/src/main/res/layout/activity_extras.xml new file mode 100644 index 0000000..313af39 --- /dev/null +++ b/app/src/main/res/layout/activity_extras.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + +