From 9c961bd93173df0c120341d690a8fe123b59310b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrien=20B=C3=A9raud?= <adrien.beraud@savoirfairelinux.com> Date: Wed, 18 Aug 2021 11:51:11 -0400 Subject: [PATCH] Add Hilt, Kotlin support, swarm fixes Change-Id: I7c6a81af01911fa5db97bef7677074398df9d3b8 --- ring-android/app/build.gradle | 35 +- ring-android/app/proguard-rules.pro | 10 +- ring-android/app/src/main/AndroidManifest.xml | 10 +- .../about/AboutBottomSheetDialogFragment.java | 59 - .../about/AboutBottomSheetDialogFragment.kt | 50 + .../java/cx/ring/about/AboutFragment.java | 114 - .../main/java/cx/ring/about/AboutFragment.kt | 103 + .../ring/account/AccountEditionFragment.java | 348 --- .../cx/ring/account/AccountEditionFragment.kt | 287 +++ .../ring/account/AccountWizardActivity.java | 275 --- .../cx/ring/account/AccountWizardActivity.kt | 241 +++ .../java/cx/ring/account/DeviceAdapter.java | 122 -- .../java/cx/ring/account/DeviceAdapter.kt | 91 + .../account/HomeAccountCreationFragment.java | 135 -- .../account/HomeAccountCreationFragment.kt | 131 ++ .../account/JamiAccountConnectFragment.java | 5 +- .../account/JamiAccountCreationFragment.java | 197 -- .../account/JamiAccountCreationFragment.kt | 152 ++ .../account/JamiAccountPasswordFragment.java | 6 +- .../account/JamiAccountSummaryFragment.java | 832 ------- .../account/JamiAccountSummaryFragment.kt | 744 +++++++ .../account/JamiAccountUsernameFragment.java | 7 +- .../ring/account/JamiLinkAccountFragment.java | 184 -- .../ring/account/JamiLinkAccountFragment.kt | 151 ++ .../JamiLinkAccountPasswordFragment.java | 5 +- .../ring/account/ProfileCreationFragment.java | 248 --- .../ring/account/ProfileCreationFragment.kt | 224 ++ .../cx/ring/account/RegisterNameDialog.java | 237 -- .../cx/ring/account/RegisterNameDialog.kt | 218 ++ .../cx/ring/account/RenameDeviceDialog.java | 113 - .../cx/ring/account/RenameDeviceDialog.kt | 111 + .../ring/adapters/ConfParticipantAdapter.java | 123 -- .../ring/adapters/ConfParticipantAdapter.kt | 105 + .../cx/ring/adapters/ConversationAdapter.java | 1246 ----------- .../cx/ring/adapters/ConversationAdapter.kt | 1141 ++++++++++ .../cx/ring/adapters/SmartListAdapter.java | 119 - .../java/cx/ring/adapters/SmartListAdapter.kt | 99 + .../cx/ring/adapters/SmartListDiffUtil.java | 69 - .../cx/ring/adapters/SmartListDiffUtil.kt | 53 + .../cx/ring/application/JamiApplication.java | 374 ---- .../cx/ring/application/JamiApplication.kt | 277 +++ .../cx/ring/client/AccountSpinnerAdapter.java | 178 -- .../cx/ring/client/AccountSpinnerAdapter.kt | 156 ++ .../java/cx/ring/client/CallActivity.java | 233 -- .../main/java/cx/ring/client/CallActivity.kt | 215 ++ .../ring/client/ColorChooserBottomSheet.java | 83 - .../cx/ring/client/ColorChooserBottomSheet.kt | 76 + .../ring/client/ContactDetailsActivity.java | 457 ---- .../cx/ring/client/ContactDetailsActivity.kt | 439 ++++ .../cx/ring/client/ConversationActivity.java | 144 -- .../cx/ring/client/ConversationActivity.kt | 124 ++ .../client/ConversationSelectionActivity.java | 151 -- .../client/ConversationSelectionActivity.kt | 108 + .../ring/client/EmojiChooserBottomSheet.java | 97 - .../cx/ring/client/EmojiChooserBottomSheet.kt | 83 + .../java/cx/ring/client/HomeActivity.java | 835 ------- .../main/java/cx/ring/client/HomeActivity.kt | 792 +++++++ .../java/cx/ring/client/LogsActivity.java | 203 -- .../main/java/cx/ring/client/LogsActivity.kt | 181 ++ ...erActivity.java => MediaViewerActivity.kt} | 23 +- .../cx/ring/client/MediaViewerFragment.java | 98 - .../cx/ring/client/MediaViewerFragment.kt | 80 + .../java/cx/ring/client/RingtoneActivity.java | 376 ---- .../java/cx/ring/client/RingtoneActivity.kt | 354 +++ .../{ShareActivity.java => ShareActivity.kt} | 38 +- .../contactrequests/BlockListAdapter.java | 67 - .../ring/contactrequests/BlockListAdapter.kt | 54 + .../contactrequests/BlockListFragment.java | 126 -- .../ring/contactrequests/BlockListFragment.kt | 114 + .../contactrequests/BlockListViewHolder.java | 45 - .../contactrequests/BlockListViewHolder.kt | 39 + .../ContactRequestsFragment.java | 164 -- .../ContactRequestsFragment.kt | 120 ++ .../JamiInjectionComponent.java | 239 -- .../JamiInjectionModule.java | 54 - .../ServiceInjectionModule.java | 181 -- .../ServiceInjectionModule.kt | 171 ++ .../fragments/AccountMigrationFragment.java | 3 +- .../fragments/AdvancedAccountFragment.java | 145 -- .../ring/fragments/AdvancedAccountFragment.kt | 143 ++ .../fragments/AdvancedAccountPresenter.java | 2 +- .../java/cx/ring/fragments/CallFragment.java | 1601 -------------- .../java/cx/ring/fragments/CallFragment.kt | 1480 +++++++++++++ .../ring/fragments/ContactPickerFragment.java | 9 +- .../ring/fragments/ConversationFragment.java | 1283 ----------- .../cx/ring/fragments/ConversationFragment.kt | 1190 ++++++++++ .../fragments/GeneralAccountFragment.java | 3 +- .../fragments/GeneralAccountPresenter.java | 11 +- .../cx/ring/fragments/LinkDeviceFragment.java | 3 +- .../fragments/LocationSharingFragment.java | 628 ------ .../ring/fragments/LocationSharingFragment.kt | 587 +++++ .../fragments/MediaPreferenceFragment.java | 3 +- .../fragments/PluginHandlersListFragment.java | 85 - .../fragments/PluginHandlersListFragment.kt | 70 + .../cx/ring/fragments/QRCodeFragment.java | 178 -- .../java/cx/ring/fragments/QRCodeFragment.kt | 141 ++ .../fragments/SIPAccountCreationFragment.java | 196 -- .../fragments/SIPAccountCreationFragment.kt | 193 ++ .../fragments/SecurityAccountFragment.java | 12 +- .../cx/ring/fragments/ShareWithFragment.java | 196 -- .../cx/ring/fragments/ShareWithFragment.kt | 183 ++ .../cx/ring/fragments/SmartListFragment.java | 540 ----- .../cx/ring/fragments/SmartListFragment.kt | 424 ++++ .../java/cx/ring/history/DatabaseHelper.java | 432 ---- .../java/cx/ring/history/DatabaseHelper.kt | 438 ++++ .../java/cx/ring/linkpreview/LinkListener.kt | 15 + .../java/cx/ring/linkpreview/LinkPreview.kt | 30 + .../java/cx/ring/linkpreview/PreviewData.kt | 12 + .../main/java/cx/ring/mvp/BaseFragment.java | 82 - .../cx/ring/mvp/BasePreferenceFragment.java | 2 + .../java/cx/ring/mvp/BaseSupportFragment.java | 102 - .../java/cx/ring/mvp/BaseSupportFragment.kt | 89 + .../java/cx/ring/plugins/PluginUtils.java | 8 +- .../RecyclerPicker/RecyclerPicker.java | 50 +- .../RecyclerPicker/RecyclerPickerAdapter.java | 20 +- .../RecyclerPickerLayoutManager.java | 10 +- .../RecyclerPicker/RecyclerPickerUtils.java | 23 - .../java/cx/ring/service/BootReceiver.java | 5 +- .../ring/service/CallNotificationService.java | 9 +- .../java/cx/ring/service/DRingService.java | 799 ------- .../main/java/cx/ring/service/DRingService.kt | 374 ++++ .../java/cx/ring/service/IDRingService.aidl | 118 - .../ring/service/LocationSharingService.java | 406 ---- .../cx/ring/service/LocationSharingService.kt | 366 ++++ .../java/cx/ring/service/SyncService.java | 8 +- .../cx/ring/services/ContactServiceImpl.java | 463 ---- .../cx/ring/services/ContactServiceImpl.kt | 500 +++++ .../cx/ring/services/DataTransferService.java | 5 +- .../services/DeviceRuntimeServiceImpl.java | 255 --- .../ring/services/DeviceRuntimeServiceImpl.kt | 231 ++ .../cx/ring/services/HardwareServiceImpl.java | 788 ------- .../cx/ring/services/HardwareServiceImpl.kt | 695 ++++++ .../cx/ring/services/HistoryServiceImpl.java | 156 -- .../cx/ring/services/HistoryServiceImpl.kt | 128 ++ .../java/cx/ring/services/LogServiceImpl.java | 2 - .../services/NotificationServiceImpl.java | 990 --------- .../ring/services/NotificationServiceImpl.kt | 1068 +++++++++ .../SharedPreferencesServiceImpl.java | 221 -- .../services/SharedPreferencesServiceImpl.kt | 204 ++ .../cx/ring/services/VCardServiceImpl.java | 122 -- .../java/cx/ring/services/VCardServiceImpl.kt | 105 + .../cx/ring/settings/AccountFragment.java | 5 +- .../cx/ring/settings/SettingsFragment.java | 6 +- .../pluginssettings/PathListAdapter.java | 13 +- .../main/java/cx/ring/share/ScanFragment.java | 185 -- .../main/java/cx/ring/share/ScanFragment.kt | 180 ++ .../java/cx/ring/share/ShareFragment.java | 6 +- .../java/cx/ring/tv/about/AboutActivity.java | 35 - .../ring/tv/about/AboutDetailsFragment.java | 28 +- .../cx/ring/tv/account/TVAccountExport.java | 258 --- .../cx/ring/tv/account/TVAccountExport.kt | 187 ++ .../cx/ring/tv/account/TVAccountWizard.java | 235 -- .../cx/ring/tv/account/TVAccountWizard.kt | 202 ++ .../TVHomeAccountCreationFragment.java | 9 +- .../TVJamiAccountCreationFragment.java | 12 +- .../tv/account/TVJamiLinkAccountFragment.java | 5 +- .../tv/account/TVProfileCreationFragment.java | 266 --- .../tv/account/TVProfileCreationFragment.kt | 224 ++ .../tv/account/TVProfileEditingFragment.java | 236 -- .../tv/account/TVProfileEditingFragment.kt | 196 ++ .../cx/ring/tv/account/TVShareActivity.java | 3 +- .../cx/ring/tv/account/TVShareFragment.java | 110 - .../cx/ring/tv/account/TVShareFragment.kt | 96 + .../java/cx/ring/tv/call/TVCallActivity.java | 118 - .../java/cx/ring/tv/call/TVCallActivity.kt | 97 + .../java/cx/ring/tv/call/TVCallFragment.java | 828 ------- .../java/cx/ring/tv/call/TVCallFragment.kt | 744 +++++++ .../ring/tv/camera/CustomCameraActivity.java | 290 --- .../cx/ring/tv/camera/CustomCameraActivity.kt | 275 +++ .../src/main/java/cx/ring/tv/cards/Card.java | 5 +- .../ring/tv/cards/CardPresenterSelector.java | 7 +- .../main/java/cx/ring/tv/cards/CardView.java | 313 +++ .../tv/cards/ShadowRowPresenterSelector.java | 38 +- .../cards/contacts/ContactCardPresenter.java | 31 +- .../tv/cards/iconcards/IconCardHelper.java | 57 +- .../tv/cards/iconcards/IconCardPresenter.java | 44 +- .../cx/ring/tv/contact/TVContactActivity.java | 162 ++ .../tv/contact/TVContactDetailPresenter.java | 70 - .../tv/contact/TVContactDetailPresenter.kt | 58 + .../cx/ring/tv/contact/TVContactFragment.java | 248 --- .../cx/ring/tv/contact/TVContactFragment.kt | 207 ++ .../ring/tv/contact/TVContactPresenter.java | 131 -- .../cx/ring/tv/contact/TVContactPresenter.kt | 108 + .../more/TVContactMoreActivity.java} | 15 +- .../tv/contact/more/TVContactMoreFragment.kt | 126 ++ .../contact/more/TVContactMorePresenter.java | 60 + .../tv/contact/more/TVContactMoreView.java | 26 + .../conversation/TvConversationFragment.java | 823 ------- .../tv/conversation/TvConversationFragment.kt | 729 +++++++ .../cx/ring/tv/main/BaseBrowseFragment.java | 4 +- .../java/cx/ring/tv/main/HomeActivity.java | 200 +- .../java/cx/ring/tv/main/MainFragment.java | 496 ----- .../main/java/cx/ring/tv/main/MainFragment.kt | 423 ++++ .../java/cx/ring/tv/main/MainPresenter.java | 13 +- .../main/java/cx/ring/tv/main/MainView.java | 6 +- .../ring/tv/search/ContactSearchFragment.java | 152 -- .../ring/tv/search/ContactSearchFragment.kt | 125 ++ .../tv/search/ContactSearchPresenter.java | 10 +- .../cx/ring/tv/search/SearchActivity.java | 2 + .../cx/ring/tv/settings/TVAboutFragment.java | 89 + .../cx/ring/tv/settings/TVSettingsActivity.kt | 33 + .../TVSettingsFragment.java | 28 +- .../cx/ring/tv/views/CustomTitleView.java | 20 + .../tv/views/NonOverlappingFrameLayout.java | 23 + .../java/cx/ring/utils/AndroidFileUtils.java | 604 ------ .../java/cx/ring/utils/AndroidFileUtils.kt | 569 +++++ .../main/java/cx/ring/utils/BitmapUtils.java | 185 -- .../main/java/cx/ring/utils/BitmapUtils.kt | 162 ++ .../java/cx/ring/utils/ClipboardHelper.java | 48 - .../java/cx/ring/utils/ClipboardHelper.kt | 41 + .../java/cx/ring/utils/ContentUriHandler.java | 108 - .../java/cx/ring/utils/ContentUriHandler.kt | 108 + .../java/cx/ring/utils/ConversationPath.java | 209 -- .../java/cx/ring/utils/ConversationPath.kt | 190 ++ .../main/java/cx/ring/utils/DeviceUtils.kt | 38 + .../java/cx/ring/utils/JamiGlideModule.java | 22 - .../java/cx/ring/utils/JamiGlideModule.kt | 15 + .../cx/ring/utils/RegisteredNameFilter.java | 59 - .../cx/ring/utils/RegisteredNameFilter.kt | 67 + .../ring/utils/RegisteredNameTextWatcher.java | 80 - .../ring/utils/RegisteredNameTextWatcher.kt | 65 + .../src/main/java/cx/ring/utils/Ringer.java | 84 - .../app/src/main/java/cx/ring/utils/Ringer.kt | 67 + .../cx/ring/utils/ShadowScrollBehavior.java | 90 - .../cx/ring/utils/TouchClickListener.java | 54 - .../java/cx/ring/utils/TouchClickListener.kt | 69 + .../ring/viewholders/SmartListViewHolder.java | 163 -- .../ring/viewholders/SmartListViewHolder.kt | 152 ++ .../java/cx/ring/views/AvatarDrawable.java | 689 ------ .../main/java/cx/ring/views/AvatarDrawable.kt | 695 ++++++ .../java/cx/ring/views/AvatarFactory.java | 121 -- .../main/java/cx/ring/views/AvatarFactory.kt | 121 ++ .../drawable/baseline_androidtv_account.xml | 12 + .../drawable/baseline_androidtv_add_user.xml | 15 + .../res/drawable/baseline_androidtv_chat.xml | 9 + .../baseline_androidtv_clearconversation.xml | 12 + .../baseline_androidtv_deletecontact.xml | 19 + .../baseline_androidtv_link_device.xml | 9 + .../baseline_androidtv_message_audio.xml | 9 + .../baseline_androidtv_message_video.xml | 9 + .../drawable/baseline_androidtv_settings.xml | 9 + .../res/drawable/ic_tv_online_indicator.xml | 5 +- .../src/main/res/drawable/tv_header_bg.xml | 3 +- .../app/src/main/res/font/mulish_regular.ttf | Bin 0 -> 89244 bytes .../app/src/main/res/font/mulish_semibold.ttf | Bin 0 -> 89340 bytes .../app/src/main/res/font/ubuntu_light.ttf | Bin 0 -> 361676 bytes .../app/src/main/res/font/ubuntu_medium.ttf | Bin 0 -> 284424 bytes .../app/src/main/res/font/ubuntu_regular.ttf | Bin 0 -> 298928 bytes .../layout-w720dp-land/tv_activity_about.xml | 26 - .../res/layout-w720dp-land/tv_frag_call.xml | 28 + .../main/res/layout/frag_conversation_tv.xml | 29 +- .../src/main/res/layout/tv_about_layout.xml | 8 + .../src/main/res/layout/tv_activity_about.xml | 26 - .../res/layout/tv_activity_contact_more.xml | 8 + .../src/main/res/layout/tv_activity_home.xml | 43 +- .../main/res/layout/tv_activity_settings.xml | 4 +- .../app/src/main/res/layout/tv_frag_call.xml | 28 + .../src/main/res/layout/tv_frag_contact.xml | 28 +- .../app/src/main/res/layout/tv_frag_share.xml | 1 + .../app/src/main/res/layout/tv_titleview.xml | 52 +- .../app/src/main/res/values/colors.xml | 7 + .../app/src/main/res/values/strings.xml | 7 +- .../src/main/res/values/strings_account.xml | 15 +- .../app/src/main/res/values/styles.xml | 79 +- .../app/src/main/res/xml/tv_about_pref.xml | 33 + .../main/res/xml/tv_account_general_pref.xml | 13 +- .../src/main/res/xml/tv_contact_more_pref.xml | 16 + .../application/JamiApplicationFirebase.java | 69 - .../application/JamiApplicationFirebase.kt | 65 + .../JamiFirebaseMessagingService.java | 58 - .../services/JamiFirebaseMessagingService.kt | 50 + ring-android/build.gradle | 8 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- ring-android/libringclient/build.gradle | 5 + .../jami/account/AccountWizardPresenter.java | 2 +- .../account/JamiAccountCreationPresenter.java | 221 -- .../account/JamiAccountCreationPresenter.kt | 200 ++ ...onView.java => JamiAccountCreationView.kt} | 35 +- .../account/JamiAccountSummaryPresenter.java | 27 +- .../account/ProfileCreationPresenter.java | 13 +- .../java/net/jami/call/CallPresenter.java | 765 ------- .../main/java/net/jami/call/CallPresenter.kt | 698 ++++++ .../ContactRequestsPresenter.java | 6 +- .../conversation/ConversationPresenter.java | 474 ---- .../conversation/ConversationPresenter.kt | 434 ++++ .../jami/conversation/ConversationView.java | 107 - .../net/jami/conversation/ConversationView.kt | 70 + .../net/jami/facades/ConversationFacade.java | 742 ------- .../src/main/java/net/jami/model/Account.java | 1124 ---------- .../src/main/java/net/jami/model/Account.kt | 995 +++++++++ .../java/net/jami/model/AccountConfig.java | 104 - .../main/java/net/jami/model/AccountConfig.kt | 90 + .../src/main/java/net/jami/model/Call.java | 366 ---- .../src/main/java/net/jami/model/Call.kt | 291 +++ .../main/java/net/jami/model/Conference.java | 242 --- .../main/java/net/jami/model/Conference.kt | 171 ++ .../model/{ConfigKey.java => ConfigKey.kt} | 48 +- .../src/main/java/net/jami/model/Contact.java | 361 ---- .../src/main/java/net/jami/model/Contact.kt | 245 +++ .../java/net/jami/model/ContactEvent.java | 103 - .../main/java/net/jami/model/ContactEvent.kt | 96 + .../java/net/jami/model/Conversation.java | 763 ------- .../main/java/net/jami/model/Conversation.kt | 660 ++++++ .../java/net/jami/model/DataTransfer.java | 192 -- .../main/java/net/jami/model/DataTransfer.kt | 176 ++ .../main/java/net/jami/model/Interaction.java | 320 --- .../main/java/net/jami/model/Interaction.kt | 232 ++ .../main/java/net/jami/model/TextMessage.java | 80 - .../main/java/net/jami/model/TextMessage.kt | 73 + .../java/net/jami/model/TrustRequest.java | 119 - .../main/java/net/jami/model/TrustRequest.kt | 78 + .../src/main/java/net/jami/model/Uri.java | 192 -- .../src/main/java/net/jami/model/Uri.kt | 167 ++ .../mvp/{GenericView.java => GenericView.kt} | 10 +- .../main/java/net/jami/mvp/RootPresenter.java | 10 +- .../navigation/HomeNavigationPresenter.java | 13 +- .../jami/navigation/HomeNavigationView.java | 2 + ...wModel.java => HomeNavigationViewModel.kt} | 23 +- .../net/jami/services/AccountService.java | 1918 ----------------- .../java/net/jami/services/AccountService.kt | 1785 +++++++++++++++ .../java/net/jami/services/CallService.java | 837 ------- .../java/net/jami/services/CallService.kt | 787 +++++++ .../net/jami/services/ContactService.java | 209 -- .../java/net/jami/services/ContactService.kt | 183 ++ .../net/jami/services/ConversationFacade.kt | 702 ++++++ .../java/net/jami/services/DaemonService.java | 29 +- .../net/jami/services/HardwareService.java | 266 --- .../java/net/jami/services/HardwareService.kt | 226 ++ .../{LogService.java => LogService.kt} | 32 +- .../jami/services/NotificationService.java | 5 +- .../net/jami/services/PreferencesService.java | 10 +- .../java/net/jami/services/VCardService.java | 3 +- .../net/jami/settings/SettingsPresenter.java | 2 +- .../java/net/jami/share/SharePresenter.java | 5 +- .../jami/smartlist/SmartListPresenter.java | 203 -- .../net/jami/smartlist/SmartListPresenter.kt | 171 ++ .../jami/smartlist/SmartListViewModel.java | 216 -- .../net/jami/smartlist/SmartListViewModel.kt | 171 ++ .../utils/{FileUtils.java => FileUtils.kt} | 68 +- .../main/java/net/jami/utils/HashUtils.java | 63 - .../main/java/net/jami/utils/HashUtils.kt} | 45 +- .../src/main/java/net/jami/utils/Log.java | 6 +- .../jami/utils/NameLookupInputHandler.java | 4 +- .../{ProfileChunk.java => ProfileChunk.kt} | 69 +- .../main/java/net/jami/utils/StringUtils.java | 156 -- .../main/java/net/jami/utils/StringUtils.kt | 150 ++ .../net/jami/utils/SwigNativeConverter.java | 57 - .../net/jami/utils/SwigNativeConverter.kt | 45 + .../net/jami/utils/{Tuple.java => Tuple.kt} | 49 +- .../main/java/net/jami/utils/VCardUtils.java | 233 -- .../main/java/net/jami/utils/VCardUtils.kt | 231 ++ 351 files changed, 33433 insertions(+), 36657 deletions(-) delete mode 100644 ring-android/app/src/main/java/cx/ring/about/AboutBottomSheetDialogFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/about/AboutBottomSheetDialogFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/about/AboutFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/about/AboutFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/account/AccountEditionFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/account/AccountEditionFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/account/AccountWizardActivity.java create mode 100644 ring-android/app/src/main/java/cx/ring/account/AccountWizardActivity.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/account/DeviceAdapter.java create mode 100644 ring-android/app/src/main/java/cx/ring/account/DeviceAdapter.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/account/HomeAccountCreationFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/account/HomeAccountCreationFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/account/JamiAccountCreationFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/account/JamiAccountCreationFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/account/JamiAccountSummaryFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/account/JamiAccountSummaryFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/account/ProfileCreationFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/account/ProfileCreationFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/account/RegisterNameDialog.java create mode 100644 ring-android/app/src/main/java/cx/ring/account/RegisterNameDialog.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/account/RenameDeviceDialog.java create mode 100644 ring-android/app/src/main/java/cx/ring/account/RenameDeviceDialog.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/adapters/ConfParticipantAdapter.java create mode 100644 ring-android/app/src/main/java/cx/ring/adapters/ConfParticipantAdapter.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.java create mode 100644 ring-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/adapters/SmartListAdapter.java create mode 100644 ring-android/app/src/main/java/cx/ring/adapters/SmartListAdapter.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/adapters/SmartListDiffUtil.java create mode 100644 ring-android/app/src/main/java/cx/ring/adapters/SmartListDiffUtil.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/application/JamiApplication.java create mode 100644 ring-android/app/src/main/java/cx/ring/application/JamiApplication.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/client/AccountSpinnerAdapter.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/AccountSpinnerAdapter.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/client/CallActivity.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/CallActivity.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/client/ColorChooserBottomSheet.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/ColorChooserBottomSheet.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/client/ContactDetailsActivity.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/ContactDetailsActivity.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/client/ConversationActivity.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/ConversationActivity.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/client/ConversationSelectionActivity.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/ConversationSelectionActivity.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/client/EmojiChooserBottomSheet.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/EmojiChooserBottomSheet.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/client/HomeActivity.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/HomeActivity.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/client/LogsActivity.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/LogsActivity.kt rename ring-android/app/src/main/java/cx/ring/client/{MediaViewerActivity.java => MediaViewerActivity.kt} (68%) delete mode 100644 ring-android/app/src/main/java/cx/ring/client/MediaViewerFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/MediaViewerFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/client/RingtoneActivity.java create mode 100644 ring-android/app/src/main/java/cx/ring/client/RingtoneActivity.kt rename ring-android/app/src/main/java/cx/ring/client/{ShareActivity.java => ShareActivity.kt} (57%) delete mode 100644 ring-android/app/src/main/java/cx/ring/contactrequests/BlockListAdapter.java create mode 100644 ring-android/app/src/main/java/cx/ring/contactrequests/BlockListAdapter.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/contactrequests/BlockListFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/contactrequests/BlockListFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/contactrequests/BlockListViewHolder.java create mode 100644 ring-android/app/src/main/java/cx/ring/contactrequests/BlockListViewHolder.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/contactrequests/ContactRequestsFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/contactrequests/ContactRequestsFragment.kt delete mode 100755 ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionComponent.java delete mode 100755 ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionModule.java delete mode 100755 ring-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.java create mode 100755 ring-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/fragments/CallFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/fragments/CallFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/fragments/PluginHandlersListFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/fragments/PluginHandlersListFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/fragments/QRCodeFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/fragments/QRCodeFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/fragments/SIPAccountCreationFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/fragments/SIPAccountCreationFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/fragments/ShareWithFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/fragments/ShareWithFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/fragments/SmartListFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/fragments/SmartListFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/history/DatabaseHelper.java create mode 100644 ring-android/app/src/main/java/cx/ring/history/DatabaseHelper.kt create mode 100644 ring-android/app/src/main/java/cx/ring/linkpreview/LinkListener.kt create mode 100644 ring-android/app/src/main/java/cx/ring/linkpreview/LinkPreview.kt create mode 100644 ring-android/app/src/main/java/cx/ring/linkpreview/PreviewData.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/mvp/BaseFragment.java delete mode 100644 ring-android/app/src/main/java/cx/ring/mvp/BaseSupportFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/mvp/BaseSupportFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerUtils.java delete mode 100644 ring-android/app/src/main/java/cx/ring/service/DRingService.java create mode 100644 ring-android/app/src/main/java/cx/ring/service/DRingService.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/service/IDRingService.aidl delete mode 100644 ring-android/app/src/main/java/cx/ring/service/LocationSharingService.java create mode 100644 ring-android/app/src/main/java/cx/ring/service/LocationSharingService.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/services/ContactServiceImpl.java create mode 100644 ring-android/app/src/main/java/cx/ring/services/ContactServiceImpl.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/services/DeviceRuntimeServiceImpl.java create mode 100644 ring-android/app/src/main/java/cx/ring/services/DeviceRuntimeServiceImpl.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.java create mode 100644 ring-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/services/HistoryServiceImpl.java create mode 100644 ring-android/app/src/main/java/cx/ring/services/HistoryServiceImpl.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.java create mode 100644 ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/services/SharedPreferencesServiceImpl.java create mode 100644 ring-android/app/src/main/java/cx/ring/services/SharedPreferencesServiceImpl.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/services/VCardServiceImpl.java create mode 100644 ring-android/app/src/main/java/cx/ring/services/VCardServiceImpl.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/share/ScanFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/share/ScanFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/about/AboutActivity.java delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/account/TVAccountExport.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/account/TVAccountExport.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/account/TVAccountWizard.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/account/TVAccountWizard.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/account/TVProfileCreationFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/account/TVProfileCreationFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/account/TVProfileEditingFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/account/TVProfileEditingFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/account/TVShareFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/account/TVShareFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/call/TVCallActivity.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/call/TVCallActivity.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/camera/CustomCameraActivity.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/camera/CustomCameraActivity.kt create mode 100644 ring-android/app/src/main/java/cx/ring/tv/cards/CardView.java delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/contact/TVContactDetailPresenter.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/contact/TVContactDetailPresenter.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/contact/TVContactFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/contact/TVContactFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/contact/TVContactPresenter.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/contact/TVContactPresenter.kt rename ring-android/app/src/main/java/cx/ring/tv/{account/TVSettingsActivity.java => contact/more/TVContactMoreActivity.java} (69%) create mode 100644 ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreFragment.kt create mode 100644 ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMorePresenter.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreView.java delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/conversation/TvConversationFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/conversation/TvConversationFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/main/MainFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/main/MainFragment.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchFragment.kt create mode 100644 ring-android/app/src/main/java/cx/ring/tv/settings/TVAboutFragment.java create mode 100644 ring-android/app/src/main/java/cx/ring/tv/settings/TVSettingsActivity.kt rename ring-android/app/src/main/java/cx/ring/tv/{account => settings}/TVSettingsFragment.java (87%) create mode 100644 ring-android/app/src/main/java/cx/ring/tv/views/NonOverlappingFrameLayout.java delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/AndroidFileUtils.java create mode 100644 ring-android/app/src/main/java/cx/ring/utils/AndroidFileUtils.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/BitmapUtils.java create mode 100644 ring-android/app/src/main/java/cx/ring/utils/BitmapUtils.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/ClipboardHelper.java create mode 100644 ring-android/app/src/main/java/cx/ring/utils/ClipboardHelper.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/ContentUriHandler.java create mode 100644 ring-android/app/src/main/java/cx/ring/utils/ContentUriHandler.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/ConversationPath.java create mode 100644 ring-android/app/src/main/java/cx/ring/utils/ConversationPath.kt create mode 100644 ring-android/app/src/main/java/cx/ring/utils/DeviceUtils.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/JamiGlideModule.java create mode 100644 ring-android/app/src/main/java/cx/ring/utils/JamiGlideModule.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/RegisteredNameFilter.java create mode 100644 ring-android/app/src/main/java/cx/ring/utils/RegisteredNameFilter.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/RegisteredNameTextWatcher.java create mode 100644 ring-android/app/src/main/java/cx/ring/utils/RegisteredNameTextWatcher.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/Ringer.java create mode 100644 ring-android/app/src/main/java/cx/ring/utils/Ringer.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/ShadowScrollBehavior.java delete mode 100644 ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.java create mode 100644 ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/viewholders/SmartListViewHolder.java create mode 100644 ring-android/app/src/main/java/cx/ring/viewholders/SmartListViewHolder.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/views/AvatarDrawable.java create mode 100644 ring-android/app/src/main/java/cx/ring/views/AvatarDrawable.kt delete mode 100644 ring-android/app/src/main/java/cx/ring/views/AvatarFactory.java create mode 100644 ring-android/app/src/main/java/cx/ring/views/AvatarFactory.kt create mode 100644 ring-android/app/src/main/res/drawable/baseline_androidtv_account.xml create mode 100644 ring-android/app/src/main/res/drawable/baseline_androidtv_add_user.xml create mode 100644 ring-android/app/src/main/res/drawable/baseline_androidtv_chat.xml create mode 100644 ring-android/app/src/main/res/drawable/baseline_androidtv_clearconversation.xml create mode 100644 ring-android/app/src/main/res/drawable/baseline_androidtv_deletecontact.xml create mode 100644 ring-android/app/src/main/res/drawable/baseline_androidtv_link_device.xml create mode 100644 ring-android/app/src/main/res/drawable/baseline_androidtv_message_audio.xml create mode 100644 ring-android/app/src/main/res/drawable/baseline_androidtv_message_video.xml create mode 100644 ring-android/app/src/main/res/drawable/baseline_androidtv_settings.xml create mode 100644 ring-android/app/src/main/res/font/mulish_regular.ttf create mode 100644 ring-android/app/src/main/res/font/mulish_semibold.ttf create mode 100644 ring-android/app/src/main/res/font/ubuntu_light.ttf create mode 100644 ring-android/app/src/main/res/font/ubuntu_medium.ttf create mode 100644 ring-android/app/src/main/res/font/ubuntu_regular.ttf delete mode 100644 ring-android/app/src/main/res/layout-w720dp-land/tv_activity_about.xml create mode 100644 ring-android/app/src/main/res/layout/tv_about_layout.xml delete mode 100644 ring-android/app/src/main/res/layout/tv_activity_about.xml create mode 100644 ring-android/app/src/main/res/layout/tv_activity_contact_more.xml create mode 100644 ring-android/app/src/main/res/xml/tv_about_pref.xml create mode 100644 ring-android/app/src/main/res/xml/tv_contact_more_pref.xml delete mode 100644 ring-android/app/src/withFirebase/java/cx/ring/application/JamiApplicationFirebase.java create mode 100644 ring-android/app/src/withFirebase/java/cx/ring/application/JamiApplicationFirebase.kt delete mode 100644 ring-android/app/src/withFirebase/java/cx/ring/services/JamiFirebaseMessagingService.java create mode 100644 ring-android/app/src/withFirebase/java/cx/ring/services/JamiFirebaseMessagingService.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationPresenter.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationPresenter.kt rename ring-android/libringclient/src/main/java/net/jami/account/{JamiAccountCreationView.java => JamiAccountCreationView.kt} (58%) delete mode 100644 ring-android/libringclient/src/main/java/net/jami/call/CallPresenter.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/call/CallPresenter.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/conversation/ConversationPresenter.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/conversation/ConversationPresenter.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/conversation/ConversationView.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/conversation/ConversationView.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/facades/ConversationFacade.java delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Account.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Account.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/AccountConfig.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/AccountConfig.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Call.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Call.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Conference.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Conference.kt rename ring-android/libringclient/src/main/java/net/jami/model/{ConfigKey.java => ConfigKey.kt} (84%) delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Contact.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Contact.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/ContactEvent.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/ContactEvent.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Conversation.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Conversation.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/DataTransfer.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/DataTransfer.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Interaction.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Interaction.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/TextMessage.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/TextMessage.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/TrustRequest.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/TrustRequest.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Uri.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/model/Uri.kt rename ring-android/libringclient/src/main/java/net/jami/mvp/{GenericView.java => GenericView.kt} (89%) rename ring-android/libringclient/src/main/java/net/jami/navigation/{HomeNavigationViewModel.java => HomeNavigationViewModel.kt} (67%) delete mode 100644 ring-android/libringclient/src/main/java/net/jami/services/AccountService.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/services/AccountService.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/services/CallService.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/services/CallService.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/services/ContactService.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/services/ContactService.kt create mode 100644 ring-android/libringclient/src/main/java/net/jami/services/ConversationFacade.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/services/HardwareService.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/services/HardwareService.kt rename ring-android/libringclient/src/main/java/net/jami/services/{LogService.java => LogService.kt} (65%) delete mode 100644 ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListPresenter.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListPresenter.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListViewModel.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListViewModel.kt rename ring-android/libringclient/src/main/java/net/jami/utils/{FileUtils.java => FileUtils.kt} (50%) delete mode 100644 ring-android/libringclient/src/main/java/net/jami/utils/HashUtils.java rename ring-android/{app/src/main/java/cx/ring/utils/DeviceUtils.java => libringclient/src/main/java/net/jami/utils/HashUtils.kt} (51%) rename ring-android/libringclient/src/main/java/net/jami/utils/{ProfileChunk.java => ProfileChunk.kt} (50%) delete mode 100644 ring-android/libringclient/src/main/java/net/jami/utils/StringUtils.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/utils/StringUtils.kt delete mode 100644 ring-android/libringclient/src/main/java/net/jami/utils/SwigNativeConverter.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/utils/SwigNativeConverter.kt rename ring-android/libringclient/src/main/java/net/jami/utils/{Tuple.java => Tuple.kt} (53%) delete mode 100644 ring-android/libringclient/src/main/java/net/jami/utils/VCardUtils.java create mode 100644 ring-android/libringclient/src/main/java/net/jami/utils/VCardUtils.kt diff --git a/ring-android/app/build.gradle b/ring-android/app/build.gradle index b2d76d3b9..c9f198ea6 100644 --- a/ring-android/app/build.gradle +++ b/ring-android/app/build.gradle @@ -1,4 +1,7 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' +apply plugin: 'dagger.hilt.android.plugin' def buildFirebase = project.hasProperty('buildFirebase') || getGradle().getStartParameter().getTaskRequests().toString().contains('Firebase') @@ -9,14 +12,12 @@ android { defaultConfig { minSdkVersion 21 targetSdkVersion 30 - versionCode 303 - versionName "20210611-01" + versionCode 304 + versionName "20210721-01" } sourceSets { main { - aidl.srcDirs = ['src/main/java'] jniLibs.srcDir 'src/main/libs' - jni.srcDirs = [] } } @@ -73,29 +74,31 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } + kotlinOptions { + jvmTarget = "1.8" + } } dependencies { - def dagger_version = '2.36' - implementation fileTree(include: '*.jar', dir: 'libs') implementation project(':libringclient') - implementation 'androidx.core:core:1.6.0' - implementation "androidx.appcompat:appcompat:1.3.0" - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.6.0' + implementation "androidx.appcompat:appcompat:1.3.1" + implementation 'androidx.constraintlayout:constraintlayout:2.1.0' implementation "androidx.legacy:legacy-support-core-utils:1.0.0" implementation "androidx.cardview:cardview:1.0.0" - implementation "androidx.preference:preference:1.1.1" + implementation "androidx.preference:preference-ktx:1.1.1" implementation "androidx.recyclerview:recyclerview:1.2.1" - implementation "androidx.leanback:leanback:1.1.0-rc01" - implementation "androidx.leanback:leanback-preference:1.1.0-rc01" + implementation "androidx.leanback:leanback:1.2.0-alpha01" + implementation "androidx.leanback:leanback-preference:1.2.0-alpha01" implementation 'androidx.tvprovider:tvprovider:1.0.0' implementation "androidx.media:media:1.4.1" implementation "androidx.percentlayout:percentlayout:1.0.0" implementation "com.google.android.material:material:1.4.0" implementation 'com.google.android:flexbox:2.0.1' - implementation 'org.osmdroid:osmdroid-android:6.1.10' + implementation 'org.osmdroid:osmdroid-android:6.1.11' implementation "androidx.sharetarget:sharetarget:1.1.0" // ORM @@ -108,12 +111,12 @@ dependencies { implementation 'com.rodolfonavalon:ShapeRippleLibrary:1.0.0' // Dagger dependency injection - implementation "com.google.dagger:dagger:$dagger_version" - annotationProcessor "com.google.dagger:dagger-compiler:$dagger_version" + implementation("com.google.dagger:hilt-android:$hilt_version") + kapt("com.google.dagger:hilt-android-compiler:$hilt_version") // Glide implementation 'com.github.bumptech.glide:glide:4.12.0' - annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' + kapt 'com.github.bumptech.glide:compiler:4.12.0' // RxAndroid implementation 'io.reactivex.rxjava3:rxandroid:3.0.0' diff --git a/ring-android/app/proguard-rules.pro b/ring-android/app/proguard-rules.pro index 683fa81f7..4738dde5b 100644 --- a/ring-android/app/proguard-rules.pro +++ b/ring-android/app/proguard-rules.pro @@ -53,9 +53,13 @@ # Glide -keep public class * implements com.bumptech.glide.module.GlideModule --keep public class * extends com.bumptech.glide.module.AppGlideModule --keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** { +-keep class * extends com.bumptech.glide.module.AppGlideModule { + <init>(...); +} +-keep public enum com.bumptech.glide.load.ImageHeaderParser$** { **[] $VALUES; public *; } - +-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder { + *** rewind(); +} diff --git a/ring-android/app/src/main/AndroidManifest.xml b/ring-android/app/src/main/AndroidManifest.xml index 03cccb908..ff781502e 100644 --- a/ring-android/app/src/main/AndroidManifest.xml +++ b/ring-android/app/src/main/AndroidManifest.xml @@ -347,11 +347,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/Theme.Leanback" /> - <activity - android:name=".tv.about.AboutActivity" - android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" - android:theme="@style/Theme.Leanback" /> <activity android:name=".tv.account.TVShareActivity" android:icon="@mipmap/ic_launcher" @@ -401,7 +396,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. </intent-filter> </activity> <activity - android:name=".tv.account.TVSettingsActivity" + android:name=".tv.settings.TVSettingsActivity" + android:theme="@style/LeanbackPreferences" /> + <activity + android:name=".tv.contact.more.TVContactMoreActivity" android:theme="@style/LeanbackPreferences" /> <provider diff --git a/ring-android/app/src/main/java/cx/ring/about/AboutBottomSheetDialogFragment.java b/ring-android/app/src/main/java/cx/ring/about/AboutBottomSheetDialogFragment.java deleted file mode 100644 index f117b9efe..000000000 --- a/ring-android/app/src/main/java/cx/ring/about/AboutBottomSheetDialogFragment.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.about; - -import android.app.Dialog; -import androidx.annotation.NonNull; -import com.google.android.material.bottomsheet.BottomSheetBehavior; -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import android.view.View; - -import cx.ring.R; - -public class AboutBottomSheetDialogFragment extends BottomSheetDialogFragment { - - private BottomSheetBehavior.BottomSheetCallback mCallback = new BottomSheetBehavior.BottomSheetCallback() { - @Override - public void onStateChanged(@NonNull View bottomSheet, int newState) { - if (newState == BottomSheetBehavior.STATE_HIDDEN) { - dismiss(); - } - } - - @Override - public void onSlide(@NonNull View bottomSheet, float slideOffset) { - - } - }; - - @Override - public void setupDialog(Dialog dialog, int style) { - View contentView = View.inflate(getContext(), R.layout.dialog_about, null); - dialog.setContentView(contentView); - - CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) ((View) contentView.getParent()).getLayoutParams(); - CoordinatorLayout.Behavior behavior = params.getBehavior(); - - if (behavior instanceof BottomSheetBehavior) { - ((BottomSheetBehavior) behavior).addBottomSheetCallback(mCallback); - } - } -} diff --git a/ring-android/app/src/main/java/cx/ring/about/AboutBottomSheetDialogFragment.kt b/ring-android/app/src/main/java/cx/ring/about/AboutBottomSheetDialogFragment.kt new file mode 100644 index 000000000..d87a1da0b --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/about/AboutBottomSheetDialogFragment.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.about + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import android.view.View +import com.google.android.material.bottomsheet.BottomSheetBehavior +import android.app.Dialog +import cx.ring.R +import androidx.coordinatorlayout.widget.CoordinatorLayout + +class AboutBottomSheetDialogFragment : BottomSheetDialogFragment() { + private val mCallback: BottomSheetCallback = object : BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + dismiss() + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + } + + override fun setupDialog(dialog: Dialog, style: Int) { + val contentView = View.inflate(context, R.layout.dialog_about, null) + dialog.setContentView(contentView) + val params = (contentView.parent as View).layoutParams as CoordinatorLayout.LayoutParams + val behavior = params.behavior + if (behavior is BottomSheetBehavior<*>) { + behavior.addBottomSheetCallback(mCallback) + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/about/AboutFragment.java b/ring-android/app/src/main/java/cx/ring/about/AboutFragment.java deleted file mode 100644 index 53a550e0a..000000000 --- a/ring-android/app/src/main/java/cx/ring/about/AboutFragment.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.about; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; -import com.google.android.material.snackbar.Snackbar; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; - -import cx.ring.BuildConfig; -import cx.ring.R; -import cx.ring.client.HomeActivity; -import cx.ring.databinding.FragAboutBinding; -import cx.ring.mvp.BaseSupportFragment; -import net.jami.mvp.RootPresenter; - -public class AboutFragment extends BaseSupportFragment<RootPresenter> { - - private FragAboutBinding binding; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragAboutBinding.inflate(inflater, container, false); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - setHasOptionsMenu(true); - binding.release.setText(getString(R.string.app_release, BuildConfig.VERSION_NAME)); - binding.logo.setOnClickListener(v -> openWebsite(getString(R.string.app_website))); - binding.sflLogo.setOnClickListener(v -> openWebsite(getString(R.string.savoirfairelinux_website))); - binding.contributeContainer.setOnClickListener(v -> openWebsite(getString(R.string.ring_contribute_website))); - binding.licenseContainer.setOnClickListener(v -> openWebsite(getString(R.string.gnu_license_website))); - binding.emailReportContainer.setOnClickListener(v -> sendFeedbackEmail()); - binding.credits.setOnClickListener(v -> creditsClicked()); - } - - @Override - public void onResume() { - super.onResume(); - ((HomeActivity) requireActivity()).setToolbarTitle(R.string.menu_item_about); - } - - @Override - public void onCreateOptionsMenu(Menu menu, @NonNull MenuInflater inflater) { - menu.clear(); - } - - @Override - public void onPrepareOptionsMenu(Menu menu) { - menu.clear(); - } - - private void sendFeedbackEmail() { - Intent emailIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:" + "jami@gnu.org")); - emailIntent.putExtra(Intent.EXTRA_SUBJECT, "[" + getText(R.string.app_name) + " Android - " + BuildConfig.VERSION_NAME + "]"); - launchSystemIntent(emailIntent, R.string.no_email_app_installed); - } - - private void creditsClicked() { - BottomSheetDialogFragment dialog = new AboutBottomSheetDialogFragment(); - dialog.show(getChildFragmentManager(), dialog.getTag()); - } - - private void openWebsite(String url) { - launchSystemIntent(new Intent(Intent.ACTION_VIEW, Uri.parse(url)), R.string.no_browser_app_installed); - } - - private void launchSystemIntent(Intent intentToLaunch, @StringRes int missingRes) { - try { - startActivity(intentToLaunch); - } catch (Exception e) { - View rootView = getView(); - if (rootView != null) { - Snackbar.make(rootView, getText(missingRes), Snackbar.LENGTH_SHORT).show(); - } - } - } -} diff --git a/ring-android/app/src/main/java/cx/ring/about/AboutFragment.kt b/ring-android/app/src/main/java/cx/ring/about/AboutFragment.kt new file mode 100644 index 000000000..567ad7e3b --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/about/AboutFragment.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.about + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.os.Bundle +import android.view.View +import cx.ring.R +import android.view.Menu +import android.view.MenuInflater +import android.content.Intent +import android.net.Uri +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import java.lang.Exception +import com.google.android.material.snackbar.Snackbar +import cx.ring.BuildConfig +import cx.ring.client.HomeActivity +import cx.ring.databinding.FragAboutBinding + +class AboutFragment : Fragment() { + private var binding: FragAboutBinding? = null + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragAboutBinding.inflate(inflater, container, false) + return binding!!.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setHasOptionsMenu(true) + binding!!.apply { + release.text = getString(R.string.app_release, BuildConfig.VERSION_NAME) + logo.setOnClickListener { openWebsite(getString(R.string.app_website)) } + sflLogo.setOnClickListener { openWebsite(getString(R.string.savoirfairelinux_website)) } + contributeContainer.setOnClickListener { openWebsite(getString(R.string.ring_contribute_website)) } + licenseContainer.setOnClickListener { openWebsite(getString(R.string.gnu_license_website)) } + emailReportContainer.setOnClickListener { sendFeedbackEmail() } + credits.setOnClickListener { creditsClicked() } + } + } + + override fun onResume() { + super.onResume() + (requireActivity() as HomeActivity).setToolbarTitle(R.string.menu_item_about) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + menu.clear() + } + + override fun onPrepareOptionsMenu(menu: Menu) { + menu.clear() + } + + private fun sendFeedbackEmail() { + val emailIntent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:" + "jami@gnu.org")) + emailIntent.putExtra(Intent.EXTRA_SUBJECT, "[" + getText(R.string.app_name) + " Android - " + BuildConfig.VERSION_NAME + "]") + launchSystemIntent(emailIntent, R.string.no_email_app_installed) + } + + private fun creditsClicked() { + val dialog: BottomSheetDialogFragment = AboutBottomSheetDialogFragment() + dialog.show(childFragmentManager, dialog.tag) + } + + private fun openWebsite(url: String) { + launchSystemIntent(Intent(Intent.ACTION_VIEW, Uri.parse(url)), R.string.no_browser_app_installed) + } + + private fun launchSystemIntent(intentToLaunch: Intent, @StringRes missingRes: Int) { + try { + startActivity(intentToLaunch) + } catch (e: Exception) { + val rootView = view + if (rootView != null) { + Snackbar.make(rootView, getText(missingRes), Snackbar.LENGTH_SHORT).show() + } + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/AccountEditionFragment.java b/ring-android/app/src/main/java/cx/ring/account/AccountEditionFragment.java deleted file mode 100644 index 2ef3dd9c3..000000000 --- a/ring-android/app/src/main/java/cx/ring/account/AccountEditionFragment.java +++ /dev/null @@ -1,348 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * Alexandre Savard <alexandre.savard@savoirfairelinux.com> - * Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Loïc Siret <loic.siret@savoirfairelinux.com> - * AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.account; - -import android.app.Activity; -import android.content.Context; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; - -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapter; -import androidx.fragment.app.FragmentTransaction; -import androidx.recyclerview.widget.RecyclerView; - -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.widget.FrameLayout; -import android.widget.LinearLayout; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.client.HomeActivity; -import cx.ring.contactrequests.BlockListFragment; -import cx.ring.databinding.FragAccountSettingsBinding; -import cx.ring.fragments.AdvancedAccountFragment; -import cx.ring.fragments.GeneralAccountFragment; -import cx.ring.fragments.MediaPreferenceFragment; -import cx.ring.fragments.SecurityAccountFragment; -import cx.ring.interfaces.BackHandlerInterface; -import cx.ring.mvp.BaseSupportFragment; -import cx.ring.utils.DeviceUtils; - -public class AccountEditionFragment extends BaseSupportFragment<AccountEditionPresenter> implements - BackHandlerInterface, - AccountEditionView, - ViewTreeObserver.OnScrollChangedListener { - private static final String TAG = AccountEditionFragment.class.getSimpleName(); - - public static final String ACCOUNT_ID_KEY = AccountEditionFragment.class.getCanonicalName() + "accountid"; - static final String ACCOUNT_HAS_PASSWORD_KEY = AccountEditionFragment.class.getCanonicalName() + "hasPassword"; - public static final String ACCOUNT_ID = TAG + "accountID"; - - private static final int SCROLL_DIRECTION_UP = -1; - - private FragAccountSettingsBinding mBinding; - - private boolean mIsVisible; - - private String mAccountId; - private boolean mAccountIsJami; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - mBinding = FragAccountSettingsBinding.inflate(inflater, container, false); - // dependency injection - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - return mBinding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - mBinding = null; - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - setHasOptionsMenu(true); - super.onViewCreated(view, savedInstanceState); - - mAccountId = getArguments().getString(ACCOUNT_ID); - - HomeActivity activity = (HomeActivity) getActivity(); - if (activity != null && DeviceUtils.isTablet(activity)) { - activity.setTabletTitle(R.string.navigation_item_account); - } - - mBinding.fragmentContainer.getViewTreeObserver().addOnScrollChangedListener(this); - - presenter.init(mAccountId); - } - - @Override - public void displaySummary(String accountId) { - toggleView(accountId, true); - FragmentManager fragmentManager = getChildFragmentManager(); - Fragment existingFragment = fragmentManager.findFragmentByTag(JamiAccountSummaryFragment.TAG); - Bundle args = new Bundle(); - args.putString(ACCOUNT_ID_KEY, accountId); - if (existingFragment == null) { - JamiAccountSummaryFragment fragment = new JamiAccountSummaryFragment(); - fragment.setArguments(args); - fragmentManager.beginTransaction() - .add(R.id.fragment_container, fragment, JamiAccountSummaryFragment.TAG) - .commit(); - } else { - if (!existingFragment.isStateSaved()) - existingFragment.setArguments(args); - ((JamiAccountSummaryFragment) existingFragment).setAccount(accountId); - } - } - - @Override - public void displaySIPView(String accountId) { - toggleView(accountId, false); - } - - @Override - public void initViewPager(String accountId, boolean isJami) { - mBinding.pager.setOffscreenPageLimit(4); - mBinding.slidingTabs.setupWithViewPager(mBinding.pager); - mBinding.pager.setAdapter(new PreferencesPagerAdapter(getChildFragmentManager(), getActivity(), accountId, isJami)); - BlockListFragment existingFragment = (BlockListFragment) getChildFragmentManager().findFragmentByTag(BlockListFragment.TAG); - if (existingFragment != null) { - Bundle args = new Bundle(); - args.putString(ACCOUNT_ID_KEY, accountId); - if (!existingFragment.isStateSaved()) - existingFragment.setArguments(args); - existingFragment.setAccount(accountId); - } - } - - @Override - public void goToBlackList(String accountId) { - BlockListFragment blockListFragment = new BlockListFragment(); - Bundle args = new Bundle(); - args.putString(ACCOUNT_ID_KEY, accountId); - blockListFragment.setArguments(args); - getChildFragmentManager().beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .addToBackStack(BlockListFragment.TAG) - .replace(R.id.fragment_container, blockListFragment, BlockListFragment.TAG) - .commit(); - mBinding.slidingTabs.setVisibility(View.GONE); - mBinding.pager.setVisibility(View.GONE); - mBinding.fragmentContainer.setVisibility(View.VISIBLE); - } - - @Override - public void onCreateOptionsMenu(Menu menu, @NonNull MenuInflater inflater) { - menu.clear(); - } - - @Override - public void onResume() { - super.onResume(); - presenter.bindView(this); - } - - @Override - public void onPause() { - super.onPause(); - setBackListenerEnabled(false); - } - - public boolean onBackPressed() { - if (mBinding == null) - return false; - mIsVisible = false; - - if (getActivity() instanceof HomeActivity) - ((HomeActivity) getActivity()).setToolbarOutlineState(true); - if (mBinding.fragmentContainer.getVisibility() != View.VISIBLE) { - toggleView(mAccountId, mAccountIsJami); - return true; - } - JamiAccountSummaryFragment summaryFragment = (JamiAccountSummaryFragment) getChildFragmentManager().findFragmentByTag(JamiAccountSummaryFragment.TAG); - if (summaryFragment != null && summaryFragment.onBackPressed()) { - return true; - } - return getChildFragmentManager().popBackStackImmediate(); - } - - private void toggleView(String accountId, boolean isJami) { - mAccountId = accountId; - mAccountIsJami = isJami; - mBinding.slidingTabs.setVisibility(isJami? View.GONE : View.VISIBLE); - mBinding.pager.setVisibility(isJami? View.GONE : View.VISIBLE); - mBinding.fragmentContainer.setVisibility(isJami? View.VISIBLE : View.GONE); - setBackListenerEnabled(isJami); - } - - @Override - public void exit() { - Activity activity = getActivity(); - if (activity != null) - activity.onBackPressed(); - } - - private void setBackListenerEnabled(boolean enable) { - Activity activity = getActivity(); - if (activity instanceof HomeActivity) - ((HomeActivity) activity).setAccountFragmentOnBackPressedListener(enable ? this : null); - } - - private static class PreferencesPagerAdapter extends FragmentStatePagerAdapter { - private final Context mContext; - private final String accountId; - private final boolean isJamiAccount; - - PreferencesPagerAdapter(FragmentManager fm, Context mContext, String accountId, boolean isJamiAccount) { - super(fm); - this.mContext = mContext; - this.accountId = accountId; - this.isJamiAccount = isJamiAccount; - } - - @StringRes - private static int getRingPanelTitle(int position) { - switch (position) { - case 0: - return R.string.account_preferences_basic_tab; - case 1: - return R.string.account_preferences_media_tab; - case 2: - return R.string.account_preferences_advanced_tab; - default: - return -1; - } - } - - @StringRes - private static int getSIPPanelTitle(int position) { - switch (position) { - case 0: - return R.string.account_preferences_basic_tab; - case 1: - return R.string.account_preferences_media_tab; - case 2: - return R.string.account_preferences_advanced_tab; - case 3: - return R.string.account_preferences_security_tab; - default: - return -1; - } - } - - @Override - public int getCount() { - return isJamiAccount ? 3 : 4; - } - - @NonNull - @Override - public Fragment getItem(int position) { - return isJamiAccount ? getJamiPanel(position) : getSIPPanel(position); - } - - @Override - public CharSequence getPageTitle(int position) { - int resId = isJamiAccount ? getRingPanelTitle(position) : getSIPPanelTitle(position); - return mContext.getString(resId); - } - - @NonNull - private Fragment getJamiPanel(int position) { - switch (position) { - case 0: - return fragmentWithBundle(new GeneralAccountFragment()); - case 1: - return fragmentWithBundle(new MediaPreferenceFragment()); - case 2: - return fragmentWithBundle(new AdvancedAccountFragment()); - default: - throw new IllegalArgumentException(); - } - } - - @NonNull - private Fragment getSIPPanel(int position) { - switch (position) { - case 0: - return GeneralAccountFragment.newInstance(accountId); - case 1: - return MediaPreferenceFragment.newInstance(accountId); - case 2: - return fragmentWithBundle(new AdvancedAccountFragment()); - case 3: - return fragmentWithBundle(new SecurityAccountFragment()); - default: - throw new IllegalArgumentException(); - } - } - - private Fragment fragmentWithBundle(Fragment result) { - Bundle args = new Bundle(); - args.putString(ACCOUNT_ID_KEY, accountId); - result.setArguments(args); - return result; - } - } - - @Override - public void onScrollChanged() { - setupElevation(); - } - - private void setupElevation() { - if (mBinding == null || !mIsVisible) { - return; - } - Activity activity = getActivity(); - if (!(activity instanceof HomeActivity)) - return; - LinearLayout ll = (LinearLayout) mBinding.pager.getChildAt(mBinding.pager.getCurrentItem()); - if (ll == null) return; - RecyclerView rv = (RecyclerView)((FrameLayout) ll.getChildAt(0)).getChildAt(0); - if (rv == null) return; - HomeActivity homeActivity = (HomeActivity) activity; - if (rv.canScrollVertically(SCROLL_DIRECTION_UP)) { - mBinding.slidingTabs.setElevation(mBinding.slidingTabs.getResources().getDimension(R.dimen.toolbar_elevation)); - homeActivity.setToolbarElevation(true); - homeActivity.setToolbarOutlineState(false); - } else { - mBinding.slidingTabs.setElevation(0); - homeActivity.setToolbarElevation(false); - homeActivity.setToolbarOutlineState(true); - } - } -} diff --git a/ring-android/app/src/main/java/cx/ring/account/AccountEditionFragment.kt b/ring-android/app/src/main/java/cx/ring/account/AccountEditionFragment.kt new file mode 100644 index 000000000..19f84f4ea --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/account/AccountEditionFragment.kt @@ -0,0 +1,287 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * Alexandre Savard <alexandre.savard@savoirfairelinux.com> + * Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Loïc Siret <loic.siret@savoirfairelinux.com> + * AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.account + +import android.app.Activity +import android.content.Context +import android.os.Bundle +import android.view.* +import android.view.ViewTreeObserver.OnScrollChangedListener +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.annotation.StringRes +import androidx.fragment.app.* +import androidx.recyclerview.widget.RecyclerView +import cx.ring.R +import cx.ring.client.HomeActivity +import cx.ring.contactrequests.BlockListFragment +import cx.ring.databinding.FragAccountSettingsBinding +import cx.ring.fragments.AdvancedAccountFragment +import cx.ring.fragments.GeneralAccountFragment +import cx.ring.fragments.MediaPreferenceFragment +import cx.ring.fragments.SecurityAccountFragment +import cx.ring.interfaces.BackHandlerInterface +import cx.ring.mvp.BaseSupportFragment +import cx.ring.utils.DeviceUtils +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class AccountEditionFragment : BaseSupportFragment<AccountEditionPresenter, AccountEditionView>(), + BackHandlerInterface, AccountEditionView, OnScrollChangedListener { + private var mBinding: FragAccountSettingsBinding? = null + private var mIsVisible = false + private var mAccountId: String? = null + private var mAccountIsJami = false + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + mBinding = FragAccountSettingsBinding.inflate(inflater, container, false) + return mBinding!!.root + } + + override fun onDestroyView() { + super.onDestroyView() + mBinding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setHasOptionsMenu(true) + super.onViewCreated(view, savedInstanceState) + mAccountId = requireArguments().getString(ACCOUNT_ID_KEY) + val activity = activity as HomeActivity? + if (activity != null && DeviceUtils.isTablet(activity)) { + activity.setTabletTitle(R.string.navigation_item_account) + } + mBinding!!.fragmentContainer.viewTreeObserver.addOnScrollChangedListener(this) + presenter.init(mAccountId) + } + + override fun displaySummary(accountId: String) { + toggleView(accountId, true) + val fragmentManager = childFragmentManager + val existingFragment = fragmentManager.findFragmentByTag(JamiAccountSummaryFragment.TAG) + val args = Bundle() + args.putString(ACCOUNT_ID_KEY, accountId) + if (existingFragment == null) { + val fragment = JamiAccountSummaryFragment() + fragment.arguments = args + fragmentManager.beginTransaction() + .add(R.id.fragment_container, fragment, JamiAccountSummaryFragment.TAG) + .commit() + } else { + if (!existingFragment.isStateSaved) existingFragment.arguments = args + (existingFragment as JamiAccountSummaryFragment).setAccount(accountId) + } + } + + override fun displaySIPView(accountId: String) { + toggleView(accountId, false) + } + + override fun initViewPager(accountId: String, isJami: Boolean) { + mBinding!!.pager.offscreenPageLimit = 4 + mBinding!!.slidingTabs.setupWithViewPager(mBinding!!.pager) + mBinding!!.pager.adapter = + PreferencesPagerAdapter(childFragmentManager, activity, accountId, isJami) + val existingFragment = + childFragmentManager.findFragmentByTag(BlockListFragment.TAG) as BlockListFragment? + if (existingFragment != null) { + val args = Bundle() + args.putString(ACCOUNT_ID_KEY, accountId) + if (!existingFragment.isStateSaved) existingFragment.arguments = args + existingFragment.setAccount(accountId) + } + } + + override fun goToBlackList(accountId: String) { + val blockListFragment = BlockListFragment() + val args = Bundle() + args.putString(ACCOUNT_ID_KEY, accountId) + blockListFragment.arguments = args + childFragmentManager.beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .addToBackStack(BlockListFragment.TAG) + .replace(R.id.fragment_container, blockListFragment, BlockListFragment.TAG) + .commit() + mBinding!!.slidingTabs.visibility = View.GONE + mBinding!!.pager.visibility = View.GONE + mBinding!!.fragmentContainer.visibility = View.VISIBLE + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + menu.clear() + } + + override fun onResume() { + super.onResume() + presenter.bindView(this) + } + + override fun onPause() { + super.onPause() + setBackListenerEnabled(false) + } + + override fun onBackPressed(): Boolean { + if (mBinding == null) return false + mIsVisible = false + if (activity is HomeActivity) (activity as HomeActivity?)!!.setToolbarOutlineState(true) + if (mBinding!!.fragmentContainer.visibility != View.VISIBLE) { + toggleView(mAccountId, mAccountIsJami) + return true + } + val summaryFragment = + childFragmentManager.findFragmentByTag(JamiAccountSummaryFragment.TAG) as JamiAccountSummaryFragment? + return if (summaryFragment != null && summaryFragment.onBackPressed()) { + true + } else childFragmentManager.popBackStackImmediate() + } + + private fun toggleView(accountId: String?, isJami: Boolean) { + mAccountId = accountId + mAccountIsJami = isJami + mBinding!!.slidingTabs.visibility = if (isJami) View.GONE else View.VISIBLE + mBinding!!.pager.visibility = if (isJami) View.GONE else View.VISIBLE + mBinding!!.fragmentContainer.visibility = if (isJami) View.VISIBLE else View.GONE + setBackListenerEnabled(isJami) + } + + override fun exit() { + val activity: Activity? = activity + activity?.onBackPressed() + } + + private fun setBackListenerEnabled(enable: Boolean) { + val activity: Activity? = activity + if (activity is HomeActivity) activity.setAccountFragmentOnBackPressedListener(if (enable) this else null) + } + + private class PreferencesPagerAdapter internal constructor( + fm: FragmentManager?, + private val mContext: Context?, + private val accountId: String, + private val isJamiAccount: Boolean + ) : FragmentStatePagerAdapter( + fm!! + ) { + override fun getCount(): Int { + return if (isJamiAccount) 3 else 4 + } + + override fun getItem(position: Int): Fragment { + return if (isJamiAccount) getJamiPanel(position) else getSIPPanel(position) + } + + override fun getPageTitle(position: Int): CharSequence? { + val resId = + if (isJamiAccount) getRingPanelTitle(position) else getSIPPanelTitle(position) + return mContext!!.getString(resId) + } + + private fun getJamiPanel(position: Int): Fragment { + return when (position) { + 0 -> fragmentWithBundle(GeneralAccountFragment()) + 1 -> fragmentWithBundle(MediaPreferenceFragment()) + 2 -> fragmentWithBundle(AdvancedAccountFragment()) + else -> throw IllegalArgumentException() + } + } + + private fun getSIPPanel(position: Int): Fragment { + return when (position) { + 0 -> GeneralAccountFragment.newInstance(accountId) + 1 -> MediaPreferenceFragment.newInstance(accountId) + 2 -> fragmentWithBundle(AdvancedAccountFragment()) + 3 -> fragmentWithBundle(SecurityAccountFragment()) + else -> throw IllegalArgumentException() + } + } + + private fun fragmentWithBundle(result: Fragment): Fragment { + val args = Bundle() + args.putString(ACCOUNT_ID_KEY, accountId) + result.arguments = args + return result + } + + companion object { + @StringRes + private fun getRingPanelTitle(position: Int): Int { + return when (position) { + 0 -> R.string.account_preferences_basic_tab + 1 -> R.string.account_preferences_media_tab + 2 -> R.string.account_preferences_advanced_tab + else -> -1 + } + } + + @StringRes + private fun getSIPPanelTitle(position: Int): Int { + return when (position) { + 0 -> R.string.account_preferences_basic_tab + 1 -> R.string.account_preferences_media_tab + 2 -> R.string.account_preferences_advanced_tab + 3 -> R.string.account_preferences_security_tab + else -> -1 + } + } + } + } + + override fun onScrollChanged() { + setupElevation() + } + + private fun setupElevation() { + if (mBinding == null || !mIsVisible) { + return + } + val activity: FragmentActivity = activity as? HomeActivity ?: return + val ll = mBinding!!.pager.getChildAt(mBinding!!.pager.currentItem) as LinearLayout + val rv = (ll.getChildAt(0) as FrameLayout).getChildAt(0) as RecyclerView + val homeActivity = activity as HomeActivity + if (rv.canScrollVertically(SCROLL_DIRECTION_UP)) { + mBinding!!.slidingTabs.elevation = + mBinding!!.slidingTabs.resources.getDimension(R.dimen.toolbar_elevation) + homeActivity.setToolbarElevation(true) + homeActivity.setToolbarOutlineState(false) + } else { + mBinding!!.slidingTabs.elevation = 0f + homeActivity.setToolbarElevation(false) + homeActivity.setToolbarOutlineState(true) + } + } + + companion object { + private val TAG = AccountEditionFragment::class.simpleName + @JvmField + val ACCOUNT_ID_KEY = AccountEditionFragment::class.qualifiedName + "accountid" + @JvmField + val ACCOUNT_HAS_PASSWORD_KEY = + AccountEditionFragment::class.qualifiedName + "hasPassword" + private const val SCROLL_DIRECTION_UP = -1 + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/AccountWizardActivity.java b/ring-android/app/src/main/java/cx/ring/account/AccountWizardActivity.java deleted file mode 100644 index 7a695a0f7..000000000 --- a/ring-android/app/src/main/java/cx/ring/account/AccountWizardActivity.java +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.account; - -import android.app.Activity; -import android.app.ProgressDialog; -import android.content.Intent; -import android.content.pm.ActivityInfo; -import android.net.Uri; -import android.os.Bundle; - -import androidx.appcompat.app.AlertDialog; - -import android.text.TextUtils; -import android.widget.Toast; - -import java.io.File; -import java.util.List; - -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.client.HomeActivity; -import cx.ring.fragments.AccountMigrationFragment; -import cx.ring.fragments.SIPAccountCreationFragment; - -import net.jami.account.AccountWizardPresenter; -import net.jami.account.AccountWizardView; -import net.jami.model.Account; -import net.jami.model.AccountConfig; -import cx.ring.mvp.BaseActivity; -import net.jami.mvp.AccountCreationModel; -import net.jami.utils.VCardUtils; -import ezvcard.VCard; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class AccountWizardActivity extends BaseActivity<AccountWizardPresenter> implements AccountWizardView { - static final String TAG = AccountWizardActivity.class.getName(); - - private ProgressDialog mProgress = null; - private String mAccountType; - private AlertDialog mAlertDialog; - - @Override - public void onCreate(Bundle savedInstanceState) { - // dependency injection - JamiApplication.getInstance().getInjectionComponent().inject(this); - super.onCreate(savedInstanceState); - - JamiApplication.getInstance().startDaemon(); - setContentView(R.layout.activity_wizard); - - String accountToMigrate = null; - Intent intent = getIntent(); - if (intent != null) { - mAccountType = intent.getAction(); - Uri path = intent.getData(); - if (path != null) { - accountToMigrate = path.getLastPathSegment(); - } - } - if (mAccountType == null) { - mAccountType = AccountConfig.ACCOUNT_TYPE_RING; - } - - if (savedInstanceState == null) { - if (accountToMigrate != null) { - Bundle args = new Bundle(); - args.putString(AccountMigrationFragment.ACCOUNT_ID, getIntent().getData().getLastPathSegment()); - Fragment fragment = new AccountMigrationFragment(); - fragment.setArguments(args); - FragmentManager fragmentManager = getSupportFragmentManager(); - fragmentManager - .beginTransaction() - .replace(R.id.wizard_container, fragment) - .commit(); - } else { - presenter.init(getIntent().getAction() != null ? getIntent().getAction() : AccountConfig.ACCOUNT_TYPE_RING); - } - } - } - - @Override - public void onDestroy() { - if (mProgress != null) { - mProgress.dismiss(); - mProgress = null; - } - if (mAlertDialog != null) { - mAlertDialog.setOnDismissListener(null); - mAlertDialog.dismiss(); - mAlertDialog = null; - } - super.onDestroy(); - } - - @Override - public Single<VCard> saveProfile(final Account account, final AccountCreationModel accountCreationModel) { - File filedir = getFilesDir(); - return accountCreationModel.toVCard() - .flatMap(vcard -> { - account.resetProfile(); - return VCardUtils.saveLocalProfileToDisk(vcard, account.getAccountID(), filedir); - }) - .subscribeOn(Schedulers.io()); - } - - public void createAccount(AccountCreationModel accountCreationModel) { - if (!TextUtils.isEmpty(accountCreationModel.getManagementServer())) { - presenter.initJamiAccountConnect(accountCreationModel, - getText(R.string.ring_account_default_name).toString()); - } else if (accountCreationModel.isLink()) { - presenter.initJamiAccountLink(accountCreationModel, - getText(R.string.ring_account_default_name).toString()); - } else { - presenter.initJamiAccountCreation(accountCreationModel, - getText(R.string.ring_account_default_name).toString()); - } - } - - @Override - public void goToHomeCreation() { - Fragment fragment = new HomeAccountCreationFragment(); - FragmentManager fragmentManager = getSupportFragmentManager(); - fragmentManager.beginTransaction() - .replace(R.id.wizard_container, fragment, HomeAccountCreationFragment.TAG) - .commit(); - } - - @Override - public void goToSipCreation() { - Fragment fragment = new SIPAccountCreationFragment(); - FragmentManager fragmentManager = getSupportFragmentManager(); - fragmentManager.beginTransaction() - .replace(R.id.wizard_container, fragment, SIPAccountCreationFragment.TAG) - .commit(); - } - - @Override - public void goToProfileCreation(AccountCreationModel model) { - List<Fragment> fragments = getSupportFragmentManager().getFragments(); - if (fragments.size() > 0) { - Fragment fragment =fragments.get(0); - if (fragment instanceof JamiLinkAccountFragment) { - ((JamiLinkAccountFragment) fragment).scrollPagerFragment(model); - } else if (fragment instanceof JamiAccountConnectFragment) { - profileCreated(model, false); - } - } - } - - @Override - public void onBackPressed() { - Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.wizard_container); - if (fragment instanceof ProfileCreationFragment) - finish(); - else - super.onBackPressed(); - } - - @Override - public void displayProgress(final boolean display) { - if (display) { - mProgress = new ProgressDialog(AccountWizardActivity.this); - mProgress.setTitle(R.string.dialog_wait_create); - mProgress.setMessage(getString(R.string.dialog_wait_create_details)); - mProgress.setCancelable(false); - mProgress.setCanceledOnTouchOutside(false); - mProgress.show(); - } else { - if (mProgress != null) { - if (mProgress.isShowing()) { - mProgress.dismiss(); - } - mProgress = null; - } - } - } - - @Override - public void displayCreationError() { - Toast.makeText(AccountWizardActivity.this, "Error creating account", Toast.LENGTH_SHORT).show(); - } - - @Override - public void blockOrientation() { - //orientation is locked during the create of account to avoid the destruction of the thread - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LOCKED); - } - - @Override - public void finish(final boolean affinity) { - if (affinity) { - startActivity(new Intent(AccountWizardActivity.this, HomeActivity.class)); - finish(); - } else { - finishAffinity(); - } - } - - @Override - public void displayGenericError() { - if (mAlertDialog != null && mAlertDialog.isShowing()) { - return; - } - mAlertDialog = new MaterialAlertDialogBuilder(AccountWizardActivity.this) - .setPositiveButton(android.R.string.ok, null) - .setTitle(R.string.account_cannot_be_found_title) - .setMessage(R.string.account_export_end_decryption_message) - .show(); - } - - @Override - public void displayNetworkError() { - if (mAlertDialog != null && mAlertDialog.isShowing()) { - return; - } - mAlertDialog = new MaterialAlertDialogBuilder(AccountWizardActivity.this) - .setPositiveButton(android.R.string.ok, null) - .setTitle(R.string.account_no_network_title) - .setMessage(R.string.account_no_network_message) - .show(); - } - - @Override - public void displayCannotBeFoundError() { - if (mAlertDialog != null && mAlertDialog.isShowing()) { - return; - } - mAlertDialog = new MaterialAlertDialogBuilder(AccountWizardActivity.this) - .setPositiveButton(android.R.string.ok, null) - .setTitle(R.string.account_cannot_be_found_title) - .setMessage(R.string.account_cannot_be_found_message) - .setOnDismissListener(dialogInterface -> getSupportFragmentManager().popBackStack()) - .show(); - } - - @Override - public void displaySuccessDialog() { - if (mAlertDialog != null && mAlertDialog.isShowing()) { - return; - } - setResult(Activity.RESULT_OK, new Intent()); - //unlock the screen orientation - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR); - presenter.successDialogClosed(); - } - - public void profileCreated(AccountCreationModel accountCreationModel, boolean saveProfile) { - presenter.profileCreated(accountCreationModel, saveProfile); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/account/AccountWizardActivity.kt b/ring-android/app/src/main/java/cx/ring/account/AccountWizardActivity.kt new file mode 100644 index 000000000..058a4d0c6 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/account/AccountWizardActivity.kt @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.account + +import android.app.ProgressDialog +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.ActivityInfo +import android.os.Bundle +import android.text.TextUtils +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import cx.ring.R +import cx.ring.application.JamiApplication.Companion.instance +import cx.ring.client.HomeActivity +import cx.ring.fragments.AccountMigrationFragment +import cx.ring.fragments.SIPAccountCreationFragment +import cx.ring.mvp.BaseActivity +import dagger.hilt.android.AndroidEntryPoint +import ezvcard.VCard +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.account.AccountWizardPresenter +import net.jami.account.AccountWizardView +import net.jami.model.Account +import net.jami.model.AccountConfig +import net.jami.mvp.AccountCreationModel +import net.jami.utils.VCardUtils + +@AndroidEntryPoint +class AccountWizardActivity : BaseActivity<AccountWizardPresenter>(), AccountWizardView { + private var mProgress: ProgressDialog? = null + private var mAccountType: String? = null + private var mAlertDialog: AlertDialog? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + instance?.startDaemon() + setContentView(R.layout.activity_wizard) + var accountToMigrate: String? = null + val intent = intent + if (intent != null) { + mAccountType = intent.action + val path = intent.data + if (path != null) { + accountToMigrate = path.lastPathSegment + } + } + if (mAccountType == null) { + mAccountType = AccountConfig.ACCOUNT_TYPE_RING + } + if (savedInstanceState == null) { + if (accountToMigrate != null) { + val args = Bundle() + args.putString(AccountMigrationFragment.ACCOUNT_ID, getIntent().data!!.lastPathSegment) + val fragment: Fragment = AccountMigrationFragment() + fragment.arguments = args + val fragmentManager = supportFragmentManager + fragmentManager + .beginTransaction() + .replace(R.id.wizard_container, fragment) + .commit() + } else { + presenter.init(if (getIntent().action != null) getIntent().action else AccountConfig.ACCOUNT_TYPE_RING) + } + } + } + + override fun onDestroy() { + if (mProgress != null) { + mProgress!!.dismiss() + mProgress = null + } + if (mAlertDialog != null) { + mAlertDialog!!.setOnDismissListener(null) + mAlertDialog!!.dismiss() + mAlertDialog = null + } + super.onDestroy() + } + + override fun saveProfile(account: Account, accountCreationModel: AccountCreationModel): Single<VCard> { + val filedir = filesDir + return accountCreationModel.toVCard() + .flatMap { vcard: VCard -> + account.resetProfile() + VCardUtils.saveLocalProfileToDisk(vcard, account.accountID, filedir) + } + .subscribeOn(Schedulers.io()) + } + + fun createAccount(accountCreationModel: AccountCreationModel) { + if (!TextUtils.isEmpty(accountCreationModel.managementServer)) { + presenter.initJamiAccountConnect(accountCreationModel, getText(R.string.ring_account_default_name).toString()) + } else if (accountCreationModel.isLink) { + presenter.initJamiAccountLink(accountCreationModel, getText(R.string.ring_account_default_name).toString()) + } else { + presenter.initJamiAccountCreation(accountCreationModel, getText(R.string.ring_account_default_name).toString()) + } + } + + override fun goToHomeCreation() { + val fragmentManager = supportFragmentManager + fragmentManager.beginTransaction() + .replace(R.id.wizard_container, HomeAccountCreationFragment(), HomeAccountCreationFragment.TAG) + .commit() + } + + override fun goToSipCreation() { + val fragment: Fragment = SIPAccountCreationFragment() + val fragmentManager = supportFragmentManager + fragmentManager.beginTransaction() + .replace(R.id.wizard_container, fragment, SIPAccountCreationFragment.TAG) + .commit() + } + + override fun goToProfileCreation(model: AccountCreationModel) { + val fragments = supportFragmentManager.fragments + if (fragments.size > 0) { + val fragment = fragments[0] + if (fragment is JamiLinkAccountFragment) { + fragment.scrollPagerFragment(model) + } else if (fragment is JamiAccountConnectFragment) { + profileCreated(model, false) + } + } + } + + override fun onBackPressed() { + val fragment = supportFragmentManager.findFragmentById(R.id.wizard_container) + if (fragment is ProfileCreationFragment) finish() else super.onBackPressed() + } + + override fun displayProgress(display: Boolean) { + if (display) { + mProgress = ProgressDialog(this@AccountWizardActivity).apply { + setTitle(R.string.dialog_wait_create) + setMessage(getString(R.string.dialog_wait_create_details)) + setCancelable(false) + setCanceledOnTouchOutside(false) + show() + } + } else { + mProgress?.apply { + if (isShowing) dismiss() + mProgress = null + } + } + } + + override fun displayCreationError() { + Toast.makeText(this@AccountWizardActivity, "Error creating account", Toast.LENGTH_SHORT) + .show() + } + + override fun blockOrientation() { + //orientation is locked during the create of account to avoid the destruction of the thread + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + } + + override fun finish(affinity: Boolean) { + if (affinity) { + startActivity(Intent(this@AccountWizardActivity, HomeActivity::class.java)) + finish() + } else { + finishAffinity() + } + } + + override fun displayGenericError() { + if (mAlertDialog != null && mAlertDialog!!.isShowing) { + return + } + mAlertDialog = MaterialAlertDialogBuilder(this@AccountWizardActivity) + .setPositiveButton(android.R.string.ok, null) + .setTitle(R.string.account_cannot_be_found_title) + .setMessage(R.string.account_export_end_decryption_message) + .show() + } + + override fun displayNetworkError() { + if (mAlertDialog != null && mAlertDialog!!.isShowing) { + return + } + mAlertDialog = MaterialAlertDialogBuilder(this@AccountWizardActivity) + .setPositiveButton(android.R.string.ok, null) + .setTitle(R.string.account_no_network_title) + .setMessage(R.string.account_no_network_message) + .show() + } + + override fun displayCannotBeFoundError() { + if (mAlertDialog != null && mAlertDialog!!.isShowing) { + return + } + mAlertDialog = MaterialAlertDialogBuilder(this@AccountWizardActivity) + .setPositiveButton(android.R.string.ok, null) + .setTitle(R.string.account_cannot_be_found_title) + .setMessage(R.string.account_cannot_be_found_message) + .setOnDismissListener { dialogInterface: DialogInterface? -> supportFragmentManager.popBackStack() } + .show() + } + + override fun displaySuccessDialog() { + if (mAlertDialog != null && mAlertDialog!!.isShowing) { + return + } + setResult(RESULT_OK, Intent()) + //unlock the screen orientation + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR + presenter.successDialogClosed() + } + + fun profileCreated(accountCreationModel: AccountCreationModel?, saveProfile: Boolean) { + presenter.profileCreated(accountCreationModel, saveProfile) + } + + companion object { + val TAG = AccountWizardActivity::class.java.name + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/DeviceAdapter.java b/ring-android/app/src/main/java/cx/ring/account/DeviceAdapter.java deleted file mode 100644 index 7520e6121..000000000 --- a/ring-android/app/src/main/java/cx/ring/account/DeviceAdapter.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.account; - -import android.content.Context; -import android.graphics.Typeface; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.StyleSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.BaseAdapter; -import android.widget.TextView; - -import java.util.ArrayList; -import java.util.Map; - -import cx.ring.R; -import cx.ring.views.TwoButtonEditText; - -public class DeviceAdapter extends BaseAdapter { - private final Context mContext; - private final ArrayList<Map.Entry<String, String>> mDevices = new ArrayList<>(); - - private String mCurrentDeviceId; - private DeviceRevocationListener mListener; - - public DeviceAdapter(Context c, Map<String, String> devices, String currentDeviceId, - DeviceRevocationListener listener) { - mContext = c; - setData(devices, currentDeviceId); - mListener = listener; - } - - public void setData(Map<String, String> devices, String currentDeviceId) { - mDevices.clear(); - mCurrentDeviceId = currentDeviceId; - if (devices != null && !devices.isEmpty()) { - mDevices.ensureCapacity(devices.size()); - mDevices.addAll(devices.entrySet()); - } - for (int i = 0; i < mDevices.size(); i++) { - if(mDevices.get(i).getKey().contentEquals(mCurrentDeviceId)) { - mDevices.remove(mDevices.get(i)); - } - } - notifyDataSetChanged(); - } - - @Override - public int getCount() { - return mDevices.size(); - } - - @Override - public Object getItem(int i) { - return mDevices.get(i); - } - - @Override - public long getItemId(int i) { - return 0; - } - - @Override - public View getView(final int i, View view, ViewGroup parent) { - if (view == null) { - view = LayoutInflater.from(mContext).inflate(R.layout.item_device, parent, false); - } - boolean isCurrentDevice = mDevices.get(i).getKey().contentEquals(mCurrentDeviceId); - - TwoButtonEditText devId = view.findViewById(R.id.txt_device_id); - TextView thisDevice = view.findViewById(R.id.txt_device_thisflag); - devId.setText(mDevices.get(i).getValue()); - String hint = mDevices.get(i).getKey(); - hint = hint.substring(0, (int) (hint.length() * 0.66)); - devId.setHint(hint); - - if (isCurrentDevice) { - thisDevice.setVisibility(View.VISIBLE); - devId.setLeftDrawable(R.drawable.baseline_edit_twoton_24dp); - devId.setLeftDrawableOnClickListener(view1 -> { - if (mListener != null) { - mListener.onDeviceRename(); - } - }); - } else { - thisDevice.setVisibility(View.GONE); - devId.setLeftDrawable(R.drawable.baseline_cancel_24); - devId.setLeftDrawableOnClickListener(view12 -> { - if (mListener != null) { - mListener.onDeviceRevocationAsked(mDevices.get(i).getKey()); - } - }); - } - - return view; - } - - public interface DeviceRevocationListener { - void onDeviceRevocationAsked(String deviceId); - - void onDeviceRename(); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/account/DeviceAdapter.kt b/ring-android/app/src/main/java/cx/ring/account/DeviceAdapter.kt new file mode 100644 index 000000000..f7807595f --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/account/DeviceAdapter.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.account + +import android.content.Context +import android.widget.BaseAdapter +import android.view.ViewGroup +import android.view.LayoutInflater +import android.view.View +import cx.ring.R +import cx.ring.views.TwoButtonEditText +import android.widget.TextView +import java.util.ArrayList + +class DeviceAdapter( + private val mContext: Context, devices: Map<String, String>?, + currentDeviceId: String?, + private val mListener: DeviceRevocationListener +) : BaseAdapter() { + private val mDevices = ArrayList<Map.Entry<String, String>>() + private var mCurrentDeviceId: String? = null + + fun setData(devices: Map<String, String>?, currentDeviceId: String?) { + mDevices.clear() + mCurrentDeviceId = currentDeviceId + if (devices != null && devices.isNotEmpty()) { + mDevices.ensureCapacity(devices.size) + mDevices.addAll(devices.entries) + } + mDevices.removeAll { d ->d.key == mCurrentDeviceId } + notifyDataSetChanged() + } + + override fun getCount(): Int { + return mDevices.size + } + + override fun getItem(i: Int): Any { + return mDevices[i] + } + + override fun getItemId(i: Int): Long { + return 0 + } + + override fun getView(i: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(mContext).inflate(R.layout.item_device, parent, false) + val isCurrentDevice = mDevices[i].key.contentEquals(mCurrentDeviceId) + val devId: TwoButtonEditText = view.findViewById(R.id.txt_device_id) + val thisDevice = view.findViewById<TextView>(R.id.txt_device_thisflag) + devId.setText(mDevices[i].value) + var hint = mDevices[i].key + hint = hint.substring(0, (hint.length * 0.66).toInt()) + devId.setHint(hint) + if (isCurrentDevice) { + thisDevice.visibility = View.VISIBLE + devId.setLeftDrawable(R.drawable.baseline_edit_twoton_24dp) + devId.setLeftDrawableOnClickListener { mListener.onDeviceRename() } + } else { + thisDevice.visibility = View.GONE + devId.setLeftDrawable(R.drawable.baseline_cancel_24) + devId.setLeftDrawableOnClickListener { mListener.onDeviceRevocationAsked(mDevices[i].key) } + } + return view + } + + interface DeviceRevocationListener { + fun onDeviceRevocationAsked(deviceId: String?) + fun onDeviceRename() + } + + init { + setData(devices, currentDeviceId) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/HomeAccountCreationFragment.java b/ring-android/app/src/main/java/cx/ring/account/HomeAccountCreationFragment.java deleted file mode 100644 index a5352ba5a..000000000 --- a/ring-android/app/src/main/java/cx/ring/account/HomeAccountCreationFragment.java +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.account; - -import android.app.Activity; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import com.google.android.material.snackbar.Snackbar; - -import net.jami.account.HomeAccountCreationPresenter; -import net.jami.account.HomeAccountCreationView; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.databinding.FragAccHomeCreateBinding; -import cx.ring.mvp.BaseSupportFragment; -import cx.ring.utils.AndroidFileUtils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; - -public class HomeAccountCreationFragment extends BaseSupportFragment<HomeAccountCreationPresenter> implements HomeAccountCreationView { - private static final int ARCHIVE_REQUEST_CODE = 42; - - public static final String TAG = HomeAccountCreationFragment.class.getSimpleName(); - - private FragAccHomeCreateBinding binding; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragAccHomeCreateBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - setRetainInstance(true); - binding.ringAddAccount.setOnClickListener(v -> presenter.clickOnLinkAccount()); - binding.ringCreateBtn.setOnClickListener(v -> presenter.clickOnCreateAccount()); - binding.accountConnectServer.setOnClickListener(v -> presenter.clickOnConnectAccount()); - binding.ringImportAccount.setOnClickListener(v -> performFileSearch()); - } - - @Override - public void goToAccountCreation() { - Fragment fragment = new JamiAccountCreationFragment(); - replaceFragmentWithSlide(fragment, R.id.wizard_container); - } - - @Override - public void goToAccountLink() { - AccountCreationModelImpl ringAccountViewModel = new AccountCreationModelImpl(); - ringAccountViewModel.setLink(true); - Fragment fragment = JamiLinkAccountFragment.newInstance(ringAccountViewModel); - replaceFragmentWithSlide(fragment, R.id.wizard_container); - } - - @Override - public void goToAccountConnect() { - AccountCreationModelImpl ringAccountViewModel = new AccountCreationModelImpl(); - ringAccountViewModel.setLink(true); - Fragment fragment = JamiAccountConnectFragment.newInstance(ringAccountViewModel); - replaceFragmentWithSlide(fragment, R.id.wizard_container); - } - - private void performFileSearch() { - try { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("*/*"); - startActivityForResult(intent, ARCHIVE_REQUEST_CODE); - } catch (Exception e) { - View v = getView(); - if (v != null) - Snackbar.make(v, "No file browser available on this device", Snackbar.LENGTH_SHORT).show(); - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent resultData) { - if (requestCode == ARCHIVE_REQUEST_CODE && resultCode == Activity.RESULT_OK) { - if (resultData != null) { - Uri uri = resultData.getData(); - if (uri != null) { - AndroidFileUtils.getCacheFile(requireContext(), uri) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(file -> { - AccountCreationModelImpl ringAccountViewModel = new AccountCreationModelImpl(); - ringAccountViewModel.setLink(true); - ringAccountViewModel.setArchive(file); - Fragment fragment = JamiLinkAccountFragment.newInstance(ringAccountViewModel); - replaceFragmentWithSlide(fragment, R.id.wizard_container); - }, e-> { - View v = getView(); - if (v != null) - Snackbar.make(v, "Can't import archive: " + e.getMessage(), Snackbar.LENGTH_LONG).show(); - }); - } - } - } - } -} diff --git a/ring-android/app/src/main/java/cx/ring/account/HomeAccountCreationFragment.kt b/ring-android/app/src/main/java/cx/ring/account/HomeAccountCreationFragment.kt new file mode 100644 index 000000000..de60c5126 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/account/HomeAccountCreationFragment.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.account + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.google.android.material.snackbar.Snackbar +import cx.ring.R +import cx.ring.account.JamiLinkAccountFragment.Companion.newInstance +import cx.ring.databinding.FragAccHomeCreateBinding +import cx.ring.mvp.BaseSupportFragment +import cx.ring.utils.AndroidFileUtils.getCacheFile +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import net.jami.account.HomeAccountCreationPresenter +import net.jami.account.HomeAccountCreationView +import java.io.File + +@AndroidEntryPoint +class HomeAccountCreationFragment : + BaseSupportFragment<HomeAccountCreationPresenter, HomeAccountCreationView>(), + HomeAccountCreationView { + private var binding: FragAccHomeCreateBinding? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragAccHomeCreateBinding.inflate(inflater, container, false) + return binding!!.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + retainInstance = true + binding!!.ringAddAccount.setOnClickListener { v: View? -> presenter.clickOnLinkAccount() } + binding!!.ringCreateBtn.setOnClickListener { v: View? -> presenter.clickOnCreateAccount() } + binding!!.accountConnectServer.setOnClickListener { v: View? -> presenter.clickOnConnectAccount() } + binding!!.ringImportAccount.setOnClickListener { v: View? -> performFileSearch() } + } + + override fun goToAccountCreation() { + val fragment: Fragment = JamiAccountCreationFragment() + replaceFragmentWithSlide(fragment, R.id.wizard_container) + } + + override fun goToAccountLink() { + val ringAccountViewModel = AccountCreationModelImpl() + ringAccountViewModel.isLink = true + val fragment: Fragment = newInstance(ringAccountViewModel) + replaceFragmentWithSlide(fragment, R.id.wizard_container) + } + + override fun goToAccountConnect() { + val ringAccountViewModel = AccountCreationModelImpl() + ringAccountViewModel.isLink = true + val fragment: Fragment = JamiAccountConnectFragment.newInstance(ringAccountViewModel) + replaceFragmentWithSlide(fragment, R.id.wizard_container) + } + + private fun performFileSearch() { + try { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + startActivityForResult(intent, ARCHIVE_REQUEST_CODE) + } catch (e: Exception) { + val v = view + if (v != null) Snackbar.make( + v, + "No file browser available on this device", + Snackbar.LENGTH_SHORT + ).show() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { + if (requestCode == ARCHIVE_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + if (resultData != null) { + val uri = resultData.data + if (uri != null) { + getCacheFile(requireContext(), uri) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ file: File? -> + val ringAccountViewModel = AccountCreationModelImpl() + ringAccountViewModel.isLink = true + ringAccountViewModel.archive = file + val fragment: Fragment = newInstance(ringAccountViewModel) + replaceFragmentWithSlide(fragment, R.id.wizard_container) + }) { e: Throwable -> + val v = view + if (v != null) + Snackbar.make(v, "Can't import archive: " + e.message, Snackbar.LENGTH_LONG).show() + } + } + } + } + } + + companion object { + private const val ARCHIVE_REQUEST_CODE = 42 + val TAG = HomeAccountCreationFragment::class.simpleName!! + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/JamiAccountConnectFragment.java b/ring-android/app/src/main/java/cx/ring/account/JamiAccountConnectFragment.java index 88ce30f24..ded0fab55 100644 --- a/ring-android/app/src/main/java/cx/ring/account/JamiAccountConnectFragment.java +++ b/ring-android/app/src/main/java/cx/ring/account/JamiAccountConnectFragment.java @@ -38,8 +38,10 @@ import net.jami.account.JamiAccountConnectPresenter; import net.jami.account.JamiConnectAccountView; import net.jami.mvp.AccountCreationModel; import cx.ring.mvp.BaseSupportFragment; +import dagger.hilt.android.AndroidEntryPoint; -public class JamiAccountConnectFragment extends BaseSupportFragment<JamiAccountConnectPresenter> implements JamiConnectAccountView { +@AndroidEntryPoint +public class JamiAccountConnectFragment extends BaseSupportFragment<JamiAccountConnectPresenter, JamiConnectAccountView> implements JamiConnectAccountView { public static final String TAG = JamiAccountConnectFragment.class.getSimpleName(); private AccountCreationModel model; @@ -55,7 +57,6 @@ public class JamiAccountConnectFragment extends BaseSupportFragment<JamiAccountC @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { mBinding = FragAccJamiConnectBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); return mBinding.getRoot(); } diff --git a/ring-android/app/src/main/java/cx/ring/account/JamiAccountCreationFragment.java b/ring-android/app/src/main/java/cx/ring/account/JamiAccountCreationFragment.java deleted file mode 100644 index 9d6e1f8d2..000000000 --- a/ring-android/app/src/main/java/cx/ring/account/JamiAccountCreationFragment.java +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.account; - -import android.content.Context; -import android.os.Bundle; -import android.util.SparseArray; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; - -import androidx.activity.OnBackPressedCallback; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapter; -import androidx.viewpager.widget.ViewPager; - -import cx.ring.databinding.FragAccJamiCreateBinding; -import net.jami.mvp.AccountCreationModel; - -import cx.ring.views.WizardViewPager; - -public class JamiAccountCreationFragment extends Fragment { - - private static final int NUM_PAGES = 3; - - private FragAccJamiCreateBinding mBinding; - private Fragment mCurrentFragment; - - private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { - @Override - public void handleOnBackPressed() { - if (mCurrentFragment instanceof ProfileCreationFragment) { - ProfileCreationFragment fragment = (ProfileCreationFragment) mCurrentFragment; - ((AccountWizardActivity) getActivity()).profileCreated(fragment.getModel(), false); - return; - } - mBinding.pager.setCurrentItem(mBinding.pager.getCurrentItem() - 1); - } - }; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - mBinding = FragAccJamiCreateBinding.inflate(inflater, container, false); - return mBinding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - mBinding = null; - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - setRetainInstance(true); - - ScreenSlidePagerAdapter pagerAdapter = new ScreenSlidePagerAdapter(getChildFragmentManager()); - mBinding.pager.setAdapter(pagerAdapter); - mBinding.pager.disableScroll(true); - mBinding.pager.setOffscreenPageLimit(1); - mBinding.indicator.setupWithViewPager(mBinding.pager, true); - - mBinding.pager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() { - @Override - public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { - } - - @Override - public void onPageSelected(int position) { - mCurrentFragment = pagerAdapter.getRegisteredFragment(position); - boolean enable = mCurrentFragment instanceof JamiAccountPasswordFragment || mCurrentFragment instanceof ProfileCreationFragment; - onBackPressedCallback.setEnabled(enable); - } - - @Override - public void onPageScrollStateChanged(int state) { - - } - }); - - LinearLayout tabStrip = ((LinearLayout) mBinding.indicator.getChildAt(0)); - for(int i = 0; i < tabStrip.getChildCount(); i++) { - tabStrip.getChildAt(i).setOnTouchListener((v, event) -> true); - } - } - - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - requireActivity().getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); - } - - public void scrollPagerFragment(AccountCreationModel accountCreationModel) { - if (accountCreationModel == null) { - mBinding.pager.setCurrentItem(mBinding.pager.getCurrentItem() - 1); - return; - } - mBinding.pager.setCurrentItem(mBinding.pager.getCurrentItem() + 1); - for (Fragment fragment : getChildFragmentManager().getFragments()) { - if (fragment instanceof JamiAccountPasswordFragment) { - ((JamiAccountPasswordFragment) fragment).setUsername(accountCreationModel.getUsername()); - } - } - } - - private static class ScreenSlidePagerAdapter extends FragmentStatePagerAdapter { - - SparseArray<Fragment> mRegisteredFragments = new SparseArray<>(); - private int mCurrentPosition = -1; - - public ScreenSlidePagerAdapter(FragmentManager fm) { - super(fm); - } - - @NonNull - @Override - public Fragment getItem(int position) { - Fragment fragment = null; - AccountCreationModelImpl ringAccountViewModel = new AccountCreationModelImpl(); - switch (position) { - case 0: - fragment = JamiAccountUsernameFragment.newInstance(ringAccountViewModel); - break; - case 1: - fragment = JamiAccountPasswordFragment.newInstance(ringAccountViewModel); - break; - case 2: - fragment = ProfileCreationFragment.newInstance(ringAccountViewModel); - break; - } - return fragment; - } - - @Override - public void setPrimaryItem(@NonNull ViewGroup container, int position, @NonNull Object object) { - super.setPrimaryItem(container, position, object); - - if (position != mCurrentPosition && container instanceof WizardViewPager) { - Fragment fragment = (Fragment) object; - WizardViewPager pager = (WizardViewPager) container; - - if (fragment.getView() != null) { - mCurrentPosition = position; - pager.measureCurrentView(fragment.getView()); - } - } - } - - @NonNull - @Override - public Object instantiateItem(@NonNull ViewGroup container, int position) { - Fragment fragment = (Fragment) super.instantiateItem(container, position); - mRegisteredFragments.put(position, fragment); - return super.instantiateItem(container, position); - } - - @Override - public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { - mRegisteredFragments.remove(position); - super.destroyItem(container, position, object); - } - - @Override - public int getCount() { - return NUM_PAGES; - } - - public Fragment getRegisteredFragment(int position) { - return mRegisteredFragments.get(position); - } - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/account/JamiAccountCreationFragment.kt b/ring-android/app/src/main/java/cx/ring/account/JamiAccountCreationFragment.kt new file mode 100644 index 000000000..224be8c8c --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/account/JamiAccountCreationFragment.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.account + +import android.content.Context +import android.os.Bundle +import android.util.SparseArray +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter +import androidx.viewpager.widget.ViewPager.OnPageChangeListener +import cx.ring.databinding.FragAccJamiCreateBinding +import cx.ring.views.WizardViewPager +import net.jami.mvp.AccountCreationModel + +class JamiAccountCreationFragment : Fragment() { + private var mBinding: FragAccJamiCreateBinding? = null + private var mCurrentFragment: Fragment? = null + private val onBackPressedCallback: OnBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (mCurrentFragment is ProfileCreationFragment) { + val fragment = mCurrentFragment as ProfileCreationFragment + (activity as AccountWizardActivity?)?.profileCreated(fragment.model, false) + return + } + mBinding!!.pager.currentItem = mBinding!!.pager.currentItem - 1 + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,savedInstanceState: Bundle?): View { + return FragAccJamiCreateBinding.inflate(inflater, container, false).apply { mBinding = this }.root + } + + override fun onDestroyView() { + super.onDestroyView() + mBinding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + retainInstance = true + val pagerAdapter = ScreenSlidePagerAdapter(childFragmentManager) + mBinding!!.pager.adapter = pagerAdapter + mBinding!!.pager.disableScroll(true) + mBinding!!.pager.offscreenPageLimit = 1 + mBinding!!.indicator.setupWithViewPager(mBinding!!.pager, true) + mBinding!!.pager.addOnPageChangeListener(object : OnPageChangeListener { + override fun onPageScrolled(position: Int,positionOffset: Float, positionOffsetPixels: Int) {} + + override fun onPageSelected(position: Int) { + mCurrentFragment = pagerAdapter.getRegisteredFragment(position) + val enable = mCurrentFragment is JamiAccountPasswordFragment || mCurrentFragment is ProfileCreationFragment + onBackPressedCallback.isEnabled = enable + } + + override fun onPageScrollStateChanged(state: Int) {} + }) + val tabStrip = mBinding!!.indicator.getChildAt(0) as LinearLayout + for (i in 0 until tabStrip.childCount) { + tabStrip.getChildAt(i).setOnTouchListener { v, event -> true } + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + requireActivity().onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } + + fun scrollPagerFragment(accountCreationModel: AccountCreationModel?) { + if (accountCreationModel == null) { + mBinding!!.pager.currentItem = mBinding!!.pager.currentItem - 1 + return + } + mBinding!!.pager.currentItem = mBinding!!.pager.currentItem + 1 + for (fragment in childFragmentManager.fragments) { + if (fragment is JamiAccountPasswordFragment) { + fragment.setUsername(accountCreationModel.username) + } + } + } + + private class ScreenSlidePagerAdapter(fm: FragmentManager) : FragmentStatePagerAdapter(fm) { + var mRegisteredFragments = SparseArray<Fragment>() + private var mCurrentPosition = -1 + override fun getItem(position: Int): Fragment { + var fragment: Fragment? = null + val ringAccountViewModel = AccountCreationModelImpl() + when (position) { + 0 -> fragment = JamiAccountUsernameFragment.newInstance(ringAccountViewModel) + 1 -> fragment = JamiAccountPasswordFragment.newInstance(ringAccountViewModel) + 2 -> fragment = ProfileCreationFragment.newInstance(ringAccountViewModel) + } + return fragment!! + } + + override fun setPrimaryItem(container: ViewGroup, position: Int, `object`: Any) { + super.setPrimaryItem(container, position, `object`) + if (position != mCurrentPosition && container is WizardViewPager) { + val fragment = `object` as Fragment + if (fragment.view != null) { + mCurrentPosition = position + container.measureCurrentView(fragment.view) + } + } + } + + override fun instantiateItem(container: ViewGroup, position: Int): Any { + val fragment = super.instantiateItem(container, position) as Fragment + mRegisteredFragments.put(position, fragment) + return super.instantiateItem(container, position) + } + + override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { + mRegisteredFragments.remove(position) + super.destroyItem(container, position, `object`) + } + + override fun getCount(): Int { + return NUM_PAGES + } + + fun getRegisteredFragment(position: Int): Fragment? { + return mRegisteredFragments[position] + } + } + + companion object { + private const val NUM_PAGES = 3 + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/JamiAccountPasswordFragment.java b/ring-android/app/src/main/java/cx/ring/account/JamiAccountPasswordFragment.java index 6aef62e1b..d818e7d23 100644 --- a/ring-android/app/src/main/java/cx/ring/account/JamiAccountPasswordFragment.java +++ b/ring-android/app/src/main/java/cx/ring/account/JamiAccountPasswordFragment.java @@ -34,15 +34,16 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import cx.ring.R; -import cx.ring.application.JamiApplication; import cx.ring.databinding.FragAccJamiPasswordBinding; import net.jami.account.JamiAccountCreationPresenter; import net.jami.account.JamiAccountCreationView; import net.jami.mvp.AccountCreationModel; import cx.ring.mvp.BaseSupportFragment; +import dagger.hilt.android.AndroidEntryPoint; -public class JamiAccountPasswordFragment extends BaseSupportFragment<JamiAccountCreationPresenter> +@AndroidEntryPoint +public class JamiAccountPasswordFragment extends BaseSupportFragment<JamiAccountCreationPresenter, JamiAccountCreationView> implements JamiAccountCreationView { private static final String KEY_MODEL = "model"; @@ -71,7 +72,6 @@ public class JamiAccountPasswordFragment extends BaseSupportFragment<JamiAccount model = (AccountCreationModelImpl) savedInstanceState.getSerializable(KEY_MODEL); } binding = FragAccJamiPasswordBinding.inflate(inflater, container, false); - ((JamiApplication) requireActivity().getApplication()).getInjectionComponent().inject(this); return binding.getRoot(); } diff --git a/ring-android/app/src/main/java/cx/ring/account/JamiAccountSummaryFragment.java b/ring-android/app/src/main/java/cx/ring/account/JamiAccountSummaryFragment.java deleted file mode 100644 index 7e9043ee0..000000000 --- a/ring-android/app/src/main/java/cx/ring/account/JamiAccountSummaryFragment.java +++ /dev/null @@ -1,832 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Loïc Siret <loic.siret@savoirfairelinux.com> - * Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.account; - -import android.Manifest; -import android.animation.Animator; -import android.animation.ValueAnimator; -import android.app.Activity; -import android.app.ProgressDialog; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.Bundle; -import android.provider.MediaStore; -import android.text.Editable; -import android.text.TextUtils; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.Toast; - -import androidx.activity.OnBackPressedCallback; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentTransaction; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.snackbar.Snackbar; - -import net.jami.account.JamiAccountSummaryPresenter; -import net.jami.account.JamiAccountSummaryView; -import net.jami.model.Account; -import net.jami.utils.StringUtils; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.client.HomeActivity; -import cx.ring.contactrequests.BlockListFragment; -import cx.ring.databinding.FragAccSummaryBinding; -import cx.ring.fragments.AdvancedAccountFragment; -import cx.ring.fragments.GeneralAccountFragment; -import cx.ring.fragments.LinkDeviceFragment; -import cx.ring.fragments.MediaPreferenceFragment; -import cx.ring.fragments.QRCodeFragment; -import cx.ring.mvp.BaseSupportFragment; -import cx.ring.settings.AccountFragment; -import cx.ring.settings.SettingsFragment; -import cx.ring.utils.AndroidFileUtils; -import cx.ring.utils.BitmapUtils; -import cx.ring.utils.ContentUriHandler; -import cx.ring.views.AvatarDrawable; -import cx.ring.views.SwitchButton; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class JamiAccountSummaryFragment extends BaseSupportFragment<JamiAccountSummaryPresenter> implements - RegisterNameDialog.RegisterNameDialogListener, - JamiAccountSummaryView, ChangePasswordDialog.PasswordChangedListener, - BackupAccountDialog.UnlockAccountListener, - ViewTreeObserver.OnScrollChangedListener, - RenameDeviceDialog.RenameDeviceListener, - DeviceAdapter.DeviceRevocationListener, - ConfirmRevocationDialog.ConfirmRevocationListener { - - public static final String TAG = JamiAccountSummaryFragment.class.getSimpleName(); - private static final String FRAGMENT_DIALOG_REVOCATION = TAG + ".dialog.deviceRevocation"; - private static final String FRAGMENT_DIALOG_RENAME = TAG + ".dialog.deviceRename"; - - private static final String FRAGMENT_DIALOG_PASSWORD = TAG + ".dialog.changePassword"; - private static final String FRAGMENT_DIALOG_BACKUP = TAG + ".dialog.backup"; - private static final int WRITE_REQUEST_CODE = 43; - private static final int SCROLL_DIRECTION_UP = -1; - - private final OnBackPressedCallback mOnBackPressedCallback = new OnBackPressedCallback(false) { - @Override - public void handleOnBackPressed() { - if (mBinding.fragment.getVisibility() == View.VISIBLE) { - mBinding.fragment.setVisibility(View.GONE); - mOnBackPressedCallback.setEnabled(false); - getChildFragmentManager().popBackStack(); - } - } - }; - - private ProgressDialog mWaitDialog; - private boolean mAccountHasPassword = true; - private String mBestName = ""; - private String mAccountId = ""; - private File mCacheArchive = null; - private ImageView mProfilePhoto; - private Bitmap mSourcePhoto; - private Uri tmpProfilePhotoUri; - private DeviceAdapter mDeviceAdapter; - - private final CompositeDisposable mDisposableBag = new CompositeDisposable(); - private final CompositeDisposable mProfileDisposable = new CompositeDisposable(); - private FragAccSummaryBinding mBinding; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - mBinding = FragAccSummaryBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - mDisposableBag.add(mProfileDisposable); - return mBinding.getRoot(); - } - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - requireActivity().getOnBackPressedDispatcher().addCallback(this, mOnBackPressedCallback); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - mDisposableBag.clear(); - mBinding = null; - } - - @Override - public void onDestroy() { - super.onDestroy(); - mDisposableBag.dispose(); - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - if (getArguments() != null) { - mAccountId = getArguments().getString(AccountEditionFragment.ACCOUNT_ID_KEY); - if (mAccountId != null) { - presenter.setAccountId(mAccountId); - } - } - - mBinding.scrollview.getViewTreeObserver().addOnScrollChangedListener(this); - mBinding.linkNewDevice.setOnClickListener(v -> showWizard(mAccountId)); - mBinding.linkedDevices.setRightDrawableOnClickListener(v -> onDeviceRename()); - mBinding.registerName.setOnClickListener(v -> showUsernameRegistrationPopup()); - - List<SettingItem> items = new ArrayList<>(4); - items.add(new SettingItem(R.string.account, R.drawable.baseline_account_card_details, () -> presenter.goToAccount())); - items.add(new SettingItem(R.string.account_preferences_media_tab, R.drawable.outline_file_copy_24, () -> presenter.goToMedia())); - items.add(new SettingItem(R.string.notif_channel_messages, R.drawable.baseline_chat_24, () -> presenter.goToSystem())); - items.add(new SettingItem(R.string.account_preferences_advanced_tab, R.drawable.round_check_circle_24, () -> presenter.goToAdvanced())); - - SettingsAdapter adapter = new SettingsAdapter(view.getContext(), R.layout.item_setting, items); - mBinding.settingsList.setOnItemClickListener((adapterView, v, i, l) -> adapter.getItem(i).onClick()); - mBinding.settingsList.setAdapter(adapter); - - int totalHeight = 0; - for (int i = 0; i < adapter.getCount(); i++) { - View listItem = adapter.getView(i, null, mBinding.settingsList); - listItem.measure(0, 0); - totalHeight += listItem.getMeasuredHeight(); - } - - ViewGroup.LayoutParams par = mBinding.settingsList.getLayoutParams(); - par.height = totalHeight + (mBinding.settingsList.getDividerHeight() * (adapter.getCount() - 1)); - mBinding.settingsList.setLayoutParams(par); - mBinding.settingsList.requestLayout(); - - mBinding.chipMore.setOnClickListener(v -> { - if (mBinding.devicesList.getVisibility() == View.GONE) { - expand(mBinding.devicesList); - } else { - collapse(mBinding.devicesList); - } - }); - } - - @Override - public void onResume() { - super.onResume(); - ((HomeActivity) requireActivity()).showAccountStatus(true); - ((HomeActivity) requireActivity()).getSwitchButton().setOnCheckedChangeListener((buttonView, isChecked) -> presenter.enableAccount(isChecked)); - } - - @Override - public void onPause() { - super.onPause(); - ((HomeActivity) requireActivity()).showAccountStatus(false); - ((HomeActivity) requireActivity()).getSwitchButton().setOnCheckedChangeListener(null); - } - - public void setAccount(String accountId) { - if (presenter != null) - presenter.setAccountId(accountId); - } - - @Override - public void updateUserView(Account account) { - Context context = getContext(); - if (context == null || account == null) - return; - - mProfileDisposable.clear(); - mProfileDisposable.add(AvatarDrawable.load(context, account) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(avatar -> { - if (mBinding != null) { - mBinding.userPhoto.setImageDrawable(avatar); - mBinding.username.setText(account.getLoadedProfile().blockingGet().first); - } - }, e -> Log.e(TAG, "Error loading avatar", e))); - } - - public void onActivityResult(int requestCode, int resultCode, Intent resultData) { - switch (requestCode) { - case WRITE_REQUEST_CODE: - if (resultCode == Activity.RESULT_OK) { - if (resultData != null) { - Uri uri = resultData.getData(); - if (uri != null) { - if (mCacheArchive != null) { - AndroidFileUtils.moveToUri(requireContext().getContentResolver(), mCacheArchive, uri) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(() -> {}, e -> { - View v = getView(); - if (v != null) - Snackbar.make(v, "Can't export archive: " + e.getMessage(), Snackbar.LENGTH_LONG).show(); - }); - } - } - } - } - break; - case HomeActivity.REQUEST_CODE_PHOTO: - if (resultCode == Activity.RESULT_OK) { - if (tmpProfilePhotoUri == null) { - if (resultData != null) - updatePhoto(Single.just((Bitmap) resultData.getExtras().get("data"))); - } else { - updatePhoto(tmpProfilePhotoUri); - } - } - tmpProfilePhotoUri = null; - break; - case HomeActivity.REQUEST_CODE_GALLERY: - if (resultCode == Activity.RESULT_OK && resultData != null) { - updatePhoto(resultData.getData()); - } - break; - } - } - - @Override - public void accountChanged(@NonNull final Account account) { - updateUserView(account); - mBinding.userPhoto.setOnClickListener(v -> profileContainerClicked(account)); - mBinding.linkedDevices.setText(account.getDeviceName()); - setLinkedDevicesAdapter(account); - mAccountHasPassword = account.hasPassword(); - - ((HomeActivity) requireActivity()).getSwitchButton().setCheckedSilent(account.isEnabled()); - mBinding.accountAliasTxt.setText(getString(R.string.profile)); - mBinding.identity.setText(account.getUsername()); - mAccountId = account.getAccountID(); - mBestName = account.getRegisteredName(); - if (mBestName.isEmpty()) { - mBestName = account.getDisplayUsername(); - if (mBestName.isEmpty()) { - mBestName = account.getUsername(); - } - } - mBestName = mBestName + ".gz"; - String username = account.getRegisteredName(); - boolean currentRegisteredName = account.registeringUsername; - boolean hasRegisteredName = !currentRegisteredName && username != null && !username.isEmpty(); - mBinding.groupRegisteringName.setVisibility(currentRegisteredName ? View.VISIBLE : View.GONE); - mBinding.btnShare.setOnClickListener(v -> shareAccount(hasRegisteredName? username : account.getUsername())); - mBinding.registerName.setVisibility(hasRegisteredName ? View.GONE : View.VISIBLE); - mBinding.registeredName.setText(hasRegisteredName ? username : getResources().getString(R.string.no_registered_name_for_account)); - mBinding.btnQr.setOnClickListener(v -> QRCodeFragment.newInstance(QRCodeFragment.INDEX_CODE).show(getParentFragmentManager(), QRCodeFragment.TAG)); - mBinding.username.setOnFocusChangeListener((v, hasFocus) -> { - Editable name = mBinding.username.getText(); - if (!hasFocus && !TextUtils.isEmpty(name)) { - presenter.saveVCardFormattedName(name.toString()); - } - }); - - setSwitchStatus(account); - } - - public boolean onBackPressed() { - return false; - } - - private void showWizard(String accountId) { - LinkDeviceFragment.newInstance(accountId).show(getParentFragmentManager(), LinkDeviceFragment.TAG); - } - - @Override - public void showNetworkError() { - dismissWaitDialog(); - new MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.account_export_end_network_title) - .setMessage(R.string.account_export_end_network_message) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - - @Override - public void showPasswordError() { - dismissWaitDialog(); - } - - @Override - public void showGenericError() { - dismissWaitDialog(); - new MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.account_export_end_error_title) - .setMessage(R.string.account_export_end_error_message) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - - @Override - public void showPIN(String pin) { - - } - - private void profileContainerClicked(Account account) { - LayoutInflater inflater = LayoutInflater.from(getActivity()); - - ViewGroup view = (ViewGroup) inflater.inflate(R.layout.dialog_profile, null); - mProfilePhoto = view.findViewById(R.id.profile_photo); - mDisposableBag.add(AvatarDrawable.load(inflater.getContext(), account) - .subscribe(a -> mProfilePhoto.setImageDrawable(a))); - - ImageButton cameraView = view.findViewById(R.id.camera); - cameraView.setOnClickListener(v -> presenter.cameraClicked()); - - ImageButton gallery = view.findViewById(R.id.gallery); - gallery.setOnClickListener(v -> presenter.galleryClicked()); - - new MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.profile) - .setView(view) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel()) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - if (mSourcePhoto != null) { - presenter.saveVCard(mBinding.username.getText().toString(), Single.just(mSourcePhoto).map(BitmapUtils::bitmapToPhoto)); - mSourcePhoto = null; - } - }) - .show(); - } - - public void onClickExport() { - if (mAccountHasPassword) { - onBackupAccount(); - } else { - onUnlockAccount(mAccountId, ""); - } - } - - private void showUsernameRegistrationPopup() { - Bundle args = new Bundle(); - args.putString(AccountEditionFragment.ACCOUNT_ID_KEY, mAccountId); - args.putBoolean(AccountEditionFragment.ACCOUNT_HAS_PASSWORD_KEY, mAccountHasPassword); - RegisterNameDialog registrationDialog = new RegisterNameDialog(); - registrationDialog.setArguments(args); - registrationDialog.setListener(this); - registrationDialog.show(getParentFragmentManager(), TAG); - } - - @Override - public void onRegisterName(String name, String password) { - presenter.registerName(name, password); - } - - @Override - public void showExportingProgressDialog() { - mWaitDialog = ProgressDialog.show(getActivity(), - getString(R.string.export_account_wait_title), - getString(R.string.export_account_wait_message)); - } - - @Override - public void showPasswordProgressDialog() { - mWaitDialog = ProgressDialog.show(getActivity(), - getString(R.string.export_account_wait_title), - getString(R.string.account_password_change_wait_message)); - } - - private void dismissWaitDialog() { - if (mWaitDialog != null) { - mWaitDialog.dismiss(); - mWaitDialog = null; - } - } - - @Override - public void passwordChangeEnded(boolean ok) { - dismissWaitDialog(); - if (!ok) { - new MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.account_device_revocation_wrong_password) - .setMessage(R.string.account_export_end_decryption_message) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - } - - private void createFile(String mimeType, String fileName) { - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType(mimeType); - intent.putExtra(Intent.EXTRA_TITLE, fileName); - startActivityForResult(intent, WRITE_REQUEST_CODE); - } - - @Override - public void displayCompleteArchive(File dest) { - String type = AndroidFileUtils.getMimeType(dest.getAbsolutePath()); - mCacheArchive = dest; - dismissWaitDialog(); - createFile(type, mBestName); - } - - private void onBackupAccount() { - BackupAccountDialog dialog = new BackupAccountDialog(); - Bundle args = new Bundle(); - args.putString(AccountEditionFragment.ACCOUNT_ID_KEY, mAccountId); - dialog.setArguments(args); - dialog.setListener(this); - dialog.show(getParentFragmentManager(), FRAGMENT_DIALOG_BACKUP); - } - - public void onPasswordChangeAsked() { - ChangePasswordDialog dialog = new ChangePasswordDialog(); - Bundle args = new Bundle(); - args.putString(AccountEditionFragment.ACCOUNT_ID_KEY, mAccountId); - args.putBoolean(AccountEditionFragment.ACCOUNT_HAS_PASSWORD_KEY, mAccountHasPassword); - dialog.setArguments(args); - dialog.setListener(this); - dialog.show(getParentFragmentManager(), FRAGMENT_DIALOG_PASSWORD); - } - - @Override - public void onPasswordChanged(String oldPassword, String newPassword) { - presenter.changePassword(oldPassword, newPassword); - } - - @Override - public void onUnlockAccount(String accountId, String password) { - Context context = requireContext(); - File cacheDir = new File(AndroidFileUtils.getTempShareDir(context), "archives"); - cacheDir.mkdirs(); - if (!cacheDir.canWrite()) - Log.w(TAG, "Can't write to: " + cacheDir); - File dest = new File(cacheDir, mBestName); - if (dest.exists()) - dest.delete(); - presenter.downloadAccountsArchive(dest, password); - } - - @Override - public void gotToImageCapture() { - Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - try { - Context context = requireContext(); - File file = AndroidFileUtils.createImageFile(context); - Uri uri = FileProvider.getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, file); - intent.putExtra(MediaStore.EXTRA_OUTPUT, uri) - .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - .putExtra("android.intent.extras.CAMERA_FACING", 1) - .putExtra("android.intent.extras.LENS_FACING_FRONT", 1) - .putExtra("android.intent.extra.USE_FRONT_CAMERA", true); - tmpProfilePhotoUri = uri; - startActivityForResult(intent, HomeActivity.REQUEST_CODE_PHOTO); - } catch (Exception e) { - Toast.makeText(requireContext(), "Error starting camera: " + e.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); - Log.e(TAG, "Can't create temp file", e); - } - } - - @Override - public void askCameraPermission() { - requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, HomeActivity.REQUEST_PERMISSION_CAMERA); - } - - @Override - public void goToGallery() { - try { - Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); - startActivityForResult(intent, HomeActivity.REQUEST_CODE_GALLERY); - } catch (Exception e) { - Toast.makeText(requireContext(), R.string.gallery_error_message, Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void askGalleryPermission() { - requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, HomeActivity.REQUEST_PERMISSION_READ_STORAGE); - } - - private void updatePhoto(Uri uriImage) { - updatePhoto(AndroidFileUtils.loadBitmap(requireContext(), uriImage)); - } - - private void updatePhoto(Single<Bitmap> image) { - Account account = presenter.getAccount(); - if (account == null) - return; - mDisposableBag.add(image.subscribeOn(Schedulers.io()) - .map(img -> { - mSourcePhoto = img; - return new AvatarDrawable.Builder() - .withPhoto(img) - .withNameData(null, account.getRegisteredName()) - .withId(account.getUri()) - .withCircleCrop(true) - .build(getContext()); - }) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(avatar -> mProfilePhoto.setImageDrawable(avatar), e-> Log.e(TAG, "Error loading image", e))); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - switch (requestCode) { - case HomeActivity.REQUEST_PERMISSION_CAMERA: - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - presenter.cameraClicked(); - } - break; - case HomeActivity.REQUEST_PERMISSION_READ_STORAGE: - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - presenter.galleryClicked(); - } - break; - } - } - - @Override - public void onScrollChanged() { - if (mBinding != null) { - Activity activity = getActivity(); - if (activity instanceof HomeActivity) - ((HomeActivity) activity).setToolbarElevation(mBinding.scrollview.canScrollVertically(SCROLL_DIRECTION_UP)); - } - } - - @Override - public void setSwitchStatus(Account account) { - SwitchButton switchButton = ((HomeActivity) requireActivity()).getSwitchButton(); - int color = R.color.red_400; - String status; - - if (account.isEnabled()) { - if (account.isTrying()) { - color = R.color.orange_400; - switchButton.showImage(true); - switchButton.startImageAnimation(); - } else if (account.needsMigration()) { - status = getString(R.string.account_update_needed); - switchButton.showImage(false); - switchButton.setStatus(status); - } else if (account.isInError()) { - status = getString(R.string.account_status_connection_error); - switchButton.showImage(false); - switchButton.setStatus(status); - } else if (account.isRegistered()) { - status = getString(R.string.account_status_online); - color = R.color.green_400; - switchButton.showImage(false); - switchButton.setStatus(status); - } else if (!account.isRegistered()){ - color = R.color.grey_400; - status = getString(R.string.account_status_offline); - switchButton.showImage(false); - switchButton.setStatus(status); - } else { - status = getString(R.string.account_status_error); - switchButton.showImage(false); - switchButton.setStatus(status); - } - } else { - color = R.color.grey_400; - status = getString(R.string.account_status_offline); - switchButton.showImage(false); - switchButton.setStatus(status); - } - - switchButton.setBackColor(ContextCompat.getColor(requireContext(), color)); - } - - @Override - public void showRevokingProgressDialog() { - mWaitDialog = ProgressDialog.show(getActivity(), - getString(R.string.revoke_device_wait_title), - getString(R.string.revoke_device_wait_message)); - } - - @Override - public void deviceRevocationEnded(final String device, final int status) { - dismissWaitDialog(); - int message, title = R.string.account_device_revocation_error_title; - switch (status) { - case 0: - title = R.string.account_device_revocation_success_title; - message = R.string.account_device_revocation_success; - break; - case 1: - message = R.string.account_device_revocation_wrong_password; - break; - case 2: - message = R.string.account_device_revocation_unknown_device; - break; - default: - message = R.string.account_device_revocation_error_unknown; - } - new MaterialAlertDialogBuilder(requireContext()) - .setTitle(title) - .setMessage(message) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - dialog.dismiss(); - if (status == 1) { - onDeviceRevocationAsked(device); - } - }) - .show(); - } - - @Override - public void updateDeviceList(final Map<String, String> devices, final String currentDeviceId) { - if (mDeviceAdapter == null) { - return; - } - mDeviceAdapter.setData(devices, currentDeviceId); - collapse(mBinding.devicesList); - } - - private void shareAccount(String username) { - if (!StringUtils.isEmpty(username)) { - Intent sharingIntent = new Intent(Intent.ACTION_SEND); - sharingIntent.setType("text/plain"); - sharingIntent.putExtra(Intent.EXTRA_SUBJECT, getText(R.string.account_contact_me)); - sharingIntent.putExtra(Intent.EXTRA_TEXT, getString(R.string.account_share_body, username, getText(R.string.app_website))); - startActivity(Intent.createChooser(sharingIntent, getText(R.string.share_via))); - } - } - - private Fragment fragmentWithBundle(Fragment result, String accountId) { - Bundle args = new Bundle(); - args.putString(AccountEditionFragment.ACCOUNT_ID_KEY, accountId); - result.setArguments(args); - return result; - } - - private void changeFragment(Fragment fragment, String tag) { - getChildFragmentManager() - .beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(R.id.fragment, fragment, tag) - .addToBackStack(tag).commit(); - mBinding.fragment.setVisibility(View.VISIBLE); - mOnBackPressedCallback.setEnabled(true); - } - - public void goToAccount(String accountId) { - changeFragment(AccountFragment.newInstance(accountId), MediaPreferenceFragment.TAG); - } - - public void goToMedia(String accountId) { - changeFragment(MediaPreferenceFragment.newInstance(accountId), MediaPreferenceFragment.TAG); - } - - public void goToSystem(String accountId) { - changeFragment(GeneralAccountFragment.newInstance(accountId), GeneralAccountFragment.TAG); - } - - public void goToAdvanced(String accountId) { - changeFragment(fragmentWithBundle(new AdvancedAccountFragment(), accountId), SettingsFragment.TAG); - } - - public void goToBlackList(String accountId) { - BlockListFragment blockListFragment = new BlockListFragment(); - Bundle args = new Bundle(); - args.putString(AccountEditionFragment.ACCOUNT_ID_KEY, accountId); - blockListFragment.setArguments(args); - changeFragment(blockListFragment, BlockListFragment.TAG); - } - - public void popBackStack() { - getChildFragmentManager().popBackStackImmediate(); - String fragmentTag = getChildFragmentManager().getBackStackEntryAt(getChildFragmentManager().getBackStackEntryCount() - 1).getName(); - Fragment fragment = getChildFragmentManager().findFragmentByTag(fragmentTag); - changeFragment(fragment, fragmentTag); - } - - @Override - public void onConfirmRevocation(String deviceId, String password) { - presenter.revokeDevice(deviceId, password); - } - - @Override - public void onDeviceRevocationAsked(String deviceId) { - ConfirmRevocationDialog dialog = new ConfirmRevocationDialog(); - Bundle args = new Bundle(); - args.putString(ConfirmRevocationDialog.DEVICEID_KEY, deviceId); - args.putBoolean(ConfirmRevocationDialog.HAS_PASSWORD_KEY, mAccountHasPassword); - dialog.setArguments(args); - dialog.setListener(this); - dialog.show(getParentFragmentManager(), FRAGMENT_DIALOG_REVOCATION); - } - - @Override - public void onDeviceRename() { - final String dev_name = presenter.getDeviceName(); - RenameDeviceDialog dialog = new RenameDeviceDialog(); - Bundle args = new Bundle(); - args.putString(RenameDeviceDialog.DEVICENAME_KEY, dev_name); - dialog.setArguments(args); - dialog.setListener(this); - dialog.show(getParentFragmentManager(), FRAGMENT_DIALOG_RENAME); - } - - @Override - public void onDeviceRename(String newName) { - Log.d(TAG, "onDeviceRename: " + presenter.getDeviceName() + " -> " + newName); - presenter.renameDevice(newName); - } - - private void expand(View summary) { - summary.setVisibility(View.VISIBLE); - - final int widthSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); - summary.measure(View.MeasureSpec.makeMeasureSpec(widthSpec, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(1200, View.MeasureSpec.AT_MOST)); - final int targetHeight = summary.getMeasuredHeight(); - - ValueAnimator animator = slideAnimator(0, targetHeight, summary); - animator.start(); - - mBinding.chipMore.setText(R.string.account_link_hide_button); - } - - private void collapse(final View summary) { - int finalHeight = summary.getHeight(); - ValueAnimator mAnimator = slideAnimator(finalHeight, 0, summary); - mAnimator.addListener(new Animator.AnimatorListener() { - @Override - public void onAnimationEnd(Animator animator) { - // Height=0, but it set visibility to GONE - summary.setVisibility(View.GONE); - } - - @Override - public void onAnimationStart(Animator animator) { - } - - @Override - public void onAnimationCancel(Animator animator) { - } - - @Override - public void onAnimationRepeat(Animator animator) { - } - }); - - mAnimator.start(); - mBinding.chipMore.setText(getString(R.string.account_link_show_button, mDeviceAdapter.getCount())); - } - - private static ValueAnimator slideAnimator(int start, int end, final View summary) { - ValueAnimator animator = ValueAnimator.ofInt(start, end); - animator.addUpdateListener(valueAnimator -> { - // Update Height - int value = (Integer) valueAnimator.getAnimatedValue(); - ViewGroup.LayoutParams layoutParams = summary.getLayoutParams(); - layoutParams.height = value; - summary.setLayoutParams(layoutParams); - }); - return animator; - } - - private void setLinkedDevicesAdapter(Account account) { - if (account.getDevices().size() == 1) { - mBinding.chipMore.setVisibility(View.GONE); - } else { - mBinding.chipMore.setVisibility(View.VISIBLE); - if (mDeviceAdapter == null) { - mDeviceAdapter = new DeviceAdapter(requireContext(), account.getDevices(), account.getDeviceId(), JamiAccountSummaryFragment.this); - mBinding.chipMore.setText(getString(R.string.account_link_show_button, mDeviceAdapter.getCount())); - mBinding.devicesList.setAdapter(mDeviceAdapter); - } else { - mDeviceAdapter.setData(account.getDevices(), account.getDeviceId()); - } - } - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/account/JamiAccountSummaryFragment.kt b/ring-android/app/src/main/java/cx/ring/account/JamiAccountSummaryFragment.kt new file mode 100644 index 000000000..e610e253a --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/account/JamiAccountSummaryFragment.kt @@ -0,0 +1,744 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Loïc Siret <loic.siret@savoirfairelinux.com> + * Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.account + +import android.Manifest +import android.animation.Animator +import android.animation.ValueAnimator +import android.app.Activity +import android.app.ProgressDialog +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.text.TextUtils +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver.OnScrollChangedListener +import android.widget.* +import androidx.activity.OnBackPressedCallback +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import cx.ring.R +import cx.ring.account.BackupAccountDialog.UnlockAccountListener +import cx.ring.account.ChangePasswordDialog.PasswordChangedListener +import cx.ring.account.ConfirmRevocationDialog.ConfirmRevocationListener +import cx.ring.account.DeviceAdapter.DeviceRevocationListener +import cx.ring.account.RegisterNameDialog.RegisterNameDialogListener +import cx.ring.account.RenameDeviceDialog.RenameDeviceListener +import cx.ring.client.HomeActivity +import cx.ring.contactrequests.BlockListFragment +import cx.ring.databinding.FragAccSummaryBinding +import cx.ring.fragments.* +import cx.ring.mvp.BaseSupportFragment +import cx.ring.services.VCardServiceImpl.Companion.loadProfile +import cx.ring.settings.AccountFragment +import cx.ring.utils.AndroidFileUtils +import cx.ring.utils.BitmapUtils +import cx.ring.utils.ContentUriHandler +import cx.ring.views.AvatarDrawable +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.account.JamiAccountSummaryPresenter +import net.jami.account.JamiAccountSummaryView +import net.jami.model.Account +import net.jami.utils.StringUtils +import java.io.File +import java.util.* + +@AndroidEntryPoint +class JamiAccountSummaryFragment : + BaseSupportFragment<JamiAccountSummaryPresenter, JamiAccountSummaryView>(), + RegisterNameDialogListener, JamiAccountSummaryView, PasswordChangedListener, + UnlockAccountListener, OnScrollChangedListener, RenameDeviceListener, DeviceRevocationListener, + ConfirmRevocationListener { + private val mOnBackPressedCallback: OnBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (mBinding!!.fragment.visibility == View.VISIBLE) { + mBinding!!.fragment.visibility = View.GONE + this.isEnabled = false + childFragmentManager.popBackStack() + } + } + } + private var mWaitDialog: ProgressDialog? = null + private var mAccountHasPassword = true + private var mBestName = "" + private var mAccountId: String? = "" + private var mCacheArchive: File? = null + private var mProfilePhoto: ImageView? = null + private var mSourcePhoto: Bitmap? = null + private var tmpProfilePhotoUri: Uri? = null + private var mDeviceAdapter: DeviceAdapter? = null + private val mDisposableBag = CompositeDisposable() + private val mProfileDisposable = CompositeDisposable() + private var mBinding: FragAccSummaryBinding? = null + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + mDisposableBag.add(mProfileDisposable) + return FragAccSummaryBinding.inflate(inflater, container, false).apply { + mBinding = this + }.root + } + + override fun onAttach(context: Context) { + super.onAttach(context) + requireActivity().onBackPressedDispatcher.addCallback(this, mOnBackPressedCallback) + } + + override fun onDestroyView() { + super.onDestroyView() + mDisposableBag.clear() + mBinding = null + } + + override fun onDestroy() { + super.onDestroy() + mDisposableBag.dispose() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireArguments().let { arguments -> + presenter.setAccountId(arguments.getString(AccountEditionFragment.ACCOUNT_ID_KEY)!!) + } + mBinding!!.scrollview.viewTreeObserver.addOnScrollChangedListener(this) + mBinding!!.linkNewDevice.setOnClickListener { v: View? -> showWizard(mAccountId) } + mBinding!!.linkedDevices.setRightDrawableOnClickListener { v: View? -> onDeviceRename() } + mBinding!!.registerName.setOnClickListener { v: View? -> showUsernameRegistrationPopup() } + val items: MutableList<SettingItem> = ArrayList(4) + items.add(SettingItem(R.string.account, R.drawable.baseline_account_card_details) { presenter.goToAccount() }) + items.add(SettingItem(R.string.account_preferences_media_tab, R.drawable.outline_file_copy_24) { presenter.goToMedia() }) + items.add(SettingItem(R.string.notif_channel_messages, R.drawable.baseline_chat_24) { presenter.goToSystem() }) + items.add(SettingItem(R.string.account_preferences_advanced_tab, R.drawable.round_check_circle_24) { presenter.goToAdvanced() }) + val adapter = SettingsAdapter(view.context, R.layout.item_setting, items) + mBinding!!.settingsList.onItemClickListener = + AdapterView.OnItemClickListener { adapterView: AdapterView<*>?, v: View?, i: Int, l: Long -> + adapter.getItem(i)!! + .onClick() + } + mBinding!!.settingsList.adapter = adapter + var totalHeight = 0 + for (i in 0 until adapter.count) { + val listItem = adapter.getView(i, null, mBinding!!.settingsList) + listItem.measure(0, 0) + totalHeight += listItem.measuredHeight + } + val par = mBinding!!.settingsList.layoutParams + par.height = totalHeight + mBinding!!.settingsList.dividerHeight * (adapter.count - 1) + mBinding!!.settingsList.layoutParams = par + mBinding!!.settingsList.requestLayout() + mBinding!!.chipMore.setOnClickListener { v: View? -> + if (mBinding!!.devicesList.visibility == View.GONE) { + expand(mBinding!!.devicesList) + } else { + collapse(mBinding!!.devicesList) + } + } + } + + override fun onResume() { + super.onResume() + (requireActivity() as HomeActivity).let { activity -> + activity.showAccountStatus(true) + activity.switchButton.setOnCheckedChangeListener { _, isChecked: Boolean -> + presenter.enableAccount(isChecked) + } + } + } + + override fun onPause() { + super.onPause() + (requireActivity() as HomeActivity).let { activity -> + activity.showAccountStatus(false) + activity.switchButton.setOnCheckedChangeListener(null) + } + } + + fun setAccount(accountId: String?) { + presenter.setAccountId(accountId) + } + + override fun updateUserView(account: Account) { + val context = context ?: return + mProfileDisposable.clear() + mProfileDisposable.add(loadProfile(context, account) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ profile -> + mBinding?.let { binding -> + binding.userPhoto.setImageDrawable(AvatarDrawable.build(context, account, profile, true)) + binding.username.setText(profile.first) + } + }, { e: Throwable -> Log.e(TAG, "Error loading avatar", e) }) + ) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { + when (requestCode) { + WRITE_REQUEST_CODE -> if (resultCode == Activity.RESULT_OK) { + if (resultData != null) { + val uri = resultData.data + if (uri != null) { + if (mCacheArchive != null) { + AndroidFileUtils.moveToUri(requireContext().contentResolver, mCacheArchive!!, uri) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({}) { e: Throwable -> + val v = view + if (v != null) + Snackbar.make(v, "Can't export archive: " + e.message, Snackbar.LENGTH_LONG).show() + } + } + } + } + } + HomeActivity.REQUEST_CODE_PHOTO -> { + tmpProfilePhotoUri.let { photoUri -> + if (resultCode == Activity.RESULT_OK) { + if (photoUri == null) { + if (resultData != null) + updatePhoto(Single.just(resultData.extras!!["data"] as Bitmap?)) + } else { + updatePhoto(photoUri) + } + } + tmpProfilePhotoUri = null + } + } + HomeActivity.REQUEST_CODE_GALLERY -> if (resultCode == Activity.RESULT_OK && resultData != null) { + updatePhoto(resultData.data!!) + } + } + } + + override fun accountChanged(account: Account) { + updateUserView(account) + mBinding?.let { binding -> + binding.userPhoto.setOnClickListener { profileContainerClicked(account) } + binding.linkedDevices.setText(account.deviceName) + setLinkedDevicesAdapter(account) + mAccountHasPassword = account.hasPassword() + (requireActivity() as HomeActivity).switchButton.setCheckedSilent(account.isEnabled) + binding.accountAliasTxt.text = getString(R.string.profile) + binding.identity.setText(account.username) + mAccountId = account.accountID + mBestName = account.registeredName ?: account.displayUsername ?: account.username!! + mBestName = "$mBestName.gz" + val username = account.registeredName + val currentRegisteredName = account.registeringUsername + val hasRegisteredName = !currentRegisteredName && username != null && !username.isEmpty() + binding.groupRegisteringName.visibility = if (currentRegisteredName) View.VISIBLE else View.GONE + binding.btnShare.setOnClickListener { shareAccount(if (hasRegisteredName) username else account.username) } + binding.registerName.visibility = if (hasRegisteredName) View.GONE else View.VISIBLE + binding.registeredName.setText(if (hasRegisteredName) username else resources.getString(R.string.no_registered_name_for_account)) + binding.btnQr.setOnClickListener { + QRCodeFragment.newInstance(QRCodeFragment.INDEX_CODE) + .show(parentFragmentManager, QRCodeFragment.TAG) + } + binding.username.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus: Boolean -> + val name = binding.username.text + if (!hasFocus && !TextUtils.isEmpty(name)) { + presenter.saveVCardFormattedName(name.toString()) + } + } + } + + setSwitchStatus(account) + } + + fun onBackPressed(): Boolean { + return false + } + + private fun showWizard(accountId: String?) { + LinkDeviceFragment.newInstance(accountId) + .show(parentFragmentManager, LinkDeviceFragment.TAG) + } + + override fun showNetworkError() { + dismissWaitDialog() + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.account_export_end_network_title) + .setMessage(R.string.account_export_end_network_message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + override fun showPasswordError() { + dismissWaitDialog() + } + + override fun showGenericError() { + dismissWaitDialog() + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.account_export_end_error_title) + .setMessage(R.string.account_export_end_error_message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + override fun showPIN(pin: String) {} + private fun profileContainerClicked(account: Account) { + val inflater = LayoutInflater.from(activity) + val view = inflater.inflate(R.layout.dialog_profile, null) as ViewGroup + val profilePhoto = view.findViewById<ImageView>(R.id.profile_photo).apply { mProfilePhoto = this} + mDisposableBag.add(AvatarDrawable.load(inflater.context, account) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { a -> profilePhoto.setImageDrawable(a) }) + val cameraView = view.findViewById<ImageButton>(R.id.camera) + cameraView.setOnClickListener { presenter.cameraClicked() } + val gallery = view.findViewById<ImageButton>(R.id.gallery) + gallery.setOnClickListener { presenter.galleryClicked() } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.profile) + .setView(view) + .setNegativeButton(android.R.string.cancel) { dialog, which -> dialog.cancel() } + .setPositiveButton(android.R.string.ok) { dialog, which -> + mSourcePhoto?.let { source -> + presenter.saveVCard(mBinding!!.username.text.toString(), + Single.just(source).map { obj -> BitmapUtils.bitmapToPhoto(obj) }) + mSourcePhoto = null + } + } + .show() + } + + fun onClickExport() { + if (mAccountHasPassword) { + onBackupAccount() + } else { + onUnlockAccount(mAccountId!!, "") + } + } + + private fun showUsernameRegistrationPopup() { + RegisterNameDialog().apply { + arguments = Bundle().apply { + putString(AccountEditionFragment.ACCOUNT_ID_KEY, mAccountId) + putBoolean(AccountEditionFragment.ACCOUNT_HAS_PASSWORD_KEY, mAccountHasPassword) + } + setListener(this@JamiAccountSummaryFragment) + }.show(parentFragmentManager, TAG) + } + + override fun onRegisterName(name: String?, password: String?) { + presenter.registerName(name, password) + } + + override fun showExportingProgressDialog() { + mWaitDialog = ProgressDialog.show(activity, getString(R.string.export_account_wait_title), getString(R.string.export_account_wait_message)) + } + + override fun showPasswordProgressDialog() { + mWaitDialog = ProgressDialog.show(activity, getString(R.string.export_account_wait_title), getString(R.string.account_password_change_wait_message)) + } + + private fun dismissWaitDialog() { + mWaitDialog?.apply { + dismiss() + mWaitDialog = null + } + } + + override fun passwordChangeEnded(ok: Boolean) { + dismissWaitDialog() + if (!ok) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.account_device_revocation_wrong_password) + .setMessage(R.string.account_export_end_decryption_message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + + private fun createFile(mimeType: String?, fileName: String) { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = mimeType + intent.putExtra(Intent.EXTRA_TITLE, fileName) + startActivityForResult(intent, WRITE_REQUEST_CODE) + } + + override fun displayCompleteArchive(dest: File) { + val type = AndroidFileUtils.getMimeType(dest.absolutePath) + mCacheArchive = dest + dismissWaitDialog() + createFile(type, mBestName) + } + + private fun onBackupAccount() { + BackupAccountDialog().apply { + arguments = Bundle().apply { + putString(AccountEditionFragment.ACCOUNT_ID_KEY, mAccountId) + } + setListener(this@JamiAccountSummaryFragment) + }.show(parentFragmentManager, FRAGMENT_DIALOG_BACKUP) + } + + fun onPasswordChangeAsked() { + ChangePasswordDialog().apply { + arguments = Bundle().apply { + putString(AccountEditionFragment.ACCOUNT_ID_KEY, mAccountId) + putBoolean(AccountEditionFragment.ACCOUNT_HAS_PASSWORD_KEY, mAccountHasPassword) + } + setListener(this@JamiAccountSummaryFragment) + }.show(parentFragmentManager, FRAGMENT_DIALOG_PASSWORD) + } + + override fun onPasswordChanged(oldPassword: String, newPassword: String) { + presenter.changePassword(oldPassword, newPassword) + } + + override fun onUnlockAccount(accountId: String, password: String) { + val context = requireContext() + val cacheDir = File(AndroidFileUtils.getTempShareDir(context), "archives") + cacheDir.mkdirs() + if (!cacheDir.canWrite()) Log.w(TAG, "Can't write to: $cacheDir") + val dest = File(cacheDir, mBestName) + if (dest.exists()) dest.delete() + presenter.downloadAccountsArchive(dest, password) + } + + override fun gotToImageCapture() { + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + try { + val context = requireContext() + val file = AndroidFileUtils.createImageFile(context) + val uri = FileProvider.getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, file) + intent.putExtra(MediaStore.EXTRA_OUTPUT, uri) + .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + .putExtra("android.intent.extras.CAMERA_FACING", 1) + .putExtra("android.intent.extras.LENS_FACING_FRONT", 1) + .putExtra("android.intent.extra.USE_FRONT_CAMERA", true) + tmpProfilePhotoUri = uri + startActivityForResult(intent, HomeActivity.REQUEST_CODE_PHOTO) + } catch (e: Exception) { + Toast.makeText(requireContext(), "Error starting camera: " + e.localizedMessage, Toast.LENGTH_SHORT).show() + Log.e(TAG, "Can't create temp file", e) + } + } + + override fun askCameraPermission() { + requestPermissions(arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ), HomeActivity.REQUEST_PERMISSION_CAMERA + ) + } + + override fun goToGallery() { + try { + val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + startActivityForResult(intent, HomeActivity.REQUEST_CODE_GALLERY) + } catch (e: Exception) { + Toast.makeText(requireContext(), R.string.gallery_error_message, Toast.LENGTH_SHORT) + .show() + } + } + + override fun askGalleryPermission() { + requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), HomeActivity.REQUEST_PERMISSION_READ_STORAGE) + } + + private fun updatePhoto(uriImage: Uri) { + updatePhoto(AndroidFileUtils.loadBitmap(requireContext(), uriImage)) + } + + private fun updatePhoto(image: Single<Bitmap>) { + val account = presenter.account ?: return + mDisposableBag.add(image.subscribeOn(Schedulers.io()) + .map { img -> + mSourcePhoto = img + AvatarDrawable.Builder() + .withPhoto(img) + .withNameData(null, account.registeredName) + .withId(account.uri) + .withCircleCrop(true) + .build(requireContext()) + } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ avatar: AvatarDrawable -> mProfilePhoto!!.setImageDrawable(avatar) }) { e: Throwable -> + Log.e(TAG, "Error loading image", e) + }) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + when (requestCode) { + HomeActivity.REQUEST_PERMISSION_CAMERA -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + presenter.cameraClicked() + } + HomeActivity.REQUEST_PERMISSION_READ_STORAGE -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + presenter.galleryClicked() + } + } + } + + override fun onScrollChanged() { + if (mBinding != null) { + val activity = activity + if (activity is HomeActivity) activity.setToolbarElevation( + mBinding!!.scrollview.canScrollVertically(SCROLL_DIRECTION_UP) + ) + } + } + + override fun setSwitchStatus(account: Account) { + val switchButton = (requireActivity() as HomeActivity).switchButton + var color = R.color.red_400 + val status: String + if (account.isEnabled) { + if (account.isTrying) { + color = R.color.orange_400 + switchButton.showImage(true) + switchButton.startImageAnimation() + } else if (account.needsMigration()) { + status = getString(R.string.account_update_needed) + switchButton.showImage(false) + switchButton.status = status + } else if (account.isInError) { + status = getString(R.string.account_status_connection_error) + switchButton.showImage(false) + switchButton.status = status + } else if (account.isRegistered) { + status = getString(R.string.account_status_online) + color = R.color.green_400 + switchButton.showImage(false) + switchButton.status = status + } else if (!account.isRegistered) { + color = R.color.grey_400 + status = getString(R.string.account_status_offline) + switchButton.showImage(false) + switchButton.status = status + } else { + status = getString(R.string.account_status_error) + switchButton.showImage(false) + switchButton.status = status + } + } else { + color = R.color.grey_400 + status = getString(R.string.account_status_offline) + switchButton.showImage(false) + switchButton.status = status + } + switchButton.backColor = ContextCompat.getColor(requireContext(), color) + } + + override fun showRevokingProgressDialog() { + mWaitDialog = ProgressDialog.show(activity, + getString(R.string.revoke_device_wait_title), + getString(R.string.revoke_device_wait_message) + ) + } + + override fun deviceRevocationEnded(device: String, status: Int) { + dismissWaitDialog() + val message: Int + var title = R.string.account_device_revocation_error_title + when (status) { + 0 -> { + title = R.string.account_device_revocation_success_title + message = R.string.account_device_revocation_success + } + 1 -> message = R.string.account_device_revocation_wrong_password + 2 -> message = R.string.account_device_revocation_unknown_device + else -> message = R.string.account_device_revocation_error_unknown + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(title) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { dialog: DialogInterface, which: Int -> + dialog.dismiss() + if (status == 1) { + onDeviceRevocationAsked(device) + } + } + .show() + } + + override fun updateDeviceList(devices: Map<String, String>, currentDeviceId: String) { + if (mDeviceAdapter == null) { + return + } + mDeviceAdapter!!.setData(devices, currentDeviceId) + collapse(mBinding!!.devicesList) + } + + private fun shareAccount(username: String?) { + if (!StringUtils.isEmpty(username)) { + val sharingIntent = Intent(Intent.ACTION_SEND) + sharingIntent.type = "text/plain" + sharingIntent.putExtra(Intent.EXTRA_SUBJECT, getText(R.string.account_contact_me)) + sharingIntent.putExtra(Intent.EXTRA_TEXT, getString(R.string.account_share_body, username, getText(R.string.app_website))) + startActivity(Intent.createChooser(sharingIntent, getText(R.string.share_via))) + } + } + + private fun fragmentWithBundle(result: Fragment, accountId: String): Fragment { + return result.apply { + arguments = Bundle().apply { putString(AccountEditionFragment.ACCOUNT_ID_KEY, accountId) } + } + } + + private fun changeFragment(fragment: Fragment, tag: String?) { + childFragmentManager + .beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(R.id.fragment, fragment, tag) + .addToBackStack(tag).commit() + mBinding!!.fragment.visibility = View.VISIBLE + mOnBackPressedCallback.isEnabled = true + } + + override fun goToAccount(accountId: String) { + changeFragment(AccountFragment.newInstance(accountId), AccountFragment.TAG) + } + + override fun goToMedia(accountId: String) { + changeFragment(MediaPreferenceFragment.newInstance(accountId), MediaPreferenceFragment.TAG) + } + + override fun goToSystem(accountId: String) { + changeFragment(GeneralAccountFragment.newInstance(accountId), GeneralAccountFragment.TAG) + } + + override fun goToAdvanced(accountId: String) { + changeFragment(fragmentWithBundle(AdvancedAccountFragment(), accountId), AdvancedAccountFragment.TAG) + } + + fun goToBlackList(accountId: String?) { + val blockListFragment = BlockListFragment().apply { + arguments = Bundle().apply { putString(AccountEditionFragment.ACCOUNT_ID_KEY, accountId) } + } + changeFragment(blockListFragment, BlockListFragment.TAG) + } + + fun popBackStack() { + childFragmentManager.popBackStackImmediate() + val fragmentTag = childFragmentManager.getBackStackEntryAt(childFragmentManager.backStackEntryCount - 1).name + val fragment = childFragmentManager.findFragmentByTag(fragmentTag) + if (fragment != null) + changeFragment(fragment, fragmentTag) + } + + override fun onConfirmRevocation(deviceId: String, password: String) { + presenter.revokeDevice(deviceId, password) + } + + override fun onDeviceRevocationAsked(deviceId: String?) { + ConfirmRevocationDialog().apply { + arguments = Bundle().apply { + putString(ConfirmRevocationDialog.DEVICEID_KEY, deviceId) + putBoolean(ConfirmRevocationDialog.HAS_PASSWORD_KEY, mAccountHasPassword) + } + setListener(this@JamiAccountSummaryFragment) + }.show(parentFragmentManager, FRAGMENT_DIALOG_REVOCATION) + } + + override fun onDeviceRename() { + RenameDeviceDialog().apply { + arguments = Bundle().apply { putString(RenameDeviceDialog.DEVICENAME_KEY, presenter.deviceName) } + setListener(this@JamiAccountSummaryFragment) + }.show(parentFragmentManager, FRAGMENT_DIALOG_RENAME) + } + + override fun onDeviceRename(newName: String?) { + Log.d(TAG, "onDeviceRename: " + presenter.deviceName + " -> " + newName) + presenter.renameDevice(newName) + } + + private fun expand(summary: View) { + summary.visibility = View.VISIBLE + val widthSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + summary.measure( + View.MeasureSpec.makeMeasureSpec(widthSpec, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(1200, View.MeasureSpec.AT_MOST) + ) + val targetHeight = summary.measuredHeight + val animator = slideAnimator(0, targetHeight, summary) + animator.start() + mBinding!!.chipMore.setText(R.string.account_link_hide_button) + } + + private fun collapse(summary: View) { + val finalHeight = summary.height + val mAnimator = slideAnimator(finalHeight, 0, summary) + mAnimator.addListener(object : Animator.AnimatorListener { + override fun onAnimationEnd(animator: Animator) { + // Height=0, but it set visibility to GONE + summary.visibility = View.GONE + } + + override fun onAnimationStart(animator: Animator) {} + override fun onAnimationCancel(animator: Animator) {} + override fun onAnimationRepeat(animator: Animator) {} + }) + mAnimator.start() + mBinding!!.chipMore.text = getString(R.string.account_link_show_button, mDeviceAdapter!!.count) + } + + private fun setLinkedDevicesAdapter(account: Account) { + if (account.devices.size == 1) { + mBinding!!.chipMore.visibility = View.GONE + } else { + mBinding!!.chipMore.visibility = View.VISIBLE + if (mDeviceAdapter == null) { + mDeviceAdapter = DeviceAdapter(requireContext(), account.devices, account.deviceId, this@JamiAccountSummaryFragment) + mBinding!!.chipMore.text = getString(R.string.account_link_show_button, mDeviceAdapter!!.count) + mBinding!!.devicesList.adapter = mDeviceAdapter + } else { + mDeviceAdapter!!.setData(account.devices, account.deviceId) + } + } + } + + companion object { + val TAG = JamiAccountSummaryFragment::class.simpleName!! + private val FRAGMENT_DIALOG_REVOCATION = "$TAG.dialog.deviceRevocation" + private val FRAGMENT_DIALOG_RENAME = "$TAG.dialog.deviceRename" + private val FRAGMENT_DIALOG_PASSWORD = "$TAG.dialog.changePassword" + private val FRAGMENT_DIALOG_BACKUP = "$TAG.dialog.backup" + private const val WRITE_REQUEST_CODE = 43 + private const val SCROLL_DIRECTION_UP = -1 + private fun slideAnimator(start: Int, end: Int, summary: View): ValueAnimator { + val animator = ValueAnimator.ofInt(start, end) + animator.addUpdateListener { valueAnimator: ValueAnimator -> + // Update Height + val value = valueAnimator.animatedValue as Int + val layoutParams = summary.layoutParams + layoutParams.height = value + summary.layoutParams = layoutParams + } + return animator + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/JamiAccountUsernameFragment.java b/ring-android/app/src/main/java/cx/ring/account/JamiAccountUsernameFragment.java index 482d7d87f..5a50c8395 100644 --- a/ring-android/app/src/main/java/cx/ring/account/JamiAccountUsernameFragment.java +++ b/ring-android/app/src/main/java/cx/ring/account/JamiAccountUsernameFragment.java @@ -39,7 +39,6 @@ import androidx.annotation.Nullable; import com.google.android.material.textfield.TextInputLayout; import cx.ring.R; -import cx.ring.application.JamiApplication; import cx.ring.databinding.FragAccJamiUsernameBinding; import net.jami.account.JamiAccountCreationPresenter; @@ -47,8 +46,10 @@ import net.jami.account.JamiAccountCreationView; import net.jami.mvp.AccountCreationModel; import cx.ring.mvp.BaseSupportFragment; import cx.ring.utils.RegisteredNameFilter; +import dagger.hilt.android.AndroidEntryPoint; -public class JamiAccountUsernameFragment extends BaseSupportFragment<JamiAccountCreationPresenter> +@AndroidEntryPoint +public class JamiAccountUsernameFragment extends BaseSupportFragment<JamiAccountCreationPresenter, JamiAccountCreationView> implements JamiAccountCreationView { private static final String KEY_MODEL = "model"; @@ -75,7 +76,7 @@ public class JamiAccountUsernameFragment extends BaseSupportFragment<JamiAccount model = (AccountCreationModelImpl) savedInstanceState.getSerializable(KEY_MODEL); } binding = FragAccJamiUsernameBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); + //((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); return binding.getRoot(); } diff --git a/ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountFragment.java b/ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountFragment.java deleted file mode 100644 index 2fb6748ee..000000000 --- a/ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountFragment.java +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.account; - -import android.content.Context; -import android.os.Bundle; -import android.util.SparseArray; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.activity.OnBackPressedCallback; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapter; -import androidx.viewpager.widget.ViewPager; - -import cx.ring.databinding.FragAccJamiLinkBinding; -import cx.ring.mvp.BaseSupportFragment; -import net.jami.mvp.AccountCreationModel; - -public class JamiLinkAccountFragment extends BaseSupportFragment { - - public static final String TAG = JamiLinkAccountFragment.class.getSimpleName(); - private static final int NUM_PAGES = 2; - - private AccountCreationModel model; - private FragAccJamiLinkBinding mBinding; - private Fragment mCurrentFragment; - - private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { - @Override - public void handleOnBackPressed() { - if (mCurrentFragment instanceof ProfileCreationFragment) { - ProfileCreationFragment fragment = (ProfileCreationFragment) mCurrentFragment; - ((AccountWizardActivity) getActivity()).profileCreated(fragment.getModel(), false); - return; - } - mBinding.pager.setCurrentItem(mBinding.pager.getCurrentItem() - 1); - } - }; - - public static JamiLinkAccountFragment newInstance(AccountCreationModelImpl ringAccountViewModel) { - JamiLinkAccountFragment fragment = new JamiLinkAccountFragment(); - fragment.model = ringAccountViewModel; - return fragment; - } - - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - outState.putSerializable("model", model); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - setRetainInstance(true); - if (savedInstanceState != null && model == null) { - model = (AccountCreationModel) savedInstanceState.getSerializable("model"); - } - mBinding = FragAccJamiLinkBinding.inflate(inflater, container, false); - return mBinding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - mBinding = null; - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - ScreenSlidePagerAdapter pagerAdapter = new ScreenSlidePagerAdapter(getChildFragmentManager(), model); - mBinding.pager.setAdapter(pagerAdapter); - mBinding.pager.disableScroll(true); - - mBinding.pager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() { - @Override - public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { - } - - @Override - public void onPageSelected(int position) { - mCurrentFragment = pagerAdapter.getRegisteredFragment(position); - onBackPressedCallback.setEnabled(mCurrentFragment instanceof ProfileCreationFragment); - } - - @Override - public void onPageScrollStateChanged(int state) { - - } - }); - } - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - requireActivity().getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); - } - - public void scrollPagerFragment(AccountCreationModel accountCreationModel) { - if (accountCreationModel == null) { - mBinding.pager.setCurrentItem(mBinding.pager.getCurrentItem() - 1); - return; - } - mBinding.pager.setCurrentItem(mBinding.pager.getCurrentItem() + 1); - for (Fragment fragment : getChildFragmentManager().getFragments()) { - if (fragment instanceof JamiAccountPasswordFragment) { - ((JamiAccountPasswordFragment) fragment).setUsername(accountCreationModel.getUsername()); - } - } - } - - private static class ScreenSlidePagerAdapter extends FragmentStatePagerAdapter { - AccountCreationModelImpl ringAccountViewModel; - SparseArray<Fragment> mRegisteredFragments = new SparseArray<>(); - - public ScreenSlidePagerAdapter(FragmentManager fm, AccountCreationModel model) { - super(fm); - ringAccountViewModel = (AccountCreationModelImpl) model; - } - - @Override - public Fragment getItem(int position) { - Fragment fragment = null; - - switch (position) { - case 0: - fragment = JamiLinkAccountPasswordFragment.newInstance(ringAccountViewModel); - break; - case 1: - fragment = ProfileCreationFragment.newInstance(ringAccountViewModel); - break; - } - - return fragment; - } - - @NonNull - @Override - public Object instantiateItem(@NonNull ViewGroup container, int position) { - Fragment fragment = (Fragment) super.instantiateItem(container, position); - mRegisteredFragments.put(position, fragment); - return super.instantiateItem(container, position); - } - - @Override - public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { - mRegisteredFragments.remove(position); - super.destroyItem(container, position, object); - } - - @Override - public int getCount() { - return NUM_PAGES; - } - - public Fragment getRegisteredFragment(int position) { - return mRegisteredFragments.get(position); - } - } - -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountFragment.kt b/ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountFragment.kt new file mode 100644 index 000000000..34a6ccdbc --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountFragment.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.account + +import android.content.Context +import android.os.Bundle +import android.util.SparseArray +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter +import androidx.viewpager.widget.ViewPager.OnPageChangeListener +import cx.ring.databinding.FragAccJamiLinkBinding +import net.jami.mvp.AccountCreationModel + +class JamiLinkAccountFragment : Fragment() { + private lateinit var model: AccountCreationModel + private var mBinding: FragAccJamiLinkBinding? = null + private var mCurrentFragment: Fragment? = null + + private val onBackPressedCallback: OnBackPressedCallback = + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (mCurrentFragment is ProfileCreationFragment) { + val fragment = mCurrentFragment as ProfileCreationFragment + (activity as AccountWizardActivity?)!!.profileCreated(fragment.model, false) + return + } + mBinding!!.pager.currentItem = mBinding!!.pager.currentItem - 1 + } + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putSerializable("model", model) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + retainInstance = true + if (savedInstanceState != null) { + model = savedInstanceState.getSerializable("model") as AccountCreationModel + } + return FragAccJamiLinkBinding.inflate(inflater, container, false).apply { + mBinding = this + }.root + } + + override fun onDestroyView() { + super.onDestroyView() + mBinding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val pagerAdapter = ScreenSlidePagerAdapter(childFragmentManager, model) + mBinding!!.pager.adapter = pagerAdapter + mBinding!!.pager.disableScroll(true) + mBinding!!.pager.addOnPageChangeListener(object : OnPageChangeListener { + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {} + + override fun onPageSelected(position: Int) { + mCurrentFragment = pagerAdapter.getRegisteredFragment(position) + onBackPressedCallback.isEnabled = mCurrentFragment is ProfileCreationFragment + } + + override fun onPageScrollStateChanged(state: Int) {} + }) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + requireActivity().onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } + + fun scrollPagerFragment(accountCreationModel: AccountCreationModel?) { + if (accountCreationModel == null) { + mBinding!!.pager.currentItem = mBinding!!.pager.currentItem - 1 + return + } + mBinding!!.pager.currentItem = mBinding!!.pager.currentItem + 1 + for (fragment in childFragmentManager.fragments) { + if (fragment is JamiAccountPasswordFragment) { + fragment.setUsername(accountCreationModel.username) + } + } + } + + private class ScreenSlidePagerAdapter(fm: FragmentManager, model: AccountCreationModel) : + FragmentStatePagerAdapter(fm) { + var ringAccountViewModel: AccountCreationModelImpl = model as AccountCreationModelImpl + var mRegisteredFragments = SparseArray<Fragment>() + override fun getItem(position: Int): Fragment { + var fragment: Fragment? = null + when (position) { + 0 -> fragment = JamiLinkAccountPasswordFragment.newInstance(ringAccountViewModel) + 1 -> fragment = ProfileCreationFragment.newInstance(ringAccountViewModel) + } + return fragment!! + } + + override fun instantiateItem(container: ViewGroup, position: Int): Any { + val fragment = super.instantiateItem(container, position) as Fragment + mRegisteredFragments.put(position, fragment) + return super.instantiateItem(container, position) + } + + override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { + mRegisteredFragments.remove(position) + super.destroyItem(container, position, `object`) + } + + override fun getCount(): Int { + return NUM_PAGES + } + + fun getRegisteredFragment(position: Int): Fragment { + return mRegisteredFragments[position] + } + + } + + companion object { + val TAG = JamiLinkAccountFragment::class.simpleName!! + private const val NUM_PAGES = 2 + + fun newInstance(ringAccountViewModel: AccountCreationModelImpl): JamiLinkAccountFragment { + val fragment = JamiLinkAccountFragment() + fragment.model = ringAccountViewModel + return fragment + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountPasswordFragment.java b/ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountPasswordFragment.java index 67da98a8c..81b175a7d 100644 --- a/ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountPasswordFragment.java +++ b/ring-android/app/src/main/java/cx/ring/account/JamiLinkAccountPasswordFragment.java @@ -41,8 +41,10 @@ import net.jami.account.JamiLinkAccountPresenter; import net.jami.account.JamiLinkAccountView; import net.jami.mvp.AccountCreationModel; import cx.ring.mvp.BaseSupportFragment; +import dagger.hilt.android.AndroidEntryPoint; -public class JamiLinkAccountPasswordFragment extends BaseSupportFragment<JamiLinkAccountPresenter> +@AndroidEntryPoint +public class JamiLinkAccountPasswordFragment extends BaseSupportFragment<JamiLinkAccountPresenter, JamiLinkAccountView> implements JamiLinkAccountView { public static final String TAG = JamiLinkAccountPasswordFragment.class.getSimpleName(); @@ -61,7 +63,6 @@ public class JamiLinkAccountPasswordFragment extends BaseSupportFragment<JamiLin if (model == null) return null; mBinding = FragAccJamiLinkPasswordBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); return mBinding.getRoot(); } diff --git a/ring-android/app/src/main/java/cx/ring/account/ProfileCreationFragment.java b/ring-android/app/src/main/java/cx/ring/account/ProfileCreationFragment.java deleted file mode 100644 index 764ee026a..000000000 --- a/ring-android/app/src/main/java/cx/ring/account/ProfileCreationFragment.java +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.account; - -import android.Manifest; -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.Bundle; -import android.provider.MediaStore; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import android.text.Editable; -import android.text.TextWatcher; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Toast; - -import java.io.File; -import java.io.IOException; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.databinding.FragAccProfileCreateBinding; - -import net.jami.account.ProfileCreationPresenter; -import net.jami.account.ProfileCreationView; -import net.jami.model.Account; -import net.jami.mvp.AccountCreationModel; -import cx.ring.mvp.BaseSupportFragment; -import cx.ring.utils.AndroidFileUtils; -import cx.ring.utils.ContentUriHandler; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.core.Single; - -public class ProfileCreationFragment extends BaseSupportFragment<ProfileCreationPresenter> implements ProfileCreationView { - public static final String TAG = ProfileCreationFragment.class.getSimpleName(); - - public static final int REQUEST_CODE_PHOTO = 1; - public static final int REQUEST_CODE_GALLERY = 2; - public static final int REQUEST_PERMISSION_CAMERA = 3; - public static final int REQUEST_PERMISSION_READ_STORAGE = 4; - - private AccountCreationModel model; - private Uri tmpProfilePhotoUri; - private FragAccProfileCreateBinding binding; - - public static ProfileCreationFragment newInstance(AccountCreationModelImpl model) { - ProfileCreationFragment fragment = new ProfileCreationFragment(); - fragment.model = model; - return fragment; - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragAccProfileCreateBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - setRetainInstance(true); - - if (model == null) { - getActivity().finish(); - return; - } - if (binding.profilePhoto.getDrawable() == null) { - binding.profilePhoto.setImageDrawable( - new AvatarDrawable.Builder() - .withNameData(model.getFullName(), model.getUsername()) - .withCircleCrop(true) - .build(view.getContext()) - ); - } - presenter.initPresenter(model); - - binding.gallery.setOnClickListener(v -> presenter.galleryClick()); - binding.camera.setOnClickListener(v -> presenter.cameraClick()); - binding.nextCreateAccount.setOnClickListener(v -> presenter.nextClick()); - binding.skipCreateAccount.setOnClickListener(v -> presenter.skipClick()); - binding.username.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) {} - - @Override - public void afterTextChanged(Editable s) { - presenter.fullNameUpdated(s.toString()); - } - }); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent intent) { - switch (requestCode) { - case REQUEST_CODE_PHOTO: - if (resultCode == Activity.RESULT_OK) { - if (tmpProfilePhotoUri == null) { - if (intent != null) { - Bundle bundle = intent.getExtras(); - Bitmap b = bundle == null ? null : (Bitmap) bundle.get("data"); - if (b != null) { - presenter.photoUpdated(Single.just(b)); - } - } - } else { - presenter.photoUpdated(AndroidFileUtils.loadBitmap(getContext(), tmpProfilePhotoUri).map(b -> (Object)b)); - } - } - break; - case REQUEST_CODE_GALLERY: - if (resultCode == Activity.RESULT_OK && intent != null) { - presenter.photoUpdated(AndroidFileUtils.loadBitmap(getActivity(), intent.getData()).map(b -> (Object)b)); - } - break; - default: - super.onActivityResult(requestCode, resultCode, intent); - break; - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - switch (requestCode) { - case ProfileCreationFragment.REQUEST_PERMISSION_CAMERA: - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - presenter.cameraPermissionChanged(true); - presenter.cameraClick(); - } - break; - case ProfileCreationFragment.REQUEST_PERMISSION_READ_STORAGE: - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - presenter.galleryClick(); - } - break; - } - } - - @Override - public void displayProfileName(String profileName) { - binding.username.setText(profileName); - } - - @Override - public void goToGallery() { - try { - Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); - startActivityForResult(intent, REQUEST_CODE_GALLERY); - } catch (Exception e) { - Toast.makeText(requireContext(), R.string.gallery_error_message, Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void goToPhotoCapture() { - try { - Context context = requireContext(); - File file = AndroidFileUtils.createImageFile(context); - Uri uri = ContentUriHandler.getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, file); - Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE) - .putExtra(MediaStore.EXTRA_OUTPUT, uri) - .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - .putExtra("android.intent.extras.CAMERA_FACING", 1) - .putExtra("android.intent.extras.LENS_FACING_FRONT", 1) - .putExtra("android.intent.extra.USE_FRONT_CAMERA", true); - tmpProfilePhotoUri = uri; - startActivityForResult(intent, REQUEST_CODE_PHOTO); - } catch (IOException e) { - Log.e(TAG, "Can't create temp file", e); - } catch (ActivityNotFoundException e) { - Log.e(TAG, "Could not start activity"); - } - } - - @Override - public void askStoragePermission() { - requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, REQUEST_PERMISSION_READ_STORAGE); - } - - @Override - public void askPhotoPermission() { - requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUEST_PERMISSION_CAMERA); - } - - @Override - public void goToNext(AccountCreationModel accountCreationModel, boolean saveProfile) { - Activity wizardActivity = getActivity(); - if (wizardActivity instanceof AccountWizardActivity) { - AccountWizardActivity wizard = (AccountWizardActivity) wizardActivity; - wizard.profileCreated(accountCreationModel, saveProfile); - } - } - - @Override - public void setProfile(AccountCreationModel accountCreationModel) { - AccountCreationModelImpl model = ((AccountCreationModelImpl) accountCreationModel); - Account newAccount = model.getNewAccount(); - binding.profilePhoto.setImageDrawable( - new AvatarDrawable.Builder() - .withPhoto(model.getPhoto()) - .withNameData(accountCreationModel.getFullName(), accountCreationModel.getUsername()) - .withId(newAccount == null ? null : newAccount.getUsername()) - .withCircleCrop(true) - .build(getContext()) - ); - } - - public AccountCreationModel getModel() { - return model; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/account/ProfileCreationFragment.kt b/ring-android/app/src/main/java/cx/ring/account/ProfileCreationFragment.kt new file mode 100644 index 000000000..e568b8ca9 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/account/ProfileCreationFragment.kt @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.account + +import android.Manifest +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import cx.ring.R +import cx.ring.databinding.FragAccProfileCreateBinding +import cx.ring.mvp.BaseSupportFragment +import cx.ring.utils.AndroidFileUtils.createImageFile +import cx.ring.utils.AndroidFileUtils.loadBitmap +import cx.ring.utils.ContentUriHandler +import cx.ring.utils.ContentUriHandler.getUriForFile +import cx.ring.views.AvatarDrawable +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.core.Single +import net.jami.account.ProfileCreationPresenter +import net.jami.account.ProfileCreationView +import net.jami.mvp.AccountCreationModel +import java.io.IOException + +@AndroidEntryPoint +class ProfileCreationFragment : BaseSupportFragment<ProfileCreationPresenter, ProfileCreationView>(), ProfileCreationView { + var model: AccountCreationModel? = null + private set + private var tmpProfilePhotoUri: Uri? = null + private var binding: FragAccProfileCreateBinding? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return FragAccProfileCreateBinding.inflate(inflater, container, false).apply { + binding = this + }.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + retainInstance = true + if (model == null) { + activity?.finish() + return + } + if (binding!!.profilePhoto.drawable == null) { + binding!!.profilePhoto.setImageDrawable( + AvatarDrawable.Builder() + .withNameData(model!!.fullName, model!!.username) + .withCircleCrop(true) + .build(view.context) + ) + } + presenter.initPresenter(model) + binding!!.gallery.setOnClickListener { presenter.galleryClick() } + binding!!.camera.setOnClickListener { presenter.cameraClick() } + binding!!.nextCreateAccount.setOnClickListener { presenter.nextClick() } + binding!!.skipCreateAccount.setOnClickListener { presenter.skipClick() } + binding!!.username.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable) { + presenter.fullNameUpdated(s.toString()) + } + }) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + when (requestCode) { + REQUEST_CODE_PHOTO -> if (resultCode == Activity.RESULT_OK) { + if (tmpProfilePhotoUri == null) { + if (intent != null) { + val bundle = intent.extras + val b = if (bundle == null) null else bundle["data"] as Bitmap? + if (b != null) { + presenter.photoUpdated(Single.just(b)) + } + } + } else { + presenter.photoUpdated(loadBitmap(requireContext(), tmpProfilePhotoUri!!).map { b: Bitmap -> b }) + } + } + REQUEST_CODE_GALLERY -> if (resultCode == Activity.RESULT_OK && intent != null) { + presenter.photoUpdated(loadBitmap(requireContext(), intent.data!!).map { b -> b }) + } + else -> super.onActivityResult(requestCode, resultCode, intent) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) { + when (requestCode) { + REQUEST_PERMISSION_CAMERA -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + presenter.cameraPermissionChanged(true) + presenter.cameraClick() + } + REQUEST_PERMISSION_READ_STORAGE -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + presenter.galleryClick() + } + } + } + + override fun displayProfileName(profileName: String) { + binding!!.username.setText(profileName) + } + + override fun goToGallery() { + try { + val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + startActivityForResult(intent, REQUEST_CODE_GALLERY) + } catch (e: Exception) { + Toast.makeText(requireContext(), R.string.gallery_error_message, Toast.LENGTH_SHORT) + .show() + } + } + + override fun goToPhotoCapture() { + try { + val context = requireContext() + val file = createImageFile(context) + val uri = getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, file) + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + .putExtra(MediaStore.EXTRA_OUTPUT, uri) + .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + .putExtra("android.intent.extras.CAMERA_FACING", 1) + .putExtra("android.intent.extras.LENS_FACING_FRONT", 1) + .putExtra("android.intent.extra.USE_FRONT_CAMERA", true) + tmpProfilePhotoUri = uri + startActivityForResult(intent, REQUEST_CODE_PHOTO) + } catch (e: IOException) { + Log.e(TAG, "Can't create temp file", e) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "Could not start activity") + } + } + + override fun askStoragePermission() { + requestPermissions( + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + REQUEST_PERMISSION_READ_STORAGE + ) + } + + override fun askPhotoPermission() { + requestPermissions( + arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ), REQUEST_PERMISSION_CAMERA + ) + } + + override fun goToNext(accountCreationModel: AccountCreationModel, saveProfile: Boolean) { + val wizardActivity: Activity? = activity + if (wizardActivity is AccountWizardActivity) { + wizardActivity.profileCreated(accountCreationModel, saveProfile) + } + } + + override fun setProfile(accountCreationModel: AccountCreationModel) { + val model = accountCreationModel as AccountCreationModelImpl + val newAccount = model.newAccount + binding!!.profilePhoto.setImageDrawable( + AvatarDrawable.Builder() + .withPhoto(model.photo) + .withNameData( + accountCreationModel.getFullName(), + accountCreationModel.getUsername() + ) + .withId(newAccount?.username) + .withCircleCrop(true) + .build(requireContext()) + ) + } + + companion object { + val TAG = ProfileCreationFragment::class.simpleName!! + const val REQUEST_CODE_PHOTO = 1 + const val REQUEST_CODE_GALLERY = 2 + const val REQUEST_PERMISSION_CAMERA = 3 + const val REQUEST_PERMISSION_READ_STORAGE = 4 + + fun newInstance(model: AccountCreationModelImpl?): ProfileCreationFragment { + val fragment = ProfileCreationFragment() + fragment.model = model + return fragment + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/RegisterNameDialog.java b/ring-android/app/src/main/java/cx/ring/account/RegisterNameDialog.java deleted file mode 100644 index f192b82e4..000000000 --- a/ring-android/app/src/main/java/cx/ring/account/RegisterNameDialog.java +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.account; - -import android.app.Dialog; -import android.content.Context; -import android.os.Bundle; -import android.text.InputFilter; -import android.text.TextWatcher; -import android.view.View; -import android.view.WindowManager; -import android.view.inputmethod.EditorInfo; -import android.widget.Button; -import android.widget.TextView; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import javax.inject.Inject; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.DialogFragment; -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.databinding.FragRegisterNameBinding; -import net.jami.services.AccountService; -import cx.ring.utils.RegisteredNameFilter; -import cx.ring.utils.RegisteredNameTextWatcher; -import io.reactivex.rxjava3.core.Scheduler; -import io.reactivex.rxjava3.disposables.Disposable; - -public class RegisterNameDialog extends DialogFragment { - static final String TAG = RegisterNameDialog.class.getSimpleName(); - @Inject - AccountService mAccountService; - @Inject - Scheduler mUiScheduler; - - private TextWatcher mUsernameTextWatcher; - private RegisterNameDialogListener mListener = null; - - private Disposable mDisposableListener; - private FragRegisterNameBinding binding; - - public void setListener(RegisterNameDialogListener l) { - mListener = l; - } - - private void onLookupResult(final int state, final String name) { - CharSequence actualName = binding.ringUsername.getText(); - if (actualName == null || actualName.length() == 0) { - binding.ringUsernameTxtBox.setErrorEnabled(false); - binding.ringUsernameTxtBox.setError(null); - return; - } - - if (name.contentEquals(actualName)) { - switch (state) { - case 0: - // on found - binding.ringUsernameTxtBox.setErrorEnabled(true); - binding.ringUsernameTxtBox.setError(getText(R.string.username_already_taken)); - break; - case 1: - // invalid name - binding.ringUsernameTxtBox.setErrorEnabled(true); - binding.ringUsernameTxtBox.setError(getText(R.string.invalid_username)); - break; - default: - // on error - binding.ringUsernameTxtBox.setErrorEnabled(false); - binding.ringUsernameTxtBox.setError(null); - break; - } - } - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - binding = FragRegisterNameBinding.inflate(getActivity().getLayoutInflater()); - View view = binding.getRoot(); - - // dependency injection - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - - String accountId = ""; - boolean hasPassword = true; - Bundle args = getArguments(); - if (args != null) { - accountId = args.getString(AccountEditionFragment.ACCOUNT_ID_KEY, accountId); - hasPassword = args.getBoolean(AccountEditionFragment.ACCOUNT_HAS_PASSWORD_KEY, true); - } - - mUsernameTextWatcher = new RegisteredNameTextWatcher(getActivity(), mAccountService, accountId, binding.ringUsernameTxtBox, binding.ringUsername); - binding.ringUsername.setFilters(new InputFilter[]{new RegisteredNameFilter()}); - binding.ringUsername.addTextChangedListener(mUsernameTextWatcher); - // binding.ringUsername.setOnEditorActionListener((v, actionId, event) -> RegisterNameDialog.this.onEditorAction(v, actionId)); - - binding.passwordTxtBox.setVisibility(hasPassword ? View.VISIBLE : View.GONE); - binding.passwordTxt.setOnEditorActionListener((v, actionId, event) -> RegisterNameDialog.this.onEditorAction(v, actionId)); - - AlertDialog dialog = (AlertDialog) getDialog(); - if (dialog != null) { - dialog.setView(view); - dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); - } - - AlertDialog result = new MaterialAlertDialogBuilder(requireContext()) - .setView(view) - .setMessage(R.string.register_username) - .setTitle(R.string.register_name) - .setPositiveButton(android.R.string.ok, null) //Set to null. We override the onclick - .setNegativeButton(android.R.string.cancel, (d, b) -> dismiss()) - .create(); - - result.setOnShowListener(d -> { - Button positiveButton = ((AlertDialog) d).getButton(AlertDialog.BUTTON_POSITIVE); - positiveButton.setOnClickListener(view1 -> { - if (validate()) { - dismiss(); - } - }); - }); - - return result; - } - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - if (binding != null) { - binding.ringUsername.addTextChangedListener(mUsernameTextWatcher); - } - } - - @Override - public void onResume() { - super.onResume(); - mDisposableListener = mAccountService - .getRegisteredNames() - .observeOn(mUiScheduler) - .subscribe(r -> onLookupResult(r.state, r.name)); - } - - @Override - public void onPause() { - super.onPause(); - mDisposableListener.dispose(); - } - - @Override - public void onDetach() { - if (binding != null) { - binding.ringUsername.removeTextChangedListener(mUsernameTextWatcher); - } - super.onDetach(); - } - - private boolean isValidUsername() { - return binding.ringUsernameTxtBox.getError() == null; - } - - private boolean checkInput() { - if (binding.ringUsername.getText() == null || binding.ringUsername.getText().length() == 0) { - binding.ringUsernameTxtBox.setErrorEnabled(true); - binding.ringUsernameTxtBox.setError(getText(R.string.prompt_new_username)); - return false; - } - - if (!isValidUsername()) { - binding.ringUsername.requestFocus(); - return false; - } - - binding.ringUsernameTxtBox.setErrorEnabled(false); - binding.ringUsernameTxtBox.setError(null); - - if (binding.passwordTxtBox.getVisibility() == View.VISIBLE) { - if (binding.passwordTxt.getText() == null || binding.passwordTxt.getText().length() == 0) { - binding.passwordTxtBox.setErrorEnabled(true); - binding.passwordTxtBox.setError(getString(R.string.prompt_password)); - return false; - } else { - binding.passwordTxtBox.setErrorEnabled(false); - binding.passwordTxtBox.setError(null); - } - } - return true; - } - - private boolean validate() { - if (checkInput() && mListener != null) { - final String username = binding.ringUsername.getText().toString(); - final String password = binding.passwordTxt.getText().toString(); - mListener.onRegisterName(username, password); - return true; - } - return false; - } - - private boolean onEditorAction(TextView v, int actionId) { - if (v == binding.passwordTxt) { - if (actionId == EditorInfo.IME_ACTION_DONE) { - boolean validationResult = validate(); - if (validationResult) { - Dialog dialog = getDialog(); - if (dialog != null) - dialog.dismiss(); - } - - return validationResult; - } - } - return false; - } - - public interface RegisterNameDialogListener { - void onRegisterName(String name, String password); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/account/RegisterNameDialog.kt b/ring-android/app/src/main/java/cx/ring/account/RegisterNameDialog.kt new file mode 100644 index 000000000..887db2ab0 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/account/RegisterNameDialog.kt @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.account + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.text.InputFilter +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.View +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import cx.ring.R +import cx.ring.databinding.FragRegisterNameBinding +import cx.ring.utils.RegisteredNameFilter +import cx.ring.utils.RegisteredNameTextWatcher +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.Disposable +import net.jami.services.AccountService +import net.jami.services.AccountService.RegisteredName +import javax.inject.Inject + +@AndroidEntryPoint +class RegisterNameDialog : DialogFragment() { + @Inject + lateinit var mAccountService: AccountService + + private var mUsernameTextWatcher: TextWatcher? = null + private var mListener: RegisterNameDialogListener? = null + private var mDisposableListener: Disposable? = null + private var binding: FragRegisterNameBinding? = null + fun setListener(l: RegisterNameDialogListener?) { + mListener = l + } + + private fun onLookupResult(state: Int, name: String) { + binding?.let { binding -> + val actualName: CharSequence = binding.ringUsername.text!! + if (actualName.isEmpty()) { + binding.ringUsernameTxtBox.isErrorEnabled = false + binding.ringUsernameTxtBox.error = null + return + } + if (name.contentEquals(actualName)) { + when (state) { + 0 -> { + // on found + binding.ringUsernameTxtBox.isErrorEnabled = true + binding.ringUsernameTxtBox.error = getText(R.string.username_already_taken) + } + 1 -> { + // invalid name + binding.ringUsernameTxtBox.isErrorEnabled = true + binding.ringUsernameTxtBox.error = getText(R.string.invalid_username) + } + else -> { + // on error + binding.ringUsernameTxtBox.isErrorEnabled = false + binding.ringUsernameTxtBox.error = null + } + } + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = FragRegisterNameBinding.inflate(layoutInflater) + val view: View = binding!!.root + var accountId = "" + var hasPassword = true + val args = arguments + if (args != null) { + accountId = args.getString(AccountEditionFragment.ACCOUNT_ID_KEY, accountId) + hasPassword = args.getBoolean(AccountEditionFragment.ACCOUNT_HAS_PASSWORD_KEY, true) + } + mUsernameTextWatcher = RegisteredNameTextWatcher( + requireContext(), + mAccountService, + accountId, + binding!!.ringUsernameTxtBox, + binding!!.ringUsername + ) + binding!!.ringUsername.filters = arrayOf<InputFilter>(RegisteredNameFilter()) + binding!!.ringUsername.addTextChangedListener(mUsernameTextWatcher) + // binding.ringUsername.setOnEditorActionListener((v, actionId, event) -> RegisterNameDialog.this.onEditorAction(v, actionId)); + binding!!.passwordTxtBox.visibility = if (hasPassword) View.VISIBLE else View.GONE + binding!!.passwordTxt.setOnEditorActionListener { v: TextView, actionId: Int, event: KeyEvent? -> + onEditorAction(v, actionId) + } + val dialog = dialog as AlertDialog? + if (dialog != null) { + dialog.setView(view) + dialog.window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + } + val result = MaterialAlertDialogBuilder(requireContext()) + .setView(view) + .setMessage(R.string.register_username) + .setTitle(R.string.register_name) + .setPositiveButton(android.R.string.ok, null) //Set to null. We override the onclick + .setNegativeButton(android.R.string.cancel) { d: DialogInterface?, b: Int -> dismiss() } + .create() + result.setOnShowListener { d: DialogInterface -> + val positiveButton = (d as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE) + positiveButton.setOnClickListener { + if (validate()) { + dismiss() + } + } + } + return result + } + + override fun onAttach(context: Context) { + super.onAttach(context) + if (binding != null) { + binding!!.ringUsername.addTextChangedListener(mUsernameTextWatcher) + } + } + + override fun onResume() { + super.onResume() + mDisposableListener = mAccountService.registeredNames + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { r: RegisteredName -> onLookupResult(r.state, r.name) } + } + + override fun onPause() { + super.onPause() + mDisposableListener!!.dispose() + } + + override fun onDetach() { + if (binding != null) { + binding!!.ringUsername.removeTextChangedListener(mUsernameTextWatcher) + } + super.onDetach() + } + + private val isValidUsername: Boolean + get() = binding!!.ringUsernameTxtBox.error == null + + private fun checkInput(): Boolean { + binding?.let { binding -> + if (binding.ringUsername.text == null || binding.ringUsername.text!!.isEmpty()) { + binding.ringUsernameTxtBox.isErrorEnabled = true + binding.ringUsernameTxtBox.error = getText(R.string.prompt_new_username) + return false + } + if (!isValidUsername) { + binding.ringUsername.requestFocus() + return false + } + binding.ringUsernameTxtBox.isErrorEnabled = false + binding.ringUsernameTxtBox.error = null + if (binding.passwordTxtBox.visibility == View.VISIBLE) { + if (binding.passwordTxt.text == null || binding.passwordTxt.text!!.isEmpty()) { + binding.passwordTxtBox.isErrorEnabled = true + binding.passwordTxtBox.error = getString(R.string.prompt_password) + return false + } else { + binding.passwordTxtBox.isErrorEnabled = false + binding.passwordTxtBox.error = null + } + } + } + return true + } + + private fun validate(): Boolean { + if (checkInput() && mListener != null) { + val username = binding!!.ringUsername.text!!.toString() + val password = binding!!.passwordTxt.text!!.toString() + mListener!!.onRegisterName(username, password) + return true + } + return false + } + + private fun onEditorAction(v: TextView, actionId: Int): Boolean { + if (v === binding?.passwordTxt) { + if (actionId == EditorInfo.IME_ACTION_DONE) { + val validationResult = validate() + if (validationResult) { + dialog?.dismiss() + } + return validationResult + } + } + return false + } + + interface RegisterNameDialogListener { + fun onRegisterName(name: String?, password: String?) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/account/RenameDeviceDialog.java b/ring-android/app/src/main/java/cx/ring/account/RenameDeviceDialog.java deleted file mode 100644 index 19b830984..000000000 --- a/ring-android/app/src/main/java/cx/ring/account/RenameDeviceDialog.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ -package cx.ring.account; - -import android.app.Dialog; -import android.os.Bundle; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; - -import android.view.WindowManager; -import android.view.inputmethod.EditorInfo; -import android.widget.Button; - -import androidx.fragment.app.DialogFragment; -import cx.ring.R; -import cx.ring.databinding.DialogDeviceRenameBinding; - -public class RenameDeviceDialog extends DialogFragment { - public static final String DEVICENAME_KEY = "devicename_key"; - static final String TAG = RenameDeviceDialog.class.getSimpleName(); - private RenameDeviceListener mListener = null; - private DialogDeviceRenameBinding binding; - - public void setListener(RenameDeviceListener listener) { - mListener = listener; - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - binding = DialogDeviceRenameBinding.inflate(getActivity().getLayoutInflater()); - - binding.ringDeviceNameTxt.setText(getArguments().getString(DEVICENAME_KEY)); - binding.ringDeviceNameTxt.setOnEditorActionListener((v, actionId, event) -> { - if (actionId == EditorInfo.IME_ACTION_DONE) { - boolean validationResult = validate(); - if (validationResult) { - getDialog().dismiss(); - } - return validationResult; - } - return false; - }); - - final AlertDialog dialog = new MaterialAlertDialogBuilder(requireContext()) - .setView(binding.getRoot()) - .setTitle(R.string.rename_device_title) - .setMessage(R.string.rename_device_message) - .setPositiveButton(R.string.rename_device_button, null) - .setNegativeButton(android.R.string.cancel, null) - .create(); - dialog.setOnShowListener(dialog1 -> { - Button button = ((AlertDialog) dialog1).getButton(AlertDialog.BUTTON_POSITIVE); - button.setOnClickListener(view1 -> { - if (validate()) { - dialog1.dismiss(); - } - }); - }); - dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); - return dialog; - } - - @Override - public void onDestroy() { - mListener = null; - super.onDestroy(); - } - - private boolean checkInput(String input) { - if (input.isEmpty()) { - binding.ringDeviceNameTxtBox.setErrorEnabled(true); - binding.ringDeviceNameTxtBox.setError(getText(R.string.account_device_name_empty)); - return false; - } else { - binding.ringDeviceNameTxtBox.setErrorEnabled(false); - binding.ringDeviceNameTxtBox.setError(null); - } - return true; - } - - private boolean validate() { - String input = binding.ringDeviceNameTxt.getText().toString().trim(); - if (checkInput(input) && mListener != null) { - mListener.onDeviceRename(input); - return true; - } - return false; - } - - public interface RenameDeviceListener { - void onDeviceRename(String newName); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/account/RenameDeviceDialog.kt b/ring-android/app/src/main/java/cx/ring/account/RenameDeviceDialog.kt new file mode 100644 index 000000000..2eab16808 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/account/RenameDeviceDialog.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ +package cx.ring.account + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.KeyEvent +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import cx.ring.R +import cx.ring.databinding.DialogDeviceRenameBinding + +class RenameDeviceDialog : DialogFragment() { + private var mListener: RenameDeviceListener? = null + private var binding: DialogDeviceRenameBinding? = null + fun setListener(listener: RenameDeviceListener?) { + mListener = listener + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogDeviceRenameBinding.inflate(layoutInflater) + binding!!.ringDeviceNameTxt.setText(requireArguments().getString(DEVICENAME_KEY)) + binding!!.ringDeviceNameTxt.setOnEditorActionListener { v: TextView?, actionId: Int, event: KeyEvent? -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + val validationResult = validate() + if (validationResult) { + requireDialog().dismiss() + } + return@setOnEditorActionListener validationResult + } + false + } + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding!!.root) + .setTitle(R.string.rename_device_title) + .setMessage(R.string.rename_device_message) + .setPositiveButton(R.string.rename_device_button, null) + .setNegativeButton(android.R.string.cancel, null) + .create() + .apply { + setOnShowListener { d: DialogInterface -> + val button = (d as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE) + button.setOnClickListener { + if (validate()) { + d.dismiss() + } + } + } + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + } + } + + override fun onDestroy() { + mListener = null + super.onDestroy() + } + + private fun checkInput(input: String): Boolean { + if (input.isEmpty()) { + binding?.apply { + ringDeviceNameTxtBox.isErrorEnabled = true + ringDeviceNameTxtBox.error = getText(R.string.account_device_name_empty) + } + return false + } else { + binding?.apply { + ringDeviceNameTxtBox.isErrorEnabled = false + ringDeviceNameTxtBox.error = null + } + } + return true + } + + private fun validate(): Boolean { + val input = binding!!.ringDeviceNameTxt.text.toString().trim { it <= ' ' } + if (checkInput(input) && mListener != null) { + mListener!!.onDeviceRename(input) + return true + } + return false + } + + interface RenameDeviceListener { + fun onDeviceRename(newName: String?) + } + + companion object { + const val DEVICENAME_KEY = "devicename_key" + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/adapters/ConfParticipantAdapter.java b/ring-android/app/src/main/java/cx/ring/adapters/ConfParticipantAdapter.java deleted file mode 100644 index 74ae2720c..000000000 --- a/ring-android/app/src/main/java/cx/ring/adapters/ConfParticipantAdapter.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.adapters; - -import android.content.Context; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.List; - -import cx.ring.fragments.CallFragment; -import cx.ring.views.AvatarDrawable; -import cx.ring.views.AvatarFactory; -import cx.ring.databinding.ItemConferenceParticipantBinding; - -import net.jami.model.Conference; -import net.jami.model.Contact; -import net.jami.model.Call; -import cx.ring.views.ParticipantView; - -public class ConfParticipantAdapter extends RecyclerView.Adapter<ParticipantView> { - protected final ConfParticipantAdapter.ConfParticipantSelected onSelectedCallback; - private List<Conference.ParticipantInfo> calls = null; - - public ConfParticipantAdapter(@NonNull ConfParticipantSelected cb) { - onSelectedCallback = cb; - } - - @NonNull - @Override - public ParticipantView onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ParticipantView(ItemConferenceParticipantBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ParticipantView holder, int position) { - final Conference.ParticipantInfo info = calls.get(position); - final Contact contact = info.contact; - - final Context context = holder.itemView.getContext(); - if (info.call != null && info.call.getCallStatus() != Call.CallStatus.CURRENT) { - holder.binding.displayName.setText(String.format("%s\n%s", contact.getDisplayName(), context.getText(CallFragment.callStateToHumanState(info.call.getCallStatus())))); - holder.binding.photo.setAlpha(.5f); - } else { - holder.binding.displayName.setText(contact.getDisplayName()); - holder.binding.photo.setAlpha(1f); - } - - if (holder.disposable != null) - holder.disposable.dispose(); - - holder.binding.photo.setImageDrawable(new AvatarDrawable.Builder() - .withContact(contact) - .withCircleCrop(true) - .withPresence(false) - .build(context)); - /*; - holder.disposable = AvatarFactory.getAvatar(context, contact) - .subscribe(holder.binding.photo::setImageDrawable);*/ - holder.itemView.setOnClickListener(view -> onSelectedCallback.onParticipantSelected(view, info)); - } - - @Override - public int getItemCount() { - return calls == null ? 0 : calls.size(); - } - - public void updateFromCalls(@NonNull final List<Conference.ParticipantInfo> contacts) { - final List<Conference.ParticipantInfo> oldCalls = calls; - calls = contacts; - if (oldCalls != null) { - DiffUtil.calculateDiff(new DiffUtil.Callback() { - @Override - public int getOldListSize() { - return oldCalls.size(); - } - - @Override - public int getNewListSize() { - return contacts.size(); - } - - @Override - public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { - return oldCalls.get(oldItemPosition) == contacts.get(newItemPosition); - } - - @Override - public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { - return false; - } - }).dispatchUpdatesTo(this); - } else { - notifyDataSetChanged(); - } - } - - public interface ConfParticipantSelected { - void onParticipantSelected(View view, Conference.ParticipantInfo contact); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/adapters/ConfParticipantAdapter.kt b/ring-android/app/src/main/java/cx/ring/adapters/ConfParticipantAdapter.kt new file mode 100644 index 000000000..dfb49a492 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/adapters/ConfParticipantAdapter.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import cx.ring.databinding.ItemConferenceParticipantBinding +import cx.ring.fragments.CallFragment +import cx.ring.views.AvatarDrawable +import cx.ring.views.ParticipantView +import net.jami.model.Call +import net.jami.model.Conference.ParticipantInfo + +class ConfParticipantAdapter(private val onSelectedCallback: ConfParticipantSelected) : + RecyclerView.Adapter<ParticipantView>() { + private var calls: List<ParticipantInfo>? = null + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ParticipantView { + return ParticipantView(ItemConferenceParticipantBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun onBindViewHolder(holder: ParticipantView, position: Int) { + val info = calls!![position] + val contact = info.contact + val context = holder.itemView.context + val call = info.call + if (call != null && call.callStatus != Call.CallStatus.CURRENT) { + holder.binding.displayName.text = String.format("%s\n%s", contact.displayName, context.getText(CallFragment.callStateToHumanState(call.callStatus))) + holder.binding.photo.alpha = .5f + } else { + holder.binding.displayName.text = contact.displayName + holder.binding.photo.alpha = 1f + } + if (holder.disposable != null) holder.disposable.dispose() + holder.binding.photo.setImageDrawable( + AvatarDrawable.Builder() + .withContact(contact) + .withCircleCrop(true) + .withPresence(false) + .build(context) + ) + /*; + holder.disposable = AvatarFactory.getAvatar(context, contact) + .subscribe(holder.binding.photo::setImageDrawable);*/ + holder.itemView.setOnClickListener { view: View -> + onSelectedCallback.onParticipantSelected(view, info) + } + } + + override fun getItemCount(): Int { + return if (calls == null) 0 else calls!!.size + } + + fun updateFromCalls(contacts: List<ParticipantInfo>) { + val oldCalls = calls + calls = contacts + if (oldCalls != null) { + DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int { + return oldCalls.size + } + + override fun getNewListSize(): Int { + return contacts.size + } + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldCalls[oldItemPosition] === contacts[newItemPosition] + } + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { + return false + } + }).dispatchUpdatesTo(this) + } else { + notifyDataSetChanged() + } + } + + interface ConfParticipantSelected { + fun onParticipantSelected(view: View, contact: ParticipantInfo) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.java b/ring-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.java deleted file mode 100644 index f0400896a..000000000 --- a/ring-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.java +++ /dev/null @@ -1,1246 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.adapters; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ValueAnimator; -import android.content.Context; -import android.content.Intent; -import android.content.res.Resources; -import android.graphics.Color; -import android.graphics.SurfaceTexture; -import android.graphics.drawable.Drawable; -import android.media.MediaPlayer; -import android.net.Uri; -import android.os.Build; -import android.text.TextUtils; -import android.text.format.DateUtils; -import android.text.format.Formatter; -import android.util.Log; -import android.util.TypedValue; -import android.view.ContextMenu; -import android.view.LayoutInflater; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.Surface; -import android.view.TextureView; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.widget.FrameLayout; -import android.widget.ImageView; - -import androidx.annotation.LayoutRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.cardview.widget.CardView; -import androidx.core.app.ActivityOptionsCompat; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.recyclerview.widget.RecyclerView; -import androidx.vectordrawable.graphics.drawable.Animatable2Compat; -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; - -import com.bumptech.glide.load.resource.bitmap.CenterInside; -import com.bumptech.glide.load.resource.bitmap.RoundedCorners; -import com.bumptech.glide.request.target.DrawableImageViewTarget; - -import net.jami.conversation.ConversationPresenter; -import net.jami.model.Account; -import net.jami.model.Call; -import net.jami.model.Contact; -import net.jami.model.ContactEvent; -import net.jami.model.DataTransfer; -import net.jami.model.Interaction; -import net.jami.model.Interaction.InteractionStatus; -import net.jami.model.Interaction.InteractionType; -import net.jami.model.TextMessage; -import net.jami.utils.StringUtils; - -import java.io.File; -import java.text.DateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.TimeUnit; - -import cx.ring.R; -import cx.ring.client.MediaViewerActivity; -import cx.ring.fragments.ConversationFragment; -import cx.ring.utils.ContentUriHandler; -import cx.ring.utils.GlideApp; -import cx.ring.utils.GlideOptions; -import cx.ring.utils.ResourceMapper; -import cx.ring.views.ConversationViewHolder; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; - -public class ConversationAdapter extends RecyclerView.Adapter<ConversationViewHolder> { - private final static String TAG = ConversationAdapter.class.getSimpleName(); - - private final ArrayList<Interaction> mInteractions = new ArrayList<>(); - - private final ConversationPresenter presenter; - private final ConversationFragment conversationFragment; - private final int hPadding; - private final int vPadding; - private final int mPictureMaxSize; - private final GlideOptions PICTURE_OPTIONS; - private RecyclerViewContextMenuInfo mCurrentLongItem = null; - private int convColor = 0; - - private int expandedItemPosition = -1; - private int lastDeliveredPosition = -1; - private int lastDisplayedPosition = -1; - private final Observable<Long> timestampUpdateTimer; - private int lastMsgPos = -1; - - private boolean isComposing = false; - private boolean mShowReadIndicator = true; - - private static final int[] msgBGLayouts = new int[] { - R.drawable.textmsg_bg_out_first, - R.drawable.textmsg_bg_out_middle, - R.drawable.textmsg_bg_out_last, - R.drawable.textmsg_bg_out, - R.drawable.textmsg_bg_in_first, - R.drawable.textmsg_bg_in_middle, - R.drawable.textmsg_bg_in_last, - R.drawable.textmsg_bg_in - }; - - public ConversationAdapter(ConversationFragment fragment, ConversationPresenter p) { - conversationFragment = fragment; - presenter = p; - Resources res = conversationFragment.getResources(); - hPadding = res.getDimensionPixelSize(R.dimen.padding_medium); - vPadding = res.getDimensionPixelSize(R.dimen.padding_small); - mPictureMaxSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200, res.getDisplayMetrics()); - int corner = (int) res.getDimension(R.dimen.conversation_message_radius); - PICTURE_OPTIONS = new GlideOptions() - .transform(new CenterInside()) - .fitCenter() - .override(mPictureMaxSize) - .transform(new RoundedCorners(corner)); - timestampUpdateTimer = Observable.interval(10, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) - .startWithItem(0L); - } - - /** - * Refreshes the data and notifies the changes - * - * @param list an arraylist of interactions - */ - public void updateDataset(final List<Interaction> list) { - Log.d(TAG, "updateDataset: list size=" + list.size()); - if (mInteractions.isEmpty()) { - mInteractions.addAll(list); - } else if (list.size() > mInteractions.size()) { - mInteractions.addAll(list.subList(mInteractions.size(), list.size())); - } else { - mInteractions.clear(); - mInteractions.addAll(list); - } - notifyDataSetChanged(); - } - - public boolean add(Interaction e) { - if (!TextUtils.isEmpty(e.getMessageId())) { - if (mInteractions.isEmpty() || e.getParentIds().contains(mInteractions.get(mInteractions.size()-1).getMessageId())) { - boolean update = !mInteractions.isEmpty(); - mInteractions.add(e); - notifyItemInserted(mInteractions.size()-1); - if (update) - notifyItemChanged(mInteractions.size()-2); - return true; - } - for (int i = 0, n = mInteractions.size(); i<n; i++) { - if (mInteractions.get(i).getParentIds().contains(e.getMessageId())) { - Log.w(TAG, "Adding message at " + i + " previous count " + n); - mInteractions.add(i, e); - notifyItemInserted(i); - return i == n-1; - } - } - } else { - boolean update = !mInteractions.isEmpty(); - mInteractions.add(e); - notifyItemInserted(mInteractions.size() - 1); - if (update) - notifyItemChanged(mInteractions.size() - 2); - } - return true; - } - - public void update(Interaction e) { - Log.w(TAG, "update " + e.getMessageId()); - if (!e.isIncoming() && e.getStatus() == InteractionStatus.SUCCESS) { - notifyItemChanged(lastDeliveredPosition); - } - for (int i = mInteractions.size() - 1; i >= 0; i--) { - Interaction element = mInteractions.get(i); - if (e == element) { - notifyItemChanged(i); - break; - } - } - } - - public void remove(Interaction e) { - if (e.getMessageId() != null) { - for (int i = mInteractions.size() - 1; i >= 0; i--) { - if (e.getMessageId().equals(mInteractions.get(i).getMessageId())) { - mInteractions.remove(i); - notifyItemRemoved(i); - if (i > 0) { - notifyItemChanged(i - 1); - } - if (i != mInteractions.size()) { - notifyItemChanged(i); - } - break; - } - } - } else { - for (int i = mInteractions.size() - 1; i >= 0; i--) { - if (e.getId() == mInteractions.get(i).getId()) { - mInteractions.remove(i); - notifyItemRemoved(i); - if (i > 0) { - notifyItemChanged(i - 1); - } - if (i != mInteractions.size()) { - notifyItemChanged(i); - } - break; - } - } - } - } - - /** - * Updates the contact photo to use for this conversation - */ - public void setPhoto() { - notifyDataSetChanged(); - } - - @Override - public int getItemCount() { - return mInteractions.size() + (isComposing ? 1 : 0); - } - - @Override - public long getItemId(int position) { - if (isComposing && position == mInteractions.size()) - return Long.MAX_VALUE; - return mInteractions.get(position).getId(); - } - - @Override - public int getItemViewType(int position) { - if (isComposing && position == mInteractions.size()) - return MessageType.COMPOSING_INDICATION.ordinal(); - - Interaction interaction = mInteractions.get(position); - - if (interaction != null) { - switch (interaction.getType()) { - case CONTACT: - return MessageType.CONTACT_EVENT.ordinal(); - case CALL: - return MessageType.CALL_INFORMATION.ordinal(); - case TEXT: - if (interaction.isIncoming()) { - return MessageType.INCOMING_TEXT_MESSAGE.ordinal(); - } else { - return MessageType.OUTGOING_TEXT_MESSAGE.ordinal(); - } - case DATA_TRANSFER: - DataTransfer file = (DataTransfer) interaction; - int out = interaction.isIncoming() ? 0 : 4; - if (file.isComplete()) { - if (file.isPicture()) { - return MessageType.INCOMING_IMAGE.ordinal() + out; - } else if (file.isAudio()) { - return MessageType.INCOMING_AUDIO.ordinal() + out; - } else if (file.isVideo()) { - return MessageType.INCOMING_VIDEO.ordinal() + out; - } - } - return out; - case INVALID: - return MessageType.INVALID.ordinal(); - } - } - return -1; - } - - @NonNull - @Override - public ConversationViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - MessageType type = MessageType.values()[viewType]; - ViewGroup v = type == MessageType.INVALID ? new FrameLayout(parent.getContext()) : (ViewGroup) LayoutInflater.from(parent.getContext()).inflate(type.layout, parent, false); - return new ConversationViewHolder(v, type); - } - - @Override - public void onBindViewHolder(@NonNull ConversationViewHolder conversationViewHolder, int position) { - if (isComposing && position == mInteractions.size()) { - configureForTypingIndicator(conversationViewHolder); - return; - } - - Interaction interaction = mInteractions.get(position); - if (interaction == null) - return; - - conversationViewHolder.compositeDisposable.clear(); - - if (position > lastMsgPos) { - lastMsgPos = position; - Animation animation = AnimationUtils.loadAnimation( - conversationViewHolder.itemView.getContext(), R.anim.fade_in); - animation.setStartOffset(150); - conversationViewHolder.itemView.startAnimation(animation); - } - - //Log.w(TAG, "onBindViewHolder " + interaction.getType() + " " + interaction); - if (interaction.getType() == InteractionType.INVALID) { - conversationViewHolder.itemView.setVisibility(View.GONE); - } else { - conversationViewHolder.itemView.setVisibility(View.VISIBLE); - if (interaction.getType() == InteractionType.TEXT) { - configureForTextMessage(conversationViewHolder, interaction, position); - } else if (interaction.getType() == InteractionType.CALL) { - configureForCallInfo(conversationViewHolder, interaction); - } else if (interaction.getType() == InteractionType.CONTACT) { - configureForContactEvent(conversationViewHolder, interaction); - } else if (interaction.getType() == InteractionType.DATA_TRANSFER) { - configureForFileInfo(conversationViewHolder, interaction, position); - } - } - } - - @Override - public void onViewRecycled(@NonNull ConversationViewHolder holder) { - holder.itemView.setOnLongClickListener(null); - if (holder.mImage != null) { - holder.mImage.setOnLongClickListener(null); - } - if (holder.video != null) { - holder.video.setOnClickListener(null); - holder.video.setSurfaceTextureListener(null); - } - if (holder.surface != null) { - holder.surface.release(); - holder.surface = null; - } - if (holder.player != null) { - try { - if (holder.player.isPlaying()) - holder.player.stop(); - holder.player.reset(); - } catch (Exception e) { - // left blank intentionally - } - holder.player.release(); - holder.player = null; - } - if (holder.mMsgTxt != null) { - holder.mMsgTxt.setOnLongClickListener(null); - } - if (holder.mItem != null) { - holder.mItem.setOnClickListener(null); - } - if (expandedItemPosition == holder.getLayoutPosition()) { - if (holder.mMsgDetailTxt != null) - holder.mMsgDetailTxt.setVisibility(View.GONE); - expandedItemPosition = -1; - } - holder.compositeDisposable.clear(); - } - - public void setPrimaryColor(int color) { - convColor = color; - notifyDataSetChanged(); - } - - public void setComposingStatus(Account.ComposingStatus composingStatus) { - boolean composing = composingStatus == Account.ComposingStatus.Active; - if (isComposing != composing) { - isComposing = composing; - if (composing) - notifyItemInserted(mInteractions.size()); - else - notifyItemRemoved(mInteractions.size()); - } - } - - public void setReadIndicatorStatus(boolean show) { - mShowReadIndicator = show; - } - - public void setLastDisplayed(Interaction interaction) { - Log.w(TAG, "setLastDisplayed " + interaction.getDaemonId()); - for (int i = mInteractions.size() - 1; i >= 0; i--) { - Interaction element = mInteractions.get(i); - if (interaction.getId() == element.getId()) { - if (lastDisplayedPosition != -1) - notifyItemChanged(lastDisplayedPosition); - lastDisplayedPosition = i; - notifyItemChanged(i); - Log.w(TAG, "new displayed item " + i); - break; - } - } - } - - private static class RecyclerViewContextMenuInfo implements ContextMenu.ContextMenuInfo { - RecyclerViewContextMenuInfo(int position, long id) { - this.position = position; - this.id = id; - } - final public int position; - final public long id; - } - - public boolean onContextItemSelected(MenuItem item) { - ConversationAdapter.RecyclerViewContextMenuInfo info = mCurrentLongItem; - Interaction interaction = null; - if (info == null) { - return false; - } - try { - interaction = mInteractions.get(info.position); - } catch (IndexOutOfBoundsException e) { - Log.e(TAG, "Interaction array may be empty or null", e); - } - if (interaction == null) - return false; - if (interaction.getType() == (InteractionType.CONTACT)) - return false; - - int itemId = item.getItemId(); - if (itemId == R.id.conv_action_download) { - presenter.saveFile(interaction); - } else if (itemId == R.id.conv_action_share) { - presenter.shareFile(interaction); - } else if (itemId == R.id.conv_action_open) { - presenter.openFile(interaction); - } else if (itemId == R.id.conv_action_delete) { - presenter.deleteConversationItem(interaction); - } else if (itemId == R.id.conv_action_cancel_message) { - presenter.cancelMessage(interaction); - } else if (itemId == R.id.conv_action_copy_text) { - addToClipboard((interaction).getBody()); - } - return true; - } - - private void addToClipboard(String text) { - android.content.ClipboardManager clipboard = (android.content.ClipboardManager) conversationFragment.requireActivity().getSystemService(Context.CLIPBOARD_SERVICE); - android.content.ClipData clip = android.content.ClipData.newPlainText("Copied Message", text); - clipboard.setPrimaryClip(clip); - } - - private void configureImage(@NonNull final ConversationViewHolder viewHolder, @NonNull File path) { - Context context = viewHolder.mImage.getContext(); - - GlideApp.with(context) - .load(path) - .apply(PICTURE_OPTIONS) - .into(new DrawableImageViewTarget(viewHolder.mImage).waitForLayout()); - - viewHolder.mImage.setOnClickListener(v -> { - Uri contentUri = ContentUriHandler.getUriForFile(v.getContext(), ContentUriHandler.AUTHORITY_FILES, path); - Intent i = new Intent(context, MediaViewerActivity.class) - .setAction(Intent.ACTION_VIEW) - .setDataAndType(contentUri, "image/*") - .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - ActivityOptionsCompat options = ActivityOptionsCompat. - makeSceneTransitionAnimation(conversationFragment.getActivity(), viewHolder.mImage, "picture"); - conversationFragment.startActivityForResult(i, 3006, options.toBundle()); - }); - } - - private void configureAudio(@NonNull final ConversationViewHolder viewHolder, @NonNull File path) { - Context context = viewHolder.itemView.getContext(); - try { - ((ImageView) viewHolder.btnAccept).setImageResource(R.drawable.baseline_play_arrow_24); - final MediaPlayer player = MediaPlayer.create(context, - ContentUriHandler.getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, path)); - viewHolder.player = player; - if (player != null) { - player.setOnCompletionListener(mp -> { - player.seekTo(0); - ((ImageView) viewHolder.btnAccept).setImageResource(R.drawable.baseline_play_arrow_24); - }); - viewHolder.btnAccept.setOnClickListener((b) -> { - if (player.isPlaying()) { - player.pause(); - ((ImageView) viewHolder.btnAccept).setImageResource(R.drawable.baseline_play_arrow_24); - } else { - player.start(); - ((ImageView) viewHolder.btnAccept).setImageResource(R.drawable.baseline_pause_24); - } - }); - viewHolder.btnRefuse.setOnClickListener((b) -> { - if (player.isPlaying()) - player.pause(); - player.seekTo(0); - ((ImageView) viewHolder.btnAccept).setImageResource(R.drawable.baseline_play_arrow_24); - }); - viewHolder.compositeDisposable.add(Observable.interval(1L, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) - .startWithItem(0L) - .subscribe(t -> { - int pS = player.getCurrentPosition() / 1000; - int dS = player.getDuration() / 1000; - viewHolder.mMsgTxt.setText(String.format(Locale.getDefault(), - "%02d:%02d / %02d:%02d", pS / 60, pS % 60, dS / 60, dS % 60)); - })); - } else { - viewHolder.btnAccept.setOnClickListener(null); - viewHolder.btnRefuse.setOnClickListener(null); - } - } catch (IllegalStateException | NullPointerException e) { - Log.e(TAG, "Error initializing player, it may have already been released: " + e.getMessage()); - } - } - - private void configureVideo(@NonNull final ConversationViewHolder viewHolder, @NonNull File path) { - Context context = viewHolder.itemView.getContext(); - if (viewHolder.player != null) { - viewHolder.player.release(); - viewHolder.player = null; - } - final MediaPlayer player = MediaPlayer.create(context, - ContentUriHandler.getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, path)); - if (player == null) - return; - viewHolder.player = player; - final Drawable playBtn = ContextCompat.getDrawable(viewHolder.mLayout.getContext(), R.drawable.baseline_play_arrow_24).mutate(); - DrawableCompat.setTint(playBtn, Color.WHITE); - ((CardView) viewHolder.mLayout).setForeground(playBtn); - player.setOnCompletionListener(mp -> { - if (player.isPlaying()) - player.pause(); - player.seekTo(1); - ((CardView) viewHolder.mLayout).setForeground(playBtn); - }); - player.setOnVideoSizeChangedListener((mp, width, height) -> { - Log.w(TAG, "OnVideoSizeChanged " + width + "x" + height); - FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) viewHolder.video.getLayoutParams(); - int maxDim = Math.max(width, height); - p.width = width * mPictureMaxSize / maxDim; - p.height = height * mPictureMaxSize / maxDim; - viewHolder.video.setLayoutParams(p); - }); - if (viewHolder.video.isAvailable()) { - if (viewHolder.surface == null) { - viewHolder.surface = new Surface(viewHolder.video.getSurfaceTexture()); - } - player.setSurface(viewHolder.surface); - } - viewHolder.video.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { - @Override - public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { - if (viewHolder.surface == null) { - viewHolder.surface = new Surface(surface); - try { - player.setSurface(viewHolder.surface); - } catch (Exception e) { - // Left blank - } - } - } - - @Override - public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { - } - - @Override - public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { - try { - player.setSurface(null); - } catch (Exception e) { - // Left blank - } - player.release(); - if (viewHolder.surface != null) { - viewHolder.surface.release(); - viewHolder.surface = null; - } - return true; - } - - @Override - public void onSurfaceTextureUpdated(SurfaceTexture surface) { - } - }); - viewHolder.video.setOnClickListener(v -> { - try { - if (player.isPlaying()) { - player.pause(); - ((CardView) viewHolder.mLayout).setForeground(playBtn); - } else { - player.start(); - ((CardView) viewHolder.mLayout).setForeground(null); - } - } catch (Exception e) { - // Left blank - } - }); - player.seekTo(1); - } - - private void configureForFileInfo(@NonNull final ConversationViewHolder viewHolder, - @NonNull final Interaction interaction, int position) { - DataTransfer file = (DataTransfer) interaction; - - File path = presenter.getDeviceRuntimeService().getConversationPath(file); - if (file.isComplete()) - file.setSize(path.length()); - String timeString = timestampToDetailString(viewHolder.itemView.getContext(), file.getTimestamp()); - viewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe(time -> { - InteractionStatus status = file.getStatus(); - if (status == InteractionStatus.TRANSFER_FINISHED) { - viewHolder.mMsgDetailTxt.setText(String.format("%s - %s", - timeString, Formatter.formatFileSize(viewHolder.itemView.getContext(), file.getTotalSize()))); - } else if (status == InteractionStatus.TRANSFER_ONGOING) { - viewHolder.mMsgDetailTxt.setText(String.format("%s / %s - %s", - Formatter.formatFileSize(viewHolder.itemView.getContext(), file.getBytesProgress()), Formatter.formatFileSize(viewHolder.itemView.getContext(), file.getTotalSize()), - ResourceMapper.getReadableFileTransferStatus(viewHolder.itemView.getContext(), status))); - } else { - viewHolder.mMsgDetailTxt.setText(String.format("%s - %s - %s", - timeString, Formatter.formatFileSize(viewHolder.itemView.getContext(), file.getTotalSize()), - ResourceMapper.getReadableFileTransferStatus(viewHolder.itemView.getContext(), status))); - } - })); - - TransferMsgType type = viewHolder.type.getTransferType(); - viewHolder.compositeDisposable.clear(); - if (hasPermanentTimeString(file, position)) { - viewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe(t -> { - String timeSeparationString = timestampToDetailString(viewHolder.itemView.getContext(), file.getTimestamp()); - viewHolder.mMsgDetailTxtPerm.setText(timeSeparationString); - })); - viewHolder.mMsgDetailTxtPerm.setVisibility(View.VISIBLE); - } else { - viewHolder.mMsgDetailTxtPerm.setVisibility(View.GONE); - } - - Contact contact = interaction.getContact(); - if (interaction.isIncoming()) { - viewHolder.mAvatar.setImageBitmap(null); - viewHolder.mAvatar.setVisibility(View.VISIBLE); - if (contact != null) { - viewHolder.mAvatar.setImageDrawable(conversationFragment.getConversationAvatar(contact.getPrimaryNumber())); - } - } else { - switch (interaction.getStatus()) { - case SENDING: - viewHolder.mStatusIcon.setVisibility(View.VISIBLE); - viewHolder.mStatusIcon.setImageResource(R.drawable.baseline_circle_24); - break; - case FAILURE: - viewHolder.mStatusIcon.setVisibility(View.VISIBLE); - viewHolder.mStatusIcon.setImageResource(R.drawable.round_highlight_off_24); - break; - case DISPLAYED: - viewHolder.mStatusIcon.setVisibility(mShowReadIndicator ? View.VISIBLE : View.GONE); - viewHolder.mStatusIcon.setImageDrawable(conversationFragment.getSmallConversationAvatar(contact.getPrimaryNumber())); - break; - default: - viewHolder.mStatusIcon.setVisibility(View.VISIBLE); - viewHolder.mStatusIcon.setImageResource(R.drawable.baseline_check_circle_24); - lastDeliveredPosition = position; - } - } - - View longPressView = type == TransferMsgType.IMAGE ? - viewHolder.mImage : (type == TransferMsgType.VIDEO) ? - viewHolder.video : (type == TransferMsgType.AUDIO) ? - viewHolder.mAudioInfoLayout : viewHolder.mFileInfoLayout; - if (longPressView == null) { - return; - } - if (type == TransferMsgType.AUDIO || type == TransferMsgType.FILE) { - longPressView.getBackground().setTintList(null); - } - - longPressView.setOnCreateContextMenuListener((menu, v, menuInfo) -> { - menu.setHeaderTitle(file.getDisplayName()); - new MenuInflater(v.getContext()).inflate(R.menu.conversation_item_actions_file, menu); - if (file.getStatus() == InteractionStatus.TRANSFER_ONGOING) { - menu.findItem(R.id.conv_action_delete).setTitle(android.R.string.cancel); - menu.removeItem(R.id.conv_action_download); - menu.removeItem(R.id.conv_action_share); - menu.removeItem(R.id.conv_action_open); - } else { - if (!file.isComplete()) { - menu.removeItem(R.id.conv_action_download); - menu.removeItem(R.id.conv_action_share); - } - } - conversationFragment.onCreateContextMenu(menu, v, menuInfo); - }); - longPressView.setOnLongClickListener(v -> { - if (type == TransferMsgType.AUDIO || type == TransferMsgType.FILE) { - conversationFragment.updatePosition(viewHolder.getAdapterPosition()); - longPressView.getBackground().setTint(conversationFragment.getResources().getColor(R.color.grey_500)); - } - mCurrentLongItem = new RecyclerViewContextMenuInfo(viewHolder.getAdapterPosition(), v.getId()); - return false; - }); - - if (type == TransferMsgType.IMAGE) { - configureImage(viewHolder, path); - } else if (type == TransferMsgType.VIDEO) { - configureVideo(viewHolder, path); - } else if (type == TransferMsgType.AUDIO) { - configureAudio(viewHolder, path); - } else { - InteractionStatus status = file.getStatus(); - if (status.isError()) { - viewHolder.mIcon.setImageResource(R.drawable.baseline_warning_24); - } else { - viewHolder.mIcon.setImageResource(R.drawable.baseline_attach_file_24); - } - - viewHolder.mMsgTxt.setText(file.getDisplayName()); - - if (status == InteractionStatus.TRANSFER_AWAITING_HOST) { - viewHolder.btnRefuse.setVisibility(View.VISIBLE); - viewHolder.mAnswerLayout.setVisibility(View.VISIBLE); - viewHolder.btnAccept.setOnClickListener(v -> presenter.acceptFile(file)); - viewHolder.btnRefuse.setOnClickListener(v -> presenter.refuseFile(file)); - } else if (status == InteractionStatus.FILE_AVAILABLE) { - viewHolder.btnRefuse.setVisibility(View.GONE); - viewHolder.mAnswerLayout.setVisibility(View.VISIBLE); - viewHolder.btnAccept.setOnClickListener(v -> presenter.acceptFile(file)); - } else { - viewHolder.mAnswerLayout.setVisibility(View.GONE); - if (status == InteractionStatus.TRANSFER_ONGOING) { - viewHolder.progress.setMax((int) (file.getTotalSize() / 1024)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - viewHolder.progress.setProgress((int) (file.getBytesProgress() / 1024), true); - } else { - viewHolder.progress.setProgress((int) (file.getBytesProgress() / 1024)); - } - viewHolder.progress.show(); - } else { - viewHolder.progress.hide(); - } - } - } - } - - private void configureForTypingIndicator(@NonNull final ConversationViewHolder viewHolder) { - AnimatedVectorDrawableCompat anim = AnimatedVectorDrawableCompat.create(viewHolder.itemView.getContext(), R.drawable.typing_indicator_animation); - if (anim != null) { - viewHolder.mStatusIcon.setImageDrawable(anim); - anim.registerAnimationCallback(new Animatable2Compat.AnimationCallback() { - @Override - public void onAnimationEnd(Drawable drawable) { - anim.start(); - } - }); - anim.start(); - } - } - - /** - * Configures the viewholder to display a classic text message, ie. not a call info text message - * - * @param convViewHolder The conversation viewHolder - * @param interaction The conversation element to display - * @param position The position of the viewHolder - */ - private void configureForTextMessage(@NonNull final ConversationViewHolder convViewHolder, - @NonNull final Interaction interaction, - int position) { - final Context context = convViewHolder.itemView.getContext(); - TextMessage textMessage = (TextMessage)interaction; - Contact contact = textMessage.getContact(); - if (contact == null) { - Log.e(TAG, "Invalid contact, not able to display message correctly"); - return; - } - // Log.w(TAG, "configureForTextMessage " + position + " " + interaction.getDaemonId() + " " + interaction.getStatus()); - - String message = textMessage.getBody().trim(); - View longPressView = convViewHolder.mMsgTxt; - longPressView.getBackground().setTintList(null); - - longPressView.setOnCreateContextMenuListener((menu, v, menuInfo) -> { - Date date = new Date(interaction.getTimestamp()); - DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT); - menu.setHeaderTitle(dateFormat.format(date)); - conversationFragment.onCreateContextMenu(menu, v, menuInfo); - MenuInflater inflater = conversationFragment.getActivity().getMenuInflater(); - inflater.inflate(R.menu.conversation_item_actions_messages, menu); - - if ((interaction).getStatus() == (InteractionStatus.SENDING)) { - menu.removeItem(R.id.conv_action_delete); - } else { - menu.findItem(R.id.conv_action_delete).setTitle(R.string.menu_message_delete); - menu.removeItem(R.id.conv_action_cancel_message); - } - }); - - longPressView.setOnLongClickListener((View v) -> { - if (expandedItemPosition == position) { - expandedItemPosition = -1; - } - conversationFragment.updatePosition(convViewHolder.getBindingAdapterPosition()); - if (textMessage.isIncoming()) { - longPressView.getBackground().setTint(conversationFragment.getResources().getColor(R.color.grey_500)); - } else { - longPressView.getBackground().setTint(conversationFragment.getResources().getColor(R.color.blue_900)); - } - mCurrentLongItem = new RecyclerViewContextMenuInfo(convViewHolder.getBindingAdapterPosition(), v.getId()); - return false; - }); - - final boolean isTimeShown = hasPermanentTimeString(textMessage, position); - final SequenceType msgSequenceType = getMsgSequencing(position, isTimeShown); - if (StringUtils.isOnlyEmoji(message)) { - convViewHolder.mMsgTxt.getBackground().setAlpha(0); - convViewHolder.mMsgTxt.setTextSize(32.0f); - convViewHolder.mMsgTxt.setPadding(0, 0, 0, 0); - } else { - int resIndex = msgSequenceType.ordinal() + (textMessage.isIncoming() ? 1 : 0) * 4; - convViewHolder.mMsgTxt.setBackground(ContextCompat.getDrawable(context, msgBGLayouts[resIndex])); - if (convColor != 0 && !textMessage.isIncoming()) { - convViewHolder.mMsgTxt.getBackground().setTint(convColor); - } - convViewHolder.mMsgTxt.getBackground().setAlpha(255); - convViewHolder.mMsgTxt.setTextSize(16.f); - convViewHolder.mMsgTxt.setPadding(hPadding, vPadding, hPadding, vPadding); - } - - convViewHolder.mMsgTxt.setText(message); - - boolean endOfSeq = msgSequenceType == SequenceType.LAST || msgSequenceType == SequenceType.SINGLE; - if (textMessage.isIncoming()) { - if (endOfSeq) { - convViewHolder.mAvatar.setImageDrawable( - conversationFragment.getConversationAvatar(contact.getPrimaryNumber()) - ); - convViewHolder.mAvatar.setVisibility(View.VISIBLE); - } else { - if (position == lastMsgPos - 1 && convViewHolder.mAvatar != null) { - Animation animation = AnimationUtils.loadAnimation( - convViewHolder.mAvatar.getContext(), R.anim.fade_out); - animation.setAnimationListener(new Animation.AnimationListener(){ - @Override - public void onAnimationStart(Animation arg0) { - } - @Override - public void onAnimationRepeat(Animation arg0) { - } - @Override - public void onAnimationEnd(Animation arg0) { - convViewHolder.mAvatar.setImageBitmap(null); - convViewHolder.mAvatar.setVisibility(View.INVISIBLE); - } - }); - convViewHolder.mAvatar.startAnimation(animation); - } else { - if (convViewHolder.mAvatar != null) { - convViewHolder.mAvatar.setImageBitmap(null); - convViewHolder.mAvatar.setVisibility(View.INVISIBLE); - } - } - } - } else { - switch (textMessage.getStatus()) { - case SENDING: - convViewHolder.mStatusIcon.setVisibility(View.VISIBLE); - convViewHolder.mStatusIcon.setImageResource(R.drawable.baseline_circle_24); - break; - case FAILURE: - convViewHolder.mStatusIcon.setVisibility(View.VISIBLE); - convViewHolder.mStatusIcon.setImageResource(R.drawable.round_highlight_off_24); - break; - case DISPLAYED: - if (lastDisplayedPosition == position) { - convViewHolder.mStatusIcon.setVisibility(mShowReadIndicator ? View.VISIBLE : View.GONE); - convViewHolder.mStatusIcon.setImageDrawable(conversationFragment.getSmallConversationAvatar(contact.getPrimaryNumber())); - } else { - convViewHolder.mStatusIcon.setVisibility(View.GONE); - convViewHolder.mStatusIcon.setImageDrawable(null); - } - break; - default: - if (position == lastOutgoingIndex()) { - convViewHolder.mStatusIcon.setVisibility(View.VISIBLE); - convViewHolder.mStatusIcon.setImageResource(R.drawable.baseline_check_circle_24); - lastDeliveredPosition = position; - } else { - convViewHolder.mStatusIcon.setVisibility(View.GONE); - convViewHolder.mStatusIcon.setImageDrawable(null); - } - } - } - - setBottomMargin(convViewHolder.mMsgTxt, endOfSeq ? 8 : 0); - - if (isTimeShown) { - convViewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe(t -> { - String timeSeparationString = timestampToDetailString(context, textMessage.getTimestamp()); - convViewHolder.mMsgDetailTxtPerm.setText(timeSeparationString); - })); - convViewHolder.mMsgDetailTxtPerm.setVisibility(View.VISIBLE); - } else { - convViewHolder.mMsgDetailTxtPerm.setVisibility(View.GONE); - final boolean isExpanded = position == expandedItemPosition; - if (isExpanded) { - convViewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe(t -> { - String timeSeparationString = timestampToDetailString(context, textMessage.getTimestamp()); - convViewHolder.mMsgDetailTxt.setText(timeSeparationString); - })); - } - setItemViewExpansionState(convViewHolder, isExpanded); - convViewHolder.mItem.setOnClickListener((View v) -> { - if (convViewHolder.animator != null && convViewHolder.animator.isRunning()) { - return; - } - if (expandedItemPosition >= 0) { - int prev = expandedItemPosition; - notifyItemChanged(prev); - } - expandedItemPosition = isExpanded ? -1 : position; - notifyItemChanged(expandedItemPosition); - }); - } - } - - private void configureForContactEvent(@NonNull final ConversationViewHolder viewHolder, @NonNull final Interaction interaction) { - ContactEvent event = (ContactEvent) interaction; - if (event.event == ContactEvent.Event.ADDED) { - viewHolder.mMsgTxt.setText(R.string.hist_contact_added); - } else if (event.event == ContactEvent.Event.INCOMING_REQUEST) { - viewHolder.mMsgTxt.setText(R.string.hist_invitation_received); - } - viewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe(t -> { - String timeSeparationString = timestampToDetailString(viewHolder.itemView.getContext(), event.getTimestamp()); - viewHolder.mMsgDetailTxt.setText(timeSeparationString); - })); - } - - /** - * Configures the viewholder to display a call info text message, ie. not a classic text message - * - * @param convViewHolder The conversation viewHolder - * @param interaction The conversation element to display - */ - private void configureForCallInfo(@NonNull final ConversationViewHolder convViewHolder, - @NonNull final Interaction interaction) { - convViewHolder.mIcon.setScaleY(1); - Context context = convViewHolder.itemView.getContext(); - - View longPressView = convViewHolder.mCallInfoLayout; - longPressView.getBackground().setTintList(null); - longPressView.setOnCreateContextMenuListener((menu, v, menuInfo) -> { - conversationFragment.onCreateContextMenu(menu, v, menuInfo); - MenuInflater inflater = conversationFragment.getActivity().getMenuInflater(); - inflater.inflate(R.menu.conversation_item_actions_messages, menu); - - menu.findItem(R.id.conv_action_delete).setTitle(R.string.menu_delete); - menu.removeItem(R.id.conv_action_cancel_message); - menu.removeItem(R.id.conv_action_copy_text); - }); - - longPressView.setOnLongClickListener((View v) -> { - longPressView.getBackground().setTint(conversationFragment.getResources().getColor(R.color.grey_500)); - conversationFragment.updatePosition(convViewHolder.getAdapterPosition()); - mCurrentLongItem = new RecyclerViewContextMenuInfo(convViewHolder.getAdapterPosition(), v.getId()); - return false; - }); - - int pictureResID; - String historyTxt; - Call call = (Call) interaction; - if (call.isMissed()) { - if (call.isIncoming()) { - pictureResID = R.drawable.baseline_call_missed_24; - } else { - pictureResID = R.drawable.baseline_call_missed_outgoing_24; - // Flip the photo upside down to show a "missed outgoing call" - convViewHolder.mIcon.setScaleY(-1); - } - historyTxt = call.isIncoming() ? - context.getString(R.string.notif_missed_incoming_call) : - context.getString(R.string.notif_missed_outgoing_call); - } else { - pictureResID = (call.isIncoming()) ? - R.drawable.baseline_call_received_24 : - R.drawable.baseline_call_made_24; - historyTxt = call.isIncoming() ? - context.getString(R.string.notif_incoming_call) : - context.getString(R.string.notif_outgoing_call); - } - - convViewHolder.mIcon.setImageResource(pictureResID); - convViewHolder.mHistTxt.setText(historyTxt); - convViewHolder.mHistDetailTxt.setText(DateFormat.getDateTimeInstance() - .format(call.getTimestamp())); // start date - } - - /** - * Computes the string to set in text details between messages, indicating time separation. - * - * @param timestamp The timestamp used to launch the computation with Date().getTime(). - * Can be the last received message timestamp for example. - * @return The string to display in the text details between messages. - */ - private String timestampToDetailString(Context context, long timestamp) { - long diff = new Date().getTime() - timestamp; - String timeStr; - if (diff < DateUtils.WEEK_IN_MILLIS) { - if (diff < DateUtils.DAY_IN_MILLIS && DateUtils.isToday(timestamp)) { // 11:32 A.M. - timeStr = DateUtils.formatDateTime(context, timestamp, DateUtils.FORMAT_SHOW_TIME); - } else { - timeStr = DateUtils.formatDateTime(context, timestamp, - DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_NO_YEAR | - DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_TIME); - } - } else if (diff < DateUtils.YEAR_IN_MILLIS) { // JAN. 7, 11:02 A.M. - timeStr = DateUtils.formatDateTime(context, timestamp, - DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_YEAR | - DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_TIME); - } else { - timeStr = DateUtils.formatDateTime(context, timestamp, - DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_DATE | - DateUtils.FORMAT_SHOW_YEAR | DateUtils.FORMAT_SHOW_WEEKDAY | - DateUtils.FORMAT_ABBREV_ALL); - } - return timeStr.toUpperCase(Locale.getDefault()); - } - - /** - * Helper method to return the previous TextMessage relative to an initial position. - * - * @param position The initial position - * @return the previous TextMessage if any, null otherwise - */ - @Nullable - private Interaction getPreviousMessageFromPosition(int position) { - if (!mInteractions.isEmpty() && position > 0) { - return mInteractions.get(position - 1); - } - return null; - } - - /** - * Helper method to return the next TextMessage relative to an initial position. - * - * @param position The initial position - * @return the next TextMessage if any, null otherwise - */ - @Nullable - private Interaction getNextMessageFromPosition(int position) { - if (!mInteractions.isEmpty() && position < mInteractions.size() - 1) { - return mInteractions.get(position + 1); - } - return null; - } - - private boolean isSeqBreak(@NonNull Interaction first, @NonNull Interaction second) { - return StringUtils.isOnlyEmoji(first.getBody()) != StringUtils.isOnlyEmoji(second.getBody()) - || first.isIncoming() != second.isIncoming() - || first.getType() != InteractionType.TEXT - || second.getType() != InteractionType.TEXT; - } - - private boolean isAlwaysSingleMsg(@NonNull Interaction msg) { - return msg.getType() != InteractionType.TEXT - || StringUtils.isOnlyEmoji(msg.getBody()); - } - - private SequenceType getMsgSequencing(final int i, final boolean isTimeShown) { - Interaction msg = mInteractions.get(i); - if (isAlwaysSingleMsg(msg)) { - return SequenceType.SINGLE; - } - if (mInteractions.size() == 1 || i == 0) { - if (mInteractions.size() == i + 1) { - return SequenceType.SINGLE; - } - Interaction nextMsg = getNextMessageFromPosition(i); - if (nextMsg != null) { - if (isSeqBreak(msg, nextMsg) || hasPermanentTimeString(nextMsg, i + 1)) { - return SequenceType.SINGLE; - } else { - return SequenceType.FIRST; - } - } - } else if (mInteractions.size() == i + 1) { - Interaction prevMsg = getPreviousMessageFromPosition(i); - if (prevMsg != null) { - if (isSeqBreak(msg, prevMsg) || isTimeShown) { - return SequenceType.SINGLE; - } else { - return SequenceType.LAST; - } - } - } - Interaction prevMsg = getPreviousMessageFromPosition(i); - Interaction nextMsg = getNextMessageFromPosition(i); - if (prevMsg != null && nextMsg != null) { - boolean nextMsgHasTime = hasPermanentTimeString(nextMsg, i + 1); - if (((isSeqBreak(msg, prevMsg) || isTimeShown) && !(isSeqBreak(msg, nextMsg) || nextMsgHasTime))) { - return SequenceType.FIRST; - } else if (!isSeqBreak(msg, prevMsg) && !isTimeShown && isSeqBreak(msg, nextMsg)) { - return SequenceType.LAST; - } else if (!isSeqBreak(msg, prevMsg) && !isTimeShown && !isSeqBreak(msg, nextMsg)) { - return nextMsgHasTime ? SequenceType.LAST : SequenceType.MIDDLE; - } - } - return SequenceType.SINGLE; - } - - private void setItemViewExpansionState(ConversationViewHolder viewHolder, boolean expanded) { - View view = viewHolder.mMsgDetailTxt; - if (viewHolder.animator == null) { - if (view.getHeight() == 0 && !expanded) { - return; - } - viewHolder.animator = new ValueAnimator(); - } - if (viewHolder.animator.isRunning()) { - viewHolder.animator.reverse(); - return; - } - view.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); - viewHolder.animator.setIntValues(0, view.getMeasuredHeight()); - if (expanded) { - view.setVisibility(View.VISIBLE); - } - viewHolder.animator.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - ValueAnimator va = (ValueAnimator) animation; - if ((Integer) va.getAnimatedValue() == 0) { - view.setVisibility(View.GONE); - } - viewHolder.animator = null; - } - }); - viewHolder.animator.setDuration(200); - viewHolder.animator.addUpdateListener(animation -> { - view.getLayoutParams().height = (Integer) animation.getAnimatedValue(); - view.requestLayout(); - }); - if (!expanded) { - viewHolder.animator.reverse(); - } else { - viewHolder.animator.start(); - } - } - - private static void setBottomMargin(View view, int value) { - int targetSize = (int) (value * view.getContext().getResources().getDisplayMetrics().density); - ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); - params.bottomMargin = targetSize; - } - - private boolean hasPermanentTimeString(final Interaction msg, int position) { - if (msg == null) { - return false; - } - Interaction prevMsg = getPreviousMessageFromPosition(position); - return (prevMsg != null && - (msg.getTimestamp() - prevMsg.getTimestamp()) > 10 * DateUtils.MINUTE_IN_MILLIS); - } - - private int lastOutgoingIndex() { - int i; - for (i = mInteractions.size() - 1; i >= 0; i--) { - if (!mInteractions.get(i).isIncoming()) { - break; - } - } - return i; - } - - private enum SequenceType { - FIRST, - MIDDLE, - LAST, - SINGLE - } - - private enum TransferMsgType { - FILE, - IMAGE, - AUDIO, - VIDEO - } - public enum MessageType { - INCOMING_FILE(R.layout.item_conv_file_peer), - INCOMING_IMAGE(R.layout.item_conv_image_peer), - INCOMING_AUDIO(R.layout.item_conv_audio_peer), - INCOMING_VIDEO(R.layout.item_conv_video_peer), - OUTGOING_FILE(R.layout.item_conv_file_me), - OUTGOING_IMAGE(R.layout.item_conv_image_me), - OUTGOING_AUDIO(R.layout.item_conv_audio_me), - OUTGOING_VIDEO(R.layout.item_conv_video_me), - CONTACT_EVENT(R.layout.item_conv_contact), - CALL_INFORMATION(R.layout.item_conv_call), - INCOMING_TEXT_MESSAGE(R.layout.item_conv_msg_peer), - OUTGOING_TEXT_MESSAGE(R.layout.item_conv_msg_me), - COMPOSING_INDICATION(R.layout.item_conv_composing), - INVALID(-1); - - @LayoutRes private final int layout; - - MessageType(@LayoutRes int l) { - layout = l; - } - - boolean isFile() { - return this == INCOMING_FILE || this == OUTGOING_FILE; - } - boolean isAudio() { - return this == INCOMING_AUDIO || this == OUTGOING_AUDIO; - } - boolean isVideo() { - return this == INCOMING_VIDEO || this == OUTGOING_VIDEO; - } - boolean isImage() { - return this == INCOMING_IMAGE || this == OUTGOING_IMAGE; - } - - public TransferMsgType getTransferType() { - return isFile() ? TransferMsgType.FILE - : (isImage() ? TransferMsgType.IMAGE - : (isAudio() ? TransferMsgType.AUDIO - : (isVideo() ? TransferMsgType.VIDEO : TransferMsgType.FILE))); - } - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.kt b/ring-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.kt new file mode 100644 index 000000000..6647eaef7 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.kt @@ -0,0 +1,1141 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.adapters + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.SurfaceTexture +import android.graphics.drawable.Drawable +import android.media.MediaPlayer +import android.os.Build +import android.text.TextUtils +import android.text.format.DateUtils +import android.text.format.Formatter +import android.util.Log +import android.util.TypedValue +import android.view.* +import android.view.ContextMenu.ContextMenuInfo +import android.view.TextureView.SurfaceTextureListener +import android.view.ViewGroup.MarginLayoutParams +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.annotation.LayoutRes +import androidx.cardview.widget.CardView +import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.recyclerview.widget.RecyclerView +import androidx.vectordrawable.graphics.drawable.Animatable2Compat +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.bumptech.glide.load.resource.bitmap.CenterInside +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.target.DrawableImageViewTarget +import cx.ring.R +import cx.ring.client.MediaViewerActivity +import cx.ring.fragments.ConversationFragment +import cx.ring.utils.ContentUriHandler +import cx.ring.utils.ContentUriHandler.getUriForFile +import cx.ring.utils.GlideApp +import cx.ring.utils.GlideOptions +import cx.ring.utils.ResourceMapper +import cx.ring.views.ConversationViewHolder +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import net.jami.conversation.ConversationPresenter +import net.jami.model.* +import net.jami.model.Account.ComposingStatus +import net.jami.model.Interaction.InteractionStatus +import net.jami.utils.StringUtils +import java.io.File +import java.text.DateFormat +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.math.max + +class ConversationAdapter( + private val conversationFragment: ConversationFragment, + private val presenter: ConversationPresenter +) : RecyclerView.Adapter<ConversationViewHolder>() { + + private val mInteractions = ArrayList<Interaction>() + private val hPadding: Int + private val vPadding: Int + private val mPictureMaxSize: Int + private val PICTURE_OPTIONS: GlideOptions + private var mCurrentLongItem: RecyclerViewContextMenuInfo? = null + private var convColor = 0 + private var expandedItemPosition = -1 + private var lastDeliveredPosition = -1 + private var lastDisplayedPosition = -1 + private val timestampUpdateTimer: Observable<Long> + private var lastMsgPos = -1 + private var isComposing = false + private var mShowReadIndicator = true + + /** + * Refreshes the data and notifies the changes + * + * @param list an arraylist of interactions + */ + @SuppressLint("NotifyDataSetChanged") + fun updateDataset(list: List<Interaction>) { + Log.d(TAG, "updateDataset: list size=" + list.size) + when { + mInteractions.isEmpty() -> { + mInteractions.addAll(list) + notifyDataSetChanged() + } + list.size > mInteractions.size -> { + val oldSize = mInteractions.size + mInteractions.addAll(list.subList(oldSize, list.size)) + notifyItemRangeInserted(oldSize, list.size) + } + else -> { + mInteractions.clear() + mInteractions.addAll(list) + notifyDataSetChanged() + } + } + } + + fun add(e: Interaction): Boolean { + if (!TextUtils.isEmpty(e.messageId)) { + if (mInteractions.isEmpty() || mInteractions[mInteractions.size - 1].messageId == e.parentId) { + val update = mInteractions.isNotEmpty() + mInteractions.add(e) + notifyItemInserted(mInteractions.size - 1) + if (update) notifyItemChanged(mInteractions.size - 2) + return true + } + var i = 0 + val n = mInteractions.size + while (i < n) { + if (e.messageId == mInteractions[i].parentId) { + Log.w(TAG, "Adding message at $i previous count $n") + mInteractions.add(i, e) + notifyItemInserted(i) + return i == n - 1 + } + i++ + } + } else { + val update = mInteractions.isNotEmpty() + mInteractions.add(e) + notifyItemInserted(mInteractions.size - 1) + if (update) notifyItemChanged(mInteractions.size - 2) + } + return true + } + + fun update(e: Interaction) { + Log.w(TAG, "update " + e.messageId) + if (!e.isIncoming && e.status == InteractionStatus.SUCCESS) { + notifyItemChanged(lastDeliveredPosition) + } + for (i in mInteractions.indices.reversed()) { + val element = mInteractions[i] + if (e === element) { + notifyItemChanged(i) + break + } + } + } + + fun remove(e: Interaction) { + if (e.messageId != null) { + for (i in mInteractions.indices.reversed()) { + if (e.messageId == mInteractions[i].messageId) { + mInteractions.removeAt(i) + notifyItemRemoved(i) + if (i > 0) { + notifyItemChanged(i - 1) + } + if (i != mInteractions.size) { + notifyItemChanged(i) + } + break + } + } + } else { + for (i in mInteractions.indices.reversed()) { + if (e.id == mInteractions[i].id) { + mInteractions.removeAt(i) + notifyItemRemoved(i) + if (i > 0) { + notifyItemChanged(i - 1) + } + if (i != mInteractions.size) { + notifyItemChanged(i) + } + break + } + } + } + } + + /** + * Updates the contact photo to use for this conversation + */ + fun setPhoto() { + notifyDataSetChanged() + } + + override fun getItemCount(): Int { + return mInteractions.size + if (isComposing) 1 else 0 + } + + override fun getItemId(position: Int): Long { + return if (isComposing && position == mInteractions.size) Long.MAX_VALUE else mInteractions[position].id.toLong() + } + + override fun getItemViewType(position: Int): Int { + if (isComposing && position == mInteractions.size) return MessageType.COMPOSING_INDICATION.ordinal + val interaction = mInteractions[position] + return when (interaction.type) { + Interaction.InteractionType.CONTACT -> MessageType.CONTACT_EVENT.ordinal + Interaction.InteractionType.CALL -> MessageType.CALL_INFORMATION.ordinal + Interaction.InteractionType.TEXT -> if (interaction.isIncoming) { + MessageType.INCOMING_TEXT_MESSAGE.ordinal + } else { + MessageType.OUTGOING_TEXT_MESSAGE.ordinal + } + Interaction.InteractionType.DATA_TRANSFER -> { + val file = interaction as DataTransfer + val out = if (interaction.isIncoming) 0 else 4 + if (file.isComplete) { + when { + file.isPicture -> return MessageType.INCOMING_IMAGE.ordinal + out + file.isAudio -> return MessageType.INCOMING_AUDIO.ordinal + out + file.isVideo -> return MessageType.INCOMING_VIDEO.ordinal + out + } + } + out + } + Interaction.InteractionType.INVALID -> MessageType.INVALID.ordinal + null -> MessageType.INVALID.ordinal + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { + val type = MessageType.values()[viewType] + val v = if (type == MessageType.INVALID) FrameLayout(parent.context) + else (LayoutInflater.from(parent.context).inflate(type.layout, parent, false) as ViewGroup) + return ConversationViewHolder(v, type) + } + + override fun onBindViewHolder(conversationViewHolder: ConversationViewHolder, position: Int) { + if (isComposing && position == mInteractions.size) { + configureForTypingIndicator(conversationViewHolder) + return + } + val interaction = mInteractions[position] + conversationViewHolder.compositeDisposable.clear() + if (position > lastMsgPos) { + lastMsgPos = position + val animation = AnimationUtils.loadAnimation(conversationViewHolder.itemView.context, R.anim.fade_in) + animation.startOffset = 150 + conversationViewHolder.itemView.startAnimation(animation) + } + + //Log.w(TAG, "onBindViewHolder " + interaction.getType() + " " + interaction); + if (interaction.type == Interaction.InteractionType.INVALID) { + conversationViewHolder.itemView.visibility = View.GONE + } else { + conversationViewHolder.itemView.visibility = View.VISIBLE + if (interaction.type == Interaction.InteractionType.TEXT) { + configureForTextMessage(conversationViewHolder, interaction, position) + } else if (interaction.type == Interaction.InteractionType.CALL) { + configureForCallInfo(conversationViewHolder, interaction) + } else if (interaction.type == Interaction.InteractionType.CONTACT) { + configureForContactEvent(conversationViewHolder, interaction) + } else if (interaction.type == Interaction.InteractionType.DATA_TRANSFER) { + configureForFileInfo(conversationViewHolder, interaction, position) + } + } + } + + override fun onViewRecycled(holder: ConversationViewHolder) { + holder.itemView.setOnLongClickListener(null) + if (holder.mImage != null) { + holder.mImage.setOnLongClickListener(null) + } + if (holder.video != null) { + holder.video.setOnClickListener(null) + holder.video.surfaceTextureListener = null + } + if (holder.surface != null) { + holder.surface.release() + holder.surface = null + } + if (holder.player != null) { + try { + if (holder.player.isPlaying) holder.player.stop() + holder.player.reset() + } catch (e: Exception) { + // left blank intentionally + } + holder.player.release() + holder.player = null + } + if (holder.mMsgTxt != null) { + holder.mMsgTxt.setOnLongClickListener(null) + } + if (holder.mItem != null) { + holder.mItem.setOnClickListener(null) + } + if (expandedItemPosition == holder.layoutPosition) { + if (holder.mMsgDetailTxt != null) holder.mMsgDetailTxt.visibility = View.GONE + expandedItemPosition = -1 + } + holder.compositeDisposable.clear() + } + + fun setPrimaryColor(color: Int) { + convColor = color + notifyDataSetChanged() + } + + fun setComposingStatus(composingStatus: ComposingStatus) { + val composing = composingStatus == ComposingStatus.Active + if (isComposing != composing) { + isComposing = composing + if (composing) notifyItemInserted(mInteractions.size) else notifyItemRemoved( + mInteractions.size + ) + } + } + + fun setReadIndicatorStatus(show: Boolean) { + mShowReadIndicator = show + } + + fun setLastDisplayed(interaction: Interaction) { + Log.w(TAG, "setLastDisplayed " + interaction.daemonId) + for (i in mInteractions.indices.reversed()) { + val element = mInteractions[i] + if (interaction.id == element.id) { + if (lastDisplayedPosition != -1) notifyItemChanged(lastDisplayedPosition) + lastDisplayedPosition = i + notifyItemChanged(i) + Log.w(TAG, "new displayed item $i") + break + } + } + } + + private class RecyclerViewContextMenuInfo( + val position: Int, + val id: Long + ) : ContextMenuInfo + + fun onContextItemSelected(item: MenuItem): Boolean { + val info = mCurrentLongItem ?: return false + var interaction: Interaction? = null + try { + interaction = mInteractions[info.position] + } catch (e: IndexOutOfBoundsException) { + Log.e(TAG, "Interaction array may be empty or null", e) + } + if (interaction == null) return false + if (interaction.type == Interaction.InteractionType.CONTACT) return false + when (item.itemId) { + R.id.conv_action_download -> presenter.saveFile(interaction) + R.id.conv_action_share -> presenter.shareFile(interaction) + R.id.conv_action_open -> presenter.openFile(interaction) + R.id.conv_action_delete -> presenter.deleteConversationItem(interaction) + R.id.conv_action_cancel_message -> presenter.cancelMessage(interaction) + R.id.conv_action_copy_text -> addToClipboard(interaction.body) + } + return true + } + + private fun addToClipboard(text: String?) { + if (text == null || text.isEmpty()) return + val clipboard = conversationFragment.requireActivity() + .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Copied Message", text) + clipboard.setPrimaryClip(clip) + } + + private fun configureImage(viewHolder: ConversationViewHolder, path: File) { + val context = viewHolder.mImage.context + GlideApp.with(context) + .load(path) + .apply(PICTURE_OPTIONS) + .into(DrawableImageViewTarget(viewHolder.mImage).waitForLayout()) + viewHolder.mImage.setOnClickListener { v: View -> + try { + val contentUri = getUriForFile(v.context, ContentUriHandler.AUTHORITY_FILES, path) + val i = Intent(context, MediaViewerActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .setDataAndType(contentUri, "image/*") + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(conversationFragment.requireActivity(), viewHolder.mImage, "picture") + conversationFragment.startActivityForResult(i, 3006, options.toBundle()) + } catch (e: Exception) { + Log.w(TAG, "Can't open picture", e); + } + } + } + + private fun configureAudio(viewHolder: ConversationViewHolder, path: File) { + val context = viewHolder.itemView.context + try { + (viewHolder.btnAccept as ImageView).setImageResource(R.drawable.baseline_play_arrow_24) + val player = MediaPlayer.create(context, getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, path)) + viewHolder.player = player + if (player != null) { + player.setOnCompletionListener { mp: MediaPlayer -> + mp.seekTo(0) + (viewHolder.btnAccept as ImageView).setImageResource(R.drawable.baseline_play_arrow_24) + } + viewHolder.btnAccept.setOnClickListener { + if (player.isPlaying) { + player.pause() + (viewHolder.btnAccept as ImageView).setImageResource(R.drawable.baseline_play_arrow_24) + } else { + player.start() + (viewHolder.btnAccept as ImageView).setImageResource(R.drawable.baseline_pause_24) + } + } + viewHolder.btnRefuse.setOnClickListener { + if (player.isPlaying) player.pause() + player.seekTo(0) + (viewHolder.btnAccept as ImageView).setImageResource(R.drawable.baseline_play_arrow_24) + } + viewHolder.compositeDisposable.add( + Observable.interval(1L, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) + .startWithItem(0L) + .subscribe { t: Long? -> + val pS = player.currentPosition / 1000 + val dS = player.duration / 1000 + viewHolder.mMsgTxt.text = String.format( + Locale.getDefault(), + "%02d:%02d / %02d:%02d", pS / 60, pS % 60, dS / 60, dS % 60 + ) + }) + } else { + viewHolder.btnAccept.setOnClickListener(null) + viewHolder.btnRefuse.setOnClickListener(null) + } + } catch (e: Exception) { + Log.e(TAG, "Error initializing player", e) + } + } + + private fun configureVideo(viewHolder: ConversationViewHolder, path: File) { + val context = viewHolder.itemView.context + viewHolder.player?.let { + viewHolder.player = null + it.release() + } + val player = MediaPlayer.create(context, getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, path)) ?: return + viewHolder.player = player + val playBtn = ContextCompat.getDrawable(viewHolder.mLayout.context, R.drawable.baseline_play_arrow_24)!!.mutate() + DrawableCompat.setTint(playBtn, Color.WHITE) + (viewHolder.mLayout as CardView).foreground = playBtn + player.setOnCompletionListener { mp: MediaPlayer -> + if (mp.isPlaying) mp.pause() + mp.seekTo(1) + (viewHolder.mLayout as CardView).foreground = playBtn + } + player.setOnVideoSizeChangedListener { mp: MediaPlayer, width: Int, height: Int -> + Log.w(TAG, "OnVideoSizeChanged " + width + "x" + height) + val p = viewHolder.video.layoutParams as FrameLayout.LayoutParams + val maxDim = max(width, height) + p.width = width * mPictureMaxSize / maxDim + p.height = height * mPictureMaxSize / maxDim + viewHolder.video.layoutParams = p + } + if (viewHolder.video.isAvailable) { + if (viewHolder.surface == null) { + viewHolder.surface = Surface(viewHolder.video.surfaceTexture) + } + player.setSurface(viewHolder.surface) + } + viewHolder.video.surfaceTextureListener = object : SurfaceTextureListener { + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + if (viewHolder.surface == null) { + viewHolder.surface = Surface(surface) + try { + player.setSurface(viewHolder.surface) + } catch (e: Exception) { + // Left blank + } + } + } + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + try { + player.setSurface(null) + } catch (e: Exception) { + // Left blank + } + player.release() + viewHolder.surface?.let { + viewHolder.surface = null + it.release() + } + return true + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {} + } + viewHolder.video.setOnClickListener { + try { + if (player.isPlaying) { + player.pause() + (viewHolder.mLayout as CardView).foreground = playBtn + } else { + player.start() + (viewHolder.mLayout as CardView).foreground = null + } + } catch (e: Exception) { + // Left blank + } + } + player.seekTo(1) + } + + private fun configureForFileInfo(viewHolder: ConversationViewHolder, interaction: Interaction, position: Int) { + val file = interaction as DataTransfer + val path = presenter.deviceRuntimeService.getConversationPath(file) + //if (file.isComplete()) + // file.setSize(path.length()); + val timeString = timestampToDetailString(viewHolder.itemView.context, file.timestamp) + viewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe { + when (val status = file.status) { + InteractionStatus.TRANSFER_FINISHED -> { + viewHolder.mMsgDetailTxt.text = String.format("%s - %s", timeString, + Formatter.formatFileSize(viewHolder.itemView.context, file.totalSize)) + } + InteractionStatus.TRANSFER_ONGOING -> { + viewHolder.mMsgDetailTxt.text = String.format("%s / %s - %s", + Formatter.formatFileSize(viewHolder.itemView.context, file.bytesProgress), + Formatter.formatFileSize(viewHolder.itemView.context, file.totalSize), + ResourceMapper.getReadableFileTransferStatus(viewHolder.itemView.context, status) + ) + } + else -> { + viewHolder.mMsgDetailTxt.text = String.format("%s - %s - %s", timeString, + Formatter.formatFileSize(viewHolder.itemView.context, file.totalSize), + ResourceMapper.getReadableFileTransferStatus(viewHolder.itemView.context, status) + ) + } + } + }) + val type = viewHolder.type.transferType + viewHolder.compositeDisposable.clear() + if (hasPermanentTimeString(file, position)) { + viewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe { + viewHolder.mMsgDetailTxtPerm.text = timestampToDetailString(viewHolder.itemView.context, file.timestamp) + }) + viewHolder.mMsgDetailTxtPerm.visibility = View.VISIBLE + } else { + viewHolder.mMsgDetailTxtPerm.visibility = View.GONE + } + val contact = interaction.contact + if (interaction.isIncoming) { + viewHolder.mAvatar.setImageBitmap(null) + viewHolder.mAvatar.visibility = View.VISIBLE + if (contact != null) { + viewHolder.mAvatar.setImageDrawable(conversationFragment.getConversationAvatar(contact.primaryNumber)) + } + } else { + when (interaction.status) { + InteractionStatus.SENDING -> { + viewHolder.mStatusIcon.visibility = View.VISIBLE + viewHolder.mStatusIcon.setImageResource(R.drawable.baseline_circle_24) + } + InteractionStatus.FAILURE -> { + viewHolder.mStatusIcon.visibility = View.VISIBLE + viewHolder.mStatusIcon.setImageResource(R.drawable.round_highlight_off_24) + } + InteractionStatus.DISPLAYED -> { + viewHolder.mStatusIcon.visibility = if (mShowReadIndicator) View.VISIBLE else View.GONE + viewHolder.mStatusIcon.setImageDrawable(conversationFragment.getSmallConversationAvatar(contact!!.primaryNumber)) + } + else -> { + viewHolder.mStatusIcon.visibility = View.VISIBLE + viewHolder.mStatusIcon.setImageResource(R.drawable.baseline_check_circle_24) + lastDeliveredPosition = position + } + } + } + val longPressView = + (if (type == TransferMsgType.IMAGE) viewHolder.mImage else if (type == TransferMsgType.VIDEO) viewHolder.video else if (type == TransferMsgType.AUDIO) viewHolder.mAudioInfoLayout else viewHolder.mFileInfoLayout) + ?: return + if (type == TransferMsgType.AUDIO || type == TransferMsgType.FILE) { + longPressView.background.setTintList(null) + } + longPressView.setOnCreateContextMenuListener { menu: ContextMenu, v: View, menuInfo: ContextMenuInfo? -> + menu.setHeaderTitle(file.displayName) + MenuInflater(v.context).inflate(R.menu.conversation_item_actions_file, menu) + if (file.status == InteractionStatus.TRANSFER_ONGOING) { + menu.findItem(R.id.conv_action_delete).setTitle(android.R.string.cancel) + menu.removeItem(R.id.conv_action_download) + menu.removeItem(R.id.conv_action_share) + menu.removeItem(R.id.conv_action_open) + } else { + if (!file.isComplete) { + menu.removeItem(R.id.conv_action_download) + menu.removeItem(R.id.conv_action_share) + } + } + conversationFragment.onCreateContextMenu(menu, v, menuInfo) + } + longPressView.setOnLongClickListener { v: View -> + if (type == TransferMsgType.AUDIO || type == TransferMsgType.FILE) { + conversationFragment.updatePosition(viewHolder.adapterPosition) + longPressView.background.setTint(conversationFragment.resources.getColor(R.color.grey_500)) + } + mCurrentLongItem = RecyclerViewContextMenuInfo(viewHolder.adapterPosition, v.id.toLong()) + false + } + if (type == TransferMsgType.IMAGE) { + configureImage(viewHolder, path) + } else if (type == TransferMsgType.VIDEO) { + configureVideo(viewHolder, path) + } else if (type == TransferMsgType.AUDIO) { + configureAudio(viewHolder, path) + } else { + val status = file.status + if (status.isError) { + viewHolder.mIcon.setImageResource(R.drawable.baseline_warning_24) + } else { + viewHolder.mIcon.setImageResource(R.drawable.baseline_attach_file_24) + } + viewHolder.mMsgTxt.text = file.displayName + if (status == InteractionStatus.TRANSFER_AWAITING_HOST) { + viewHolder.btnRefuse.visibility = View.VISIBLE + viewHolder.mAnswerLayout.visibility = View.VISIBLE + viewHolder.btnAccept.setOnClickListener { presenter.acceptFile(file) } + viewHolder.btnRefuse.setOnClickListener { presenter.refuseFile(file) } + } else if (status == InteractionStatus.FILE_AVAILABLE) { + viewHolder.btnRefuse.visibility = View.GONE + viewHolder.mAnswerLayout.visibility = View.VISIBLE + viewHolder.btnAccept.setOnClickListener { presenter.acceptFile(file) } + } else { + viewHolder.mAnswerLayout.visibility = View.GONE + if (status == InteractionStatus.TRANSFER_ONGOING) { + viewHolder.progress.max = (file.totalSize / 1024).toInt() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + viewHolder.progress.setProgress((file.bytesProgress / 1024).toInt(), true) + } else { + viewHolder.progress.progress = (file.bytesProgress / 1024).toInt() + } + viewHolder.progress.show() + } else { + viewHolder.progress.hide() + } + } + } + } + + private fun configureForTypingIndicator(viewHolder: ConversationViewHolder) { + AnimatedVectorDrawableCompat.create(viewHolder.itemView.context, R.drawable.typing_indicator_animation)?.let { anim -> + viewHolder.mStatusIcon.setImageDrawable(anim) + anim.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable) { + anim.start() + } + }) + anim.start() + } + } + + /** + * Configures the viewholder to display a classic text message, ie. not a call info text message + * + * @param convViewHolder The conversation viewHolder + * @param interaction The conversation element to display + * @param position The position of the viewHolder + */ + private fun configureForTextMessage(convViewHolder: ConversationViewHolder, interaction: Interaction, position: Int) { + val context = convViewHolder.itemView.context + val textMessage = interaction as TextMessage + val contact = textMessage.contact ?: return + // Log.w(TAG, "configureForTextMessage " + position + " " + interaction.getDaemonId() + " " + interaction.getStatus()); + val message = textMessage.body!!.trim { it <= ' ' } + val longPressView: View = convViewHolder.mMsgTxt + longPressView.background.setTintList(null) + longPressView.setOnCreateContextMenuListener { menu: ContextMenu, v: View?, menuInfo: ContextMenuInfo? -> + val date = Date(interaction.timestamp) + val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + menu.setHeaderTitle(dateFormat.format(date)) + conversationFragment.onCreateContextMenu(menu, v!!, menuInfo) + val inflater = conversationFragment.requireActivity().menuInflater + inflater.inflate(R.menu.conversation_item_actions_messages, menu) + if (interaction.status == InteractionStatus.SENDING) { + menu.removeItem(R.id.conv_action_delete) + } else { + menu.findItem(R.id.conv_action_delete).setTitle(R.string.menu_message_delete) + menu.removeItem(R.id.conv_action_cancel_message) + } + } + longPressView.setOnLongClickListener { v: View -> + if (expandedItemPosition == position) { + expandedItemPosition = -1 + } + conversationFragment.updatePosition(convViewHolder.bindingAdapterPosition) + if (textMessage.isIncoming) { + longPressView.background.setTint(conversationFragment.resources.getColor(R.color.grey_500)) + } else { + longPressView.background.setTint(conversationFragment.resources.getColor(R.color.blue_900)) + } + mCurrentLongItem = RecyclerViewContextMenuInfo(convViewHolder.bindingAdapterPosition, v.id.toLong()) + false + } + val isTimeShown = hasPermanentTimeString(textMessage, position) + val msgSequenceType = getMsgSequencing(position, isTimeShown) + if (StringUtils.isOnlyEmoji(message)) { + convViewHolder.mMsgTxt.background.alpha = 0 + convViewHolder.mMsgTxt.textSize = 32.0f + convViewHolder.mMsgTxt.setPadding(0, 0, 0, 0) + } else { + val resIndex = msgSequenceType.ordinal + (if (textMessage.isIncoming) 1 else 0) * 4 + convViewHolder.mMsgTxt.background = ContextCompat.getDrawable(context, msgBGLayouts[resIndex]) + if (convColor != 0 && !textMessage.isIncoming) { + convViewHolder.mMsgTxt.background.setTint(convColor) + } + convViewHolder.mMsgTxt.background.alpha = 255 + convViewHolder.mMsgTxt.textSize = 16f + convViewHolder.mMsgTxt.setPadding(hPadding, vPadding, hPadding, vPadding) + } + convViewHolder.mMsgTxt.text = message + val endOfSeq = + msgSequenceType == SequenceType.LAST || msgSequenceType == SequenceType.SINGLE + if (textMessage.isIncoming) { + if (endOfSeq) { + convViewHolder.mAvatar.setImageDrawable(conversationFragment.getConversationAvatar(contact.primaryNumber)) + convViewHolder.mAvatar.visibility = View.VISIBLE + } else { + if (position == lastMsgPos - 1 && convViewHolder.mAvatar != null) { + val animation = AnimationUtils.loadAnimation(convViewHolder.mAvatar.context, R.anim.fade_out) + animation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(arg0: Animation) {} + override fun onAnimationRepeat(arg0: Animation) {} + override fun onAnimationEnd(arg0: Animation) { + convViewHolder.mAvatar.setImageBitmap(null) + convViewHolder.mAvatar.visibility = View.INVISIBLE + } + }) + convViewHolder.mAvatar.startAnimation(animation) + } else { + if (convViewHolder.mAvatar != null) { + convViewHolder.mAvatar.setImageBitmap(null) + convViewHolder.mAvatar.visibility = View.INVISIBLE + } + } + } + } else { + when (textMessage.status) { + InteractionStatus.SENDING -> { + convViewHolder.mStatusIcon.visibility = View.VISIBLE + convViewHolder.mStatusIcon.setImageResource(R.drawable.baseline_circle_24) + } + InteractionStatus.FAILURE -> { + convViewHolder.mStatusIcon.visibility = View.VISIBLE + convViewHolder.mStatusIcon.setImageResource(R.drawable.round_highlight_off_24) + } + InteractionStatus.DISPLAYED -> if (lastDisplayedPosition == position) { + convViewHolder.mStatusIcon.visibility = if (mShowReadIndicator) View.VISIBLE else View.GONE + convViewHolder.mStatusIcon.setImageDrawable(conversationFragment.getSmallConversationAvatar(contact.primaryNumber)) + } else { + convViewHolder.mStatusIcon.visibility = View.GONE + convViewHolder.mStatusIcon.setImageDrawable(null) + } + else -> if (position == lastOutgoingIndex()) { + convViewHolder.mStatusIcon.visibility = View.VISIBLE + convViewHolder.mStatusIcon.setImageResource(R.drawable.baseline_check_circle_24) + lastDeliveredPosition = position + } else { + convViewHolder.mStatusIcon.visibility = View.GONE + convViewHolder.mStatusIcon.setImageDrawable(null) + } + } + } + setBottomMargin(convViewHolder.mMsgTxt, if (endOfSeq) 8 else 0) + if (isTimeShown) { + convViewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe { + val timeSeparationString = timestampToDetailString(context, textMessage.timestamp) + convViewHolder.mMsgDetailTxtPerm.text = timeSeparationString + }) + convViewHolder.mMsgDetailTxtPerm.visibility = View.VISIBLE + } else { + convViewHolder.mMsgDetailTxtPerm.visibility = View.GONE + val isExpanded = position == expandedItemPosition + if (isExpanded) { + convViewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe { + val timeSeparationString = + timestampToDetailString(context, textMessage.timestamp) + convViewHolder.mMsgDetailTxt.text = timeSeparationString + }) + } + setItemViewExpansionState(convViewHolder, isExpanded) + convViewHolder.mItem.setOnClickListener { + if (convViewHolder.animator != null && convViewHolder.animator.isRunning) { + return@setOnClickListener + } + if (expandedItemPosition >= 0) { + val prev = expandedItemPosition + notifyItemChanged(prev) + } + expandedItemPosition = if (isExpanded) -1 else position + notifyItemChanged(expandedItemPosition) + } + } + } + + private fun configureForContactEvent(viewHolder: ConversationViewHolder, interaction: Interaction) { + val event = interaction as ContactEvent + viewHolder.mMsgTxt.setText(when (event.event) { + ContactEvent.Event.ADDED -> R.string.hist_contact_added + ContactEvent.Event.INVITED -> R.string.hist_contact_invited + ContactEvent.Event.REMOVED -> R.string.hist_contact_left + ContactEvent.Event.BANNED -> R.string.hist_contact_banned + ContactEvent.Event.INCOMING_REQUEST -> R.string.hist_invitation_received + else -> R.string.hist_contact_added + }) + viewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe { + val timeSeparationString = timestampToDetailString(viewHolder.itemView.context, event.timestamp) + viewHolder.mMsgDetailTxt.text = timeSeparationString + }) + } + + /** + * Configures the viewholder to display a call info text message, ie. not a classic text message + * + * @param convViewHolder The conversation viewHolder + * @param interaction The conversation element to display + */ + private fun configureForCallInfo(convViewHolder: ConversationViewHolder, interaction: Interaction) { + convViewHolder.mIcon.scaleY = 1f + val context = convViewHolder.itemView.context + val longPressView: View = convViewHolder.mCallInfoLayout + longPressView.background.setTintList(null) + longPressView.setOnCreateContextMenuListener { menu: ContextMenu, v: View, menuInfo: ContextMenuInfo? -> + conversationFragment.onCreateContextMenu(menu, v, menuInfo) + val inflater = conversationFragment.requireActivity().menuInflater + inflater.inflate(R.menu.conversation_item_actions_messages, menu) + menu.findItem(R.id.conv_action_delete).setTitle(R.string.menu_delete) + menu.removeItem(R.id.conv_action_cancel_message) + menu.removeItem(R.id.conv_action_copy_text) + } + longPressView.setOnLongClickListener { v: View -> + longPressView.background.setTint(conversationFragment.resources.getColor(R.color.grey_500)) + conversationFragment.updatePosition(convViewHolder.adapterPosition) + mCurrentLongItem = RecyclerViewContextMenuInfo( + convViewHolder.adapterPosition, v.id + .toLong() + ) + false + } + val pictureResID: Int + val historyTxt: String + val call = interaction as Call + if (call.isMissed) { + if (call.isIncoming) { + pictureResID = R.drawable.baseline_call_missed_24 + } else { + pictureResID = R.drawable.baseline_call_missed_outgoing_24 + // Flip the photo upside down to show a "missed outgoing call" + convViewHolder.mIcon.scaleY = -1f + } + historyTxt = + if (call.isIncoming) context.getString(R.string.notif_missed_incoming_call) else context.getString( + R.string.notif_missed_outgoing_call + ) + } else { + pictureResID = + if (call.isIncoming) R.drawable.baseline_call_received_24 else R.drawable.baseline_call_made_24 + historyTxt = + if (call.isIncoming) context.getString(R.string.notif_incoming_call) else context.getString( + R.string.notif_outgoing_call + ) + } + convViewHolder.mIcon.setImageResource(pictureResID) + convViewHolder.mHistTxt.text = historyTxt + convViewHolder.mHistDetailTxt.text = DateFormat.getDateTimeInstance() + .format(call.timestamp) // start date + } + + /** + * Computes the string to set in text details between messages, indicating time separation. + * + * @param timestamp The timestamp used to launch the computation with Date().getTime(). + * Can be the last received message timestamp for example. + * @return The string to display in the text details between messages. + */ + private fun timestampToDetailString(context: Context, timestamp: Long): String { + val diff = Date().time - timestamp + val timeStr: String = if (diff < DateUtils.WEEK_IN_MILLIS) { + if (diff < DateUtils.DAY_IN_MILLIS && DateUtils.isToday(timestamp)) { // 11:32 A.M. + DateUtils.formatDateTime(context, timestamp, DateUtils.FORMAT_SHOW_TIME) + } else { + DateUtils.formatDateTime( + context, timestamp, + DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_NO_YEAR or + DateUtils.FORMAT_ABBREV_ALL or DateUtils.FORMAT_SHOW_TIME + ) + } + } else if (diff < DateUtils.YEAR_IN_MILLIS) { // JAN. 7, 11:02 A.M. + DateUtils.formatDateTime( + context, timestamp, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR or + DateUtils.FORMAT_ABBREV_ALL or DateUtils.FORMAT_SHOW_TIME + ) + } else { + DateUtils.formatDateTime( + context, timestamp, + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_SHOW_YEAR or DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_ABBREV_ALL + ) + } + return timeStr.uppercase(Locale.getDefault()) + } + + /** + * Helper method to return the previous TextMessage relative to an initial position. + * + * @param position The initial position + * @return the previous TextMessage if any, null otherwise + */ + private fun getPreviousMessageFromPosition(position: Int): Interaction? { + return if (mInteractions.isNotEmpty() && position > 0) { + mInteractions[position - 1] + } else null + } + + /** + * Helper method to return the next TextMessage relative to an initial position. + * + * @param position The initial position + * @return the next TextMessage if any, null otherwise + */ + private fun getNextMessageFromPosition(position: Int): Interaction? { + return if (mInteractions.isNotEmpty() && position < mInteractions.size - 1) { + mInteractions[position + 1] + } else null + } + + private fun isSeqBreak(first: Interaction, second: Interaction): Boolean { + return StringUtils.isOnlyEmoji(first.body) != StringUtils.isOnlyEmoji(second.body) || first.isIncoming != second.isIncoming || first.type != Interaction.InteractionType.TEXT || second.type != Interaction.InteractionType.TEXT + } + + private fun isAlwaysSingleMsg(msg: Interaction): Boolean { + return (msg.type != Interaction.InteractionType.TEXT + || StringUtils.isOnlyEmoji(msg.body)) + } + + private fun getMsgSequencing(i: Int, isTimeShown: Boolean): SequenceType { + val msg = mInteractions[i] + if (isAlwaysSingleMsg(msg)) { + return SequenceType.SINGLE + } + if (mInteractions.size == 1 || i == 0) { + if (mInteractions.size == i + 1) { + return SequenceType.SINGLE + } + val nextMsg = getNextMessageFromPosition(i) + if (nextMsg != null) { + return if (isSeqBreak(msg, nextMsg) || hasPermanentTimeString(nextMsg, i + 1)) { + SequenceType.SINGLE + } else { + SequenceType.FIRST + } + } + } else if (mInteractions.size == i + 1) { + val prevMsg = getPreviousMessageFromPosition(i) + if (prevMsg != null) { + return if (isSeqBreak(msg, prevMsg) || isTimeShown) { + SequenceType.SINGLE + } else { + SequenceType.LAST + } + } + } + val prevMsg = getPreviousMessageFromPosition(i) + val nextMsg = getNextMessageFromPosition(i) + if (prevMsg != null && nextMsg != null) { + val nextMsgHasTime = hasPermanentTimeString(nextMsg, i + 1) + if ((isSeqBreak(msg, prevMsg) || isTimeShown) && !(isSeqBreak( + msg, + nextMsg + ) || nextMsgHasTime) + ) { + return SequenceType.FIRST + } else if (!isSeqBreak(msg, prevMsg) && !isTimeShown && isSeqBreak(msg, nextMsg)) { + return SequenceType.LAST + } else if (!isSeqBreak(msg, prevMsg) && !isTimeShown && !isSeqBreak(msg, nextMsg)) { + return if (nextMsgHasTime) SequenceType.LAST else SequenceType.MIDDLE + } + } + return SequenceType.SINGLE + } + + private fun setItemViewExpansionState(viewHolder: ConversationViewHolder, expanded: Boolean) { + val view: View = viewHolder.mMsgDetailTxt + if (viewHolder.animator == null) { + if (view.height == 0 && !expanded) { + return + } + viewHolder.animator = ValueAnimator() + } + if (viewHolder.animator.isRunning) { + viewHolder.animator.reverse() + return + } + view.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + viewHolder.animator.setIntValues(0, view.measuredHeight) + if (expanded) { + view.visibility = View.VISIBLE + } + viewHolder.animator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + val va = animation as ValueAnimator + if (va.animatedValue as Int == 0) { + view.visibility = View.GONE + } + viewHolder.animator = null + } + }) + viewHolder.animator.duration = 200 + viewHolder.animator.addUpdateListener { animation: ValueAnimator -> + view.layoutParams.height = (animation.animatedValue as Int) + view.requestLayout() + } + if (!expanded) { + viewHolder.animator.reverse() + } else { + viewHolder.animator.start() + } + } + + private fun hasPermanentTimeString(msg: Interaction?, position: Int): Boolean { + if (msg == null) { + return false + } + val prevMsg = getPreviousMessageFromPosition(position) + return prevMsg != null && + msg.timestamp - prevMsg.timestamp > 10 * DateUtils.MINUTE_IN_MILLIS + } + + private fun lastOutgoingIndex(): Int { + var i: Int = mInteractions.size - 1 + while (i >= 0) { + if (!mInteractions[i].isIncoming) { + break + } + i-- + } + return i + } + + private enum class SequenceType { + FIRST, MIDDLE, LAST, SINGLE + } + + enum class TransferMsgType { + FILE, IMAGE, AUDIO, VIDEO + } + + enum class MessageType(@LayoutRes val layout: Int) { + INCOMING_FILE(R.layout.item_conv_file_peer), + INCOMING_IMAGE(R.layout.item_conv_image_peer), + INCOMING_AUDIO(R.layout.item_conv_audio_peer), + INCOMING_VIDEO(R.layout.item_conv_video_peer), + OUTGOING_FILE(R.layout.item_conv_file_me), + OUTGOING_IMAGE(R.layout.item_conv_image_me), + OUTGOING_AUDIO(R.layout.item_conv_audio_me), + OUTGOING_VIDEO(R.layout.item_conv_video_me), + CONTACT_EVENT(R.layout.item_conv_contact), + CALL_INFORMATION(R.layout.item_conv_call), + INCOMING_TEXT_MESSAGE(R.layout.item_conv_msg_peer), + OUTGOING_TEXT_MESSAGE(R.layout.item_conv_msg_me), + COMPOSING_INDICATION(R.layout.item_conv_composing), + INVALID(-1); + + val isFile: Boolean + get() = this == INCOMING_FILE || this == OUTGOING_FILE + val isAudio: Boolean + get() = this == INCOMING_AUDIO || this == OUTGOING_AUDIO + val isVideo: Boolean + get() = this == INCOMING_VIDEO || this == OUTGOING_VIDEO + val isImage: Boolean + get() = this == INCOMING_IMAGE || this == OUTGOING_IMAGE + val transferType: TransferMsgType + get() = if (isFile) TransferMsgType.FILE else if (isImage) TransferMsgType.IMAGE else if (isAudio) TransferMsgType.AUDIO else if (isVideo) TransferMsgType.VIDEO else TransferMsgType.FILE + } + + companion object { + private val TAG = ConversationAdapter::class.java.simpleName + private val msgBGLayouts = intArrayOf( + R.drawable.textmsg_bg_out_first, + R.drawable.textmsg_bg_out_middle, + R.drawable.textmsg_bg_out_last, + R.drawable.textmsg_bg_out, + R.drawable.textmsg_bg_in_first, + R.drawable.textmsg_bg_in_middle, + R.drawable.textmsg_bg_in_last, + R.drawable.textmsg_bg_in + ) + + private fun setBottomMargin(view: View, value: Int) { + val targetSize = (value * view.context.resources.displayMetrics.density).toInt() + val params = view.layoutParams as MarginLayoutParams + params.bottomMargin = targetSize + } + } + + init { + val res = conversationFragment.resources + hPadding = res.getDimensionPixelSize(R.dimen.padding_medium) + vPadding = res.getDimensionPixelSize(R.dimen.padding_small) + mPictureMaxSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 200f, + res.displayMetrics + ).toInt() + val corner = res.getDimension(R.dimen.conversation_message_radius).toInt() + PICTURE_OPTIONS = GlideOptions() + .transform(CenterInside()) + .fitCenter() + .override(mPictureMaxSize) + .transform(RoundedCorners(corner)) + timestampUpdateTimer = + Observable.interval(10, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) + .startWithItem(0L) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/adapters/SmartListAdapter.java b/ring-android/app/src/main/java/cx/ring/adapters/SmartListAdapter.java deleted file mode 100644 index c83d8a9fe..000000000 --- a/ring-android/app/src/main/java/cx/ring/adapters/SmartListAdapter.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.adapters; - -import cx.ring.databinding.ItemSmartlistBinding; -import cx.ring.databinding.ItemSmartlistHeaderBinding; -import net.jami.smartlist.SmartListViewModel; -import cx.ring.viewholders.SmartListViewHolder; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -import android.os.Parcelable; -import android.view.LayoutInflater; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.List; - -public class SmartListAdapter extends RecyclerView.Adapter<SmartListViewHolder> { - - private List<SmartListViewModel> mSmartListViewModels = new ArrayList<>(); - private final SmartListViewHolder.SmartListListeners listener; - private final CompositeDisposable mDisposable; - private RecyclerView recyclerView; - - public SmartListAdapter(List<SmartListViewModel> smartListViewModels, SmartListViewHolder.SmartListListeners listener, CompositeDisposable disposable) { - this.listener = listener; - mDisposable = disposable; - if (smartListViewModels != null) - mSmartListViewModels.addAll(smartListViewModels); - } - - @NonNull - @Override - public SmartListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); - if (viewType == 0) { - ItemSmartlistBinding itemBinding = ItemSmartlistBinding.inflate(layoutInflater, parent, false); - return new SmartListViewHolder(itemBinding, mDisposable); - } else { - ItemSmartlistHeaderBinding itemBinding = ItemSmartlistHeaderBinding.inflate(layoutInflater, parent, false); - return new SmartListViewHolder(itemBinding, mDisposable); - } - } - - @Override - public int getItemViewType(int position) { - final SmartListViewModel smartListViewModel = mSmartListViewModels.get(position); - return smartListViewModel.getHeaderTitle() == SmartListViewModel.Title.None ? 0 : 1; - } - - @Override - public void onViewRecycled(@NonNull SmartListViewHolder holder) { - super.onViewRecycled(holder); - holder.unbind(); - } - - @Override - public void onBindViewHolder(@NonNull SmartListViewHolder holder, int position) { - holder.bind(listener, mSmartListViewModels.get(position)); - } - - @Override - public int getItemCount() { - return mSmartListViewModels.size(); - } - - @Override - public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { - super.onAttachedToRecyclerView(recyclerView); - this.recyclerView = recyclerView; - } - - public void update(List<SmartListViewModel> viewModels) { - //Log.w("SmartListAdapter", "update " + (viewModels == null ? null : viewModels.size())); - final List<SmartListViewModel> old = mSmartListViewModels; - mSmartListViewModels = viewModels == null ? new ArrayList<>() : viewModels; - if (old != null && viewModels != null) { - Parcelable recyclerViewState = recyclerView.getLayoutManager().onSaveInstanceState(); - DiffUtil.calculateDiff(new SmartListDiffUtil(old, viewModels)) - .dispatchUpdatesTo(this); - recyclerView.getLayoutManager().onRestoreInstanceState(recyclerViewState); - } else { - notifyDataSetChanged(); - } - } - - public void update(SmartListViewModel smartListViewModel) { - for (int i = 0; i < mSmartListViewModels.size(); i++) { - SmartListViewModel old = mSmartListViewModels.get(i); - if (old.getContacts() == smartListViewModel.getContacts()) { - mSmartListViewModels.set(i, smartListViewModel); - notifyItemChanged(i); - return; - } - } - } -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/adapters/SmartListAdapter.kt b/ring-android/app/src/main/java/cx/ring/adapters/SmartListAdapter.kt new file mode 100644 index 000000000..3541baa8c --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/adapters/SmartListAdapter.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import cx.ring.databinding.ItemSmartlistBinding +import cx.ring.databinding.ItemSmartlistHeaderBinding +import cx.ring.viewholders.SmartListViewHolder +import cx.ring.viewholders.SmartListViewHolder.SmartListListeners +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.smartlist.SmartListViewModel + +class SmartListAdapter( + smartListViewModels: List<SmartListViewModel>?, + private val listener: SmartListListeners, + private val mDisposable: CompositeDisposable +) : RecyclerView.Adapter<SmartListViewHolder>() { + private var mSmartListViewModels: MutableList<SmartListViewModel> = if (smartListViewModels != null) ArrayList(smartListViewModels) else ArrayList() + private var recyclerView: RecyclerView? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SmartListViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return if (viewType == 0) { + val itemBinding = ItemSmartlistBinding.inflate(layoutInflater, parent, false) + SmartListViewHolder(itemBinding, mDisposable) + } else { + val itemBinding = ItemSmartlistHeaderBinding.inflate(layoutInflater, parent, false) + SmartListViewHolder(itemBinding, mDisposable) + } + } + + override fun getItemViewType(position: Int): Int { + val smartListViewModel = mSmartListViewModels[position] + return if (smartListViewModel.headerTitle == SmartListViewModel.Title.None) 0 else 1 + } + + override fun onViewRecycled(holder: SmartListViewHolder) { + super.onViewRecycled(holder) + holder.unbind() + } + + override fun onBindViewHolder(holder: SmartListViewHolder, position: Int) { + holder.bind(listener, mSmartListViewModels[position]) + } + + override fun getItemCount(): Int { + return mSmartListViewModels.size + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + this.recyclerView = recyclerView + } + + fun update(viewModels: MutableList<SmartListViewModel>?) { + val old: List<SmartListViewModel> = mSmartListViewModels + mSmartListViewModels = viewModels ?: ArrayList() + if (viewModels != null) { + val recyclerViewState = recyclerView?.layoutManager?.onSaveInstanceState() + DiffUtil.calculateDiff(SmartListDiffUtil(old, viewModels)) + .dispatchUpdatesTo(this) + recyclerView?.layoutManager?.onRestoreInstanceState(recyclerViewState) + } else { + notifyDataSetChanged() + } + } + + fun update(smartListViewModel: SmartListViewModel) { + for (i in mSmartListViewModels.indices) { + val old = mSmartListViewModels[i] + if (old.contacts === smartListViewModel.contacts) { + mSmartListViewModels[i] = smartListViewModel + notifyItemChanged(i) + return + } + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/adapters/SmartListDiffUtil.java b/ring-android/app/src/main/java/cx/ring/adapters/SmartListDiffUtil.java deleted file mode 100644 index f26aff1fb..000000000 --- a/ring-android/app/src/main/java/cx/ring/adapters/SmartListDiffUtil.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.adapters; - -import androidx.recyclerview.widget.DiffUtil; - -import java.util.List; - -import net.jami.smartlist.SmartListViewModel; - -public class SmartListDiffUtil extends DiffUtil.Callback { - - private final List<SmartListViewModel> mOldList; - private final List<SmartListViewModel> mNewList; - - public SmartListDiffUtil(List<SmartListViewModel> oldList, List<SmartListViewModel> newList) { - mOldList = oldList; - mNewList = newList; - } - - @Override - public int getOldListSize() { - return mOldList.size(); - } - - @Override - public int getNewListSize() { - return mNewList.size(); - } - - @Override - public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { - SmartListViewModel oldItem = mOldList.get(oldItemPosition); - SmartListViewModel newItem = mNewList.get(newItemPosition); - if (newItem.getHeaderTitle() != oldItem.getHeaderTitle()) - return false; - if (newItem.getContacts() != oldItem.getContacts()) { - if (newItem.getContacts().size() != oldItem.getContacts().size()) - return false; - for (int i = 0; i < newItem.getContacts().size(); i++) { - if (newItem.getContacts().get(i) != oldItem.getContacts().get(i)) - return false; - } - } - return true; - } - - @Override - public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { - return mNewList.get(newItemPosition).equals(mOldList.get(oldItemPosition)); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/adapters/SmartListDiffUtil.kt b/ring-android/app/src/main/java/cx/ring/adapters/SmartListDiffUtil.kt new file mode 100644 index 000000000..e90336ba1 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/adapters/SmartListDiffUtil.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.adapters + +import net.jami.smartlist.SmartListViewModel +import androidx.recyclerview.widget.DiffUtil + +class SmartListDiffUtil( + private val mOldList: List<SmartListViewModel>, + private val mNewList: List<SmartListViewModel> +) : DiffUtil.Callback() { + override fun getOldListSize(): Int { + return mOldList.size + } + + override fun getNewListSize(): Int { + return mNewList.size + } + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = mOldList[oldItemPosition] + val newItem = mNewList[newItemPosition] + if (newItem.headerTitle != oldItem.headerTitle) return false + if (newItem.contacts !== oldItem.contacts) { + if (newItem.contacts.size != oldItem.contacts.size) return false + for (i in newItem.contacts.indices) { + if (newItem.contacts[i] !== oldItem.contacts[i]) return false + } + } + return true + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return mNewList[newItemPosition] == mOldList[oldItemPosition] + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/application/JamiApplication.java b/ring-android/app/src/main/java/cx/ring/application/JamiApplication.java deleted file mode 100644 index 5161c3344..000000000 --- a/ring-android/app/src/main/java/cx/ring/application/JamiApplication.java +++ /dev/null @@ -1,374 +0,0 @@ -/* - * Copyright (C) 2016-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.application; - -import android.app.Activity; -import android.app.Application; -import android.app.job.JobInfo; -import android.app.job.JobScheduler; -import android.content.BroadcastReceiver; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.ServiceConnection; -import android.media.AudioManager; -import android.os.Build; -import android.os.Bundle; -import android.os.IBinder; -import android.system.Os; -import android.util.Log; -import android.view.WindowManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; - -import com.bumptech.glide.Glide; - -import net.jami.daemon.JamiService; -import net.jami.facades.ConversationFacade; -import net.jami.services.AccountService; -import net.jami.services.CallService; -import net.jami.services.ContactService; -import net.jami.services.DaemonService; -import net.jami.services.DeviceRuntimeService; -import net.jami.services.HardwareService; -import net.jami.services.PreferencesService; - -import java.io.File; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; - -import javax.inject.Inject; -import javax.inject.Named; - -import cx.ring.BuildConfig; -import cx.ring.R; -import cx.ring.views.AvatarFactory; -import cx.ring.dependencyinjection.DaggerJamiInjectionComponent; -import cx.ring.dependencyinjection.JamiInjectionComponent; -import cx.ring.dependencyinjection.JamiInjectionModule; -import cx.ring.dependencyinjection.ServiceInjectionModule; -import cx.ring.service.DRingService; -import cx.ring.service.JamiJobService; -import cx.ring.utils.AndroidFileUtils; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public abstract class JamiApplication extends Application { - private static final String TAG = JamiApplication.class.getSimpleName(); - public static final String DRING_CONNECTION_CHANGED = BuildConfig.APPLICATION_ID + ".event.DRING_CONNECTION_CHANGE"; - public static final int PERMISSIONS_REQUEST = 57; - private static final IntentFilter RINGER_FILTER = new IntentFilter(AudioManager.RINGER_MODE_CHANGED_ACTION); - private static JamiApplication sInstance = null; - - @Inject - @Named("DaemonExecutor") - ScheduledExecutorService mExecutor; - @Inject - DaemonService mDaemonService; - @Inject - AccountService mAccountService; - @Inject - CallService mCallService; - //@Inject - //ConferenceService mConferenceService; - @Inject - HardwareService mHardwareService; - @Inject - PreferencesService mPreferencesService; - @Inject - DeviceRuntimeService mDeviceRuntimeService; - @Inject - ContactService mContactService; - - private JamiInjectionComponent mJamiInjectionComponent; - private final Map<String, Boolean> mPermissionsBeingAsked = new HashMap<>();; - private final BroadcastReceiver ringerModeListener = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - ringerModeChanged(intent.getIntExtra(AudioManager.EXTRA_RINGER_MODE, AudioManager.RINGER_MODE_NORMAL)); - } - }; - - public abstract String getPushToken(); - - private boolean mBound = false; - private final ServiceConnection mConnection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName className, IBinder s) { - Log.d(TAG, "onServiceConnected: " + className.getClassName()); - mBound = true; - // bootstrap Daemon - //bootstrapDaemon(); - } - - @Override - public void onServiceDisconnected(ComponentName className) { - Log.d(TAG, "onServiceDisconnected: " + className.getClassName()); - mBound = false; - } - }; - - private void ringerModeChanged(int newMode) { - boolean mute = newMode == AudioManager.RINGER_MODE_VIBRATE || newMode == AudioManager.RINGER_MODE_SILENT; - mCallService.muteRingTone(mute); - } - - @Override - public void onLowMemory() { - super.onLowMemory(); - AvatarFactory.clearCache(); - Glide.get(this).clearMemory(); - } - - public void bootstrapDaemon() { - - if (mDaemonService.isStarted()) { - return; - } - - mExecutor.execute(() -> { - try { - Log.d(TAG, "bootstrapDaemon: START"); - if (mDaemonService.isStarted()) { - return; - } - mDaemonService.startDaemon(); - - // Check if the camera hardware feature is available. - if (mDeviceRuntimeService.hasVideoPermission()) { - //initVideo is called here to give time to the application to initialize hardware cameras - Log.d(TAG, "bootstrapDaemon: At least one camera available. Initializing video..."); - mHardwareService.initVideo() - .onErrorComplete() - .subscribe(); - } else { - Log.d(TAG, "bootstrapDaemon: No camera available"); - } - - ringerModeChanged(((AudioManager) getSystemService(Context.AUDIO_SERVICE)).getRingerMode()); - registerReceiver(ringerModeListener, RINGER_FILTER); - - // load accounts from Daemon - mAccountService.loadAccountsFromDaemon(mPreferencesService.hasNetworkConnected()); - - if (mPreferencesService.getSettings().isAllowPushNotifications()) { - String token = getPushToken(); - if (token != null) { - JamiService.setPushNotificationToken(token); - } - } else { - JamiService.setPushNotificationToken(""); - } - - Intent intent = new Intent(DRING_CONNECTION_CHANGED); - intent.putExtra("connected", mDaemonService.isStarted()); - sendBroadcast(intent); - - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { - scheduleRefreshJob(); - } - } catch (Exception e) { - Log.e(TAG, "DRingService start failed", e); - } - }); - } - - @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) - private void scheduleRefreshJob() { - JobScheduler scheduler = (JobScheduler) getSystemService(Context.JOB_SCHEDULER_SERVICE); - if (scheduler == null) { - Log.e(TAG, "JobScheduler: can't retrieve service"); - return; - } - JobInfo.Builder jobBuilder = new JobInfo.Builder(JamiJobService.JOB_ID, new ComponentName(this, JamiJobService.class)) - .setPersisted(true) - .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY); - if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - jobBuilder.setPeriodic(JamiJobService.JOB_INTERVAL, JamiJobService.JOB_FLEX); - else - jobBuilder.setPeriodic(JamiJobService.JOB_INTERVAL); - Log.w(TAG, "JobScheduler: scheduling job"); - scheduler.schedule(jobBuilder.build()); - } - - public void terminateDaemon() { - Future<Boolean> stopResult = mExecutor.submit(() -> { - unregisterReceiver(ringerModeListener); - mDaemonService.stopDaemon(); - Intent intent = new Intent(DRING_CONNECTION_CHANGED); - intent.putExtra("connected", mDaemonService.isStarted()); - sendBroadcast(intent); - - return true; - }); - - try { - stopResult.get(); - } catch (Exception e) { - Log.e(TAG, "DRingService stop failed", e); - } - } - - @Override - public void onCreate() { - super.onCreate(); - sInstance = this; - - //RxJavaPlugins.setErrorHandler(e -> Log.e(TAG, "Unhandled RxJava error", e)); - - // building injection dependency tree - mJamiInjectionComponent = DaggerJamiInjectionComponent.builder() - .jamiInjectionModule(new JamiInjectionModule(this)) - .serviceInjectionModule(new ServiceInjectionModule(this)) - .build(); - - // we can now inject in our self whatever modules define - mJamiInjectionComponent.inject(this); - - bootstrapDaemon(); - - mPreferencesService.loadDarkMode(); - - Completable.fromAction(() -> { - File path = AndroidFileUtils.ringtonesPath(this); - File defaultRingtone = new File(path, getString(R.string.ringtone_default_name)); - File defaultLink = new File(path, "default.opus"); - if (!defaultRingtone.exists()) { - AndroidFileUtils.copyAssetFolder(getAssets(), "ringtones", path); - } - if (!defaultLink.exists()) { - Os.symlink(defaultRingtone.getAbsolutePath(), defaultLink.getAbsolutePath()); - } - - String caRootFile = getString(R.string.ca_root_file); - File dest = new File(getFilesDir(), caRootFile); - AndroidFileUtils.copyAsset(getAssets(), caRootFile, dest); - Os.setenv("CA_ROOT_FILE", dest.getAbsolutePath(), true); - }) - .subscribeOn(Schedulers.io()) - .subscribe(); - - setupActivityListener(); - } - - public void startDaemon() { - if (!DRingService.isRunning) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - && mPreferencesService.getSettings().isAllowPersistentNotification()) { - startForegroundService(new Intent(this, DRingService.class)); - } else { - startService(new Intent(this, DRingService.class)); - } - } catch (Exception e) { - Log.w(TAG, "Error starting daemon service"); - } - } - bindDaemon(); - } - - public void bindDaemon() { - if (!mBound) { - try { - bindService(new Intent(this, DRingService.class), mConnection, BIND_AUTO_CREATE | BIND_IMPORTANT | BIND_ABOVE_CLIENT); - } catch (Exception e) { - Log.w(TAG, "Error binding daemon service"); - } - } - } - - public static JamiApplication getInstance() { - return sInstance; - } - - @Override - public void onTerminate() { - super.onTerminate(); - - // todo decide when to stop the daemon - terminateDaemon(); - sInstance = null; - } - - public JamiInjectionComponent getInjectionComponent() { - return mJamiInjectionComponent; - } - - public boolean canAskForPermission(String permission) { - - Boolean isBeingAsked = mPermissionsBeingAsked.get(permission); - - if (isBeingAsked != null && isBeingAsked) { - return false; - } - - mPermissionsBeingAsked.put(permission, true); - - return true; - } - - public void permissionHasBeenAsked(String permission) { - mPermissionsBeingAsked.remove(permission); - } - - public DaemonService getDaemon() { - return mDaemonService; - } - - public HardwareService getHardwareService() { - return mHardwareService; - } - - private void setupActivityListener() { - registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() { - - @Override - public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle bundle) { - if (mPreferencesService.getSettings().isRecordingBlocked()) { - activity.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); - } - } - - @Override - public void onActivityStarted(@NonNull Activity activity) {} - - @Override - public void onActivityResumed(@NonNull Activity activity) {} - - @Override - public void onActivityPaused(@NonNull Activity activity) {} - - @Override - public void onActivityStopped(@NonNull Activity activity) {} - - @Override - public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle bundle) {} - - @Override - public void onActivityDestroyed(@NonNull Activity activity) {} - }); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/application/JamiApplication.kt b/ring-android/app/src/main/java/cx/ring/application/JamiApplication.kt new file mode 100644 index 000000000..d01769276 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/application/JamiApplication.kt @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2016-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.application + +import android.app.Activity +import android.app.Application +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.* +import android.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.system.Os +import android.util.Log +import android.view.WindowManager +import androidx.annotation.RequiresApi +import com.bumptech.glide.Glide +import cx.ring.BuildConfig +import cx.ring.R +import cx.ring.service.DRingService +import cx.ring.service.JamiJobService +import cx.ring.utils.AndroidFileUtils +import cx.ring.views.AvatarFactory +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.daemon.JamiService +import net.jami.services.* +import java.io.File +import java.util.concurrent.ScheduledExecutorService +import javax.inject.Inject +import javax.inject.Named + +abstract class JamiApplication : Application() { + companion object { + private val TAG = JamiApplication::class.java.simpleName + const val DRING_CONNECTION_CHANGED = BuildConfig.APPLICATION_ID + ".event.DRING_CONNECTION_CHANGE" + const val PERMISSIONS_REQUEST = 57 + private val RINGER_FILTER = IntentFilter(AudioManager.RINGER_MODE_CHANGED_ACTION) + @JvmStatic var instance: JamiApplication? = null + } + + @Inject + @Named("DaemonExecutor") lateinit + var mExecutor: ScheduledExecutorService + + @Inject lateinit + var daemon: DaemonService + + @Inject lateinit + var mAccountService: AccountService + + @Inject lateinit + var mCallService: CallService + + //@Inject + //ConferenceService mConferenceService; + @Inject lateinit + var hardwareService: HardwareService + + @Inject lateinit + var mPreferencesService: PreferencesService + + @Inject lateinit + var mDeviceRuntimeService: DeviceRuntimeService + + @Inject lateinit + var mContactService: ContactService + + private val ringerModeListener: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + ringerModeChanged(intent.getIntExtra(AudioManager.EXTRA_RINGER_MODE, AudioManager.RINGER_MODE_NORMAL)) + } + } + abstract val pushToken: String? + + private var mBound = false + private val mConnection: ServiceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, s: IBinder) { + Log.d(TAG, "onServiceConnected: " + className.className) + mBound = true + // bootstrap Daemon + //bootstrapDaemon(); + } + + override fun onServiceDisconnected(className: ComponentName) { + Log.d(TAG, "onServiceDisconnected: " + className.className) + mBound = false + } + } + + private fun ringerModeChanged(newMode: Int) { + val mute = newMode == AudioManager.RINGER_MODE_VIBRATE || newMode == AudioManager.RINGER_MODE_SILENT + mCallService.muteRingTone(mute) + } + + override fun onLowMemory() { + super.onLowMemory() + AvatarFactory.clearCache() + Glide.get(this).clearMemory() + } + + fun bootstrapDaemon() { + if (daemon.isStarted) { + return + } + Log.d(TAG, "bootstrapDaemon") + mExecutor.execute { + try { + Log.d(TAG, "bootstrapDaemon: START") + if (daemon.isStarted) { + return@execute + } + daemon.startDaemon() + + // Check if the camera hardware feature is available. + if (mDeviceRuntimeService.hasVideoPermission()) { + //initVideo is called here to give time to the application to initialize hardware cameras + Log.d(TAG, "bootstrapDaemon: At least one camera available. Initializing video...") + hardwareService.initVideo() + .onErrorComplete() + .subscribe() + } else { + Log.d(TAG, "bootstrapDaemon: No camera available") + } + ringerModeChanged((getSystemService(AUDIO_SERVICE) as AudioManager).ringerMode) + registerReceiver(ringerModeListener, RINGER_FILTER) + + // load accounts from Daemon + mAccountService.loadAccountsFromDaemon(mPreferencesService.hasNetworkConnected()) + if (mPreferencesService.settings.isAllowPushNotifications) { + pushToken?.let { token -> JamiService.setPushNotificationToken(token) } + } else { + JamiService.setPushNotificationToken("") + } + val intent = Intent(DRING_CONNECTION_CHANGED) + intent.putExtra("connected", daemon.isStarted) + sendBroadcast(intent) + scheduleRefreshJob() + } catch (e: Exception) { + Log.e(TAG, "DRingService start failed", e) + } + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private fun scheduleRefreshJob() { + val scheduler = getSystemService(JOB_SCHEDULER_SERVICE) as JobScheduler + val jobBuilder = JobInfo.Builder(JamiJobService.JOB_ID, ComponentName(this, JamiJobService::class.java)) + .setPersisted(true) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) jobBuilder.setPeriodic(JamiJobService.JOB_INTERVAL, JamiJobService.JOB_FLEX) else jobBuilder.setPeriodic(JamiJobService.JOB_INTERVAL) + Log.w(TAG, "JobScheduler: scheduling job") + scheduler.schedule(jobBuilder.build()) + } + + private fun terminateDaemon() { + val stopResult = mExecutor.submit<Boolean> { + unregisterReceiver(ringerModeListener) + daemon.stopDaemon() + val intent = Intent(DRING_CONNECTION_CHANGED) + intent.putExtra("connected", daemon.isStarted) + sendBroadcast(intent) + true + } + try { + stopResult.get() + } catch (e: Exception) { + Log.e(TAG, "DRingService stop failed", e) + } + } + + override fun onCreate() { + super.onCreate() + instance = this + + //RxJavaPlugins.setErrorHandler(e -> Log.e(TAG, "Unhandled RxJava error", e)); + + // building injection dependency tree + /*injectionComponent = DaggerJamiInjectionComponent.builder() + .jamiInjectionModule(JamiInjectionModule(this)) + .serviceInjectionModule(ServiceInjectionModule(this)) + .build() + + // we can now inject in our self whatever modules define + injectionComponent!!.inject(this)*/ + bootstrapDaemon() + mPreferencesService.loadDarkMode() + Completable.fromAction { + val path = AndroidFileUtils.ringtonesPath(this) + val defaultRingtone = File(path, getString(R.string.ringtone_default_name)) + val defaultLink = File(path, "default.opus") + if (!defaultRingtone.exists()) { + AndroidFileUtils.copyAssetFolder(assets, "ringtones", path) + } + if (!defaultLink.exists()) { + Os.symlink(defaultRingtone.absolutePath, defaultLink.absolutePath) + } + val caRootFile = getString(R.string.ca_root_file) + val dest = File(filesDir, caRootFile) + AndroidFileUtils.copyAsset(assets, caRootFile, dest) + Os.setenv("CA_ROOT_FILE", dest.absolutePath, true) + } + .subscribeOn(Schedulers.io()) + .subscribe() + setupActivityListener() + } + + fun startDaemon() { + if (!DRingService.isRunning) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + && mPreferencesService.settings.isAllowPersistentNotification) { + startForegroundService(Intent(this, DRingService::class.java)) + } else { + startService(Intent(this, DRingService::class.java)) + } + } catch (e: Exception) { + Log.w(TAG, "Error starting daemon service") + } + } + bindDaemon() + } + + fun bindDaemon() { + if (!mBound) { + try { + bindService(Intent(this, DRingService::class.java), mConnection, BIND_AUTO_CREATE or BIND_IMPORTANT or BIND_ABOVE_CLIENT) + } catch (e: Exception) { + Log.w(TAG, "Error binding daemon service") + } + } + } + + override fun onTerminate() { + super.onTerminate() + + // todo decide when to stop the daemon + terminateDaemon() + instance = null + } + + private fun setupActivityListener() { + registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, bundle: Bundle?) { + if (mPreferencesService.settings.isRecordingBlocked) { + activity.window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + } + } + + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + }) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/AccountSpinnerAdapter.java b/ring-android/app/src/main/java/cx/ring/client/AccountSpinnerAdapter.java deleted file mode 100644 index 99f8ff951..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/AccountSpinnerAdapter.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.client; - -import android.content.Context; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.RelativeLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import net.jami.model.Account; - -import java.util.List; - -import cx.ring.R; -import cx.ring.databinding.ItemToolbarSelectedBinding; -import cx.ring.databinding.ItemToolbarSpinnerBinding; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class AccountSpinnerAdapter extends ArrayAdapter<Account> { - private static final String TAG = AccountSpinnerAdapter.class.getSimpleName(); - public static final int TYPE_ACCOUNT = 0; - public static final int TYPE_CREATE_JAMI = 1; - public static final int TYPE_CREATE_SIP = 2; - private final LayoutInflater mInflater; - private final int logoSize; - - public AccountSpinnerAdapter(@NonNull Context context, List<Account> accounts){ - super(context, R.layout.item_toolbar_spinner, accounts); - mInflater = LayoutInflater.from(context); - logoSize = context.getResources().getDimensionPixelSize(R.dimen.list_medium_icon_size); - } - - @NonNull - @Override - public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { - int type = getItemViewType(position); - ViewHolderHeader holder; - if (convertView == null) { - holder = new ViewHolderHeader(); - holder.binding = ItemToolbarSelectedBinding.inflate(mInflater, parent, false); - convertView = holder.binding.getRoot(); - convertView.setTag(holder); - } else { - holder = (ViewHolderHeader) convertView.getTag(); - holder.loader.clear(); - } - - if (type == TYPE_ACCOUNT) { - Account account = getItem(position); - holder.loader.add(AvatarDrawable.load(getContext(), account) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(avatar -> holder.binding.logo.setImageDrawable(avatar), e -> Log.e(TAG, "Error loading avatar", e))); - holder.loader.add(account.getAccountAlias() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(alias -> holder.binding.title.setText(alias), e -> Log.e(TAG, "Error loading title", e))); - } - return convertView; - } - - @Override - public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { - int type = getItemViewType(position); - ViewHolder holder; - View rowView = convertView; - if (rowView == null) { - holder = new ViewHolder(); - holder.binding = ItemToolbarSpinnerBinding.inflate(mInflater, parent, false); - rowView = holder.binding.getRoot(); - rowView.setTag(holder); - } else { - holder = (ViewHolder) rowView.getTag(); - holder.loader.clear(); - } - - holder.binding.logo.setVisibility(View.VISIBLE); - ViewGroup.LayoutParams logoParam = holder.binding.logo.getLayoutParams(); - if (type == TYPE_ACCOUNT) { - Account account = getItem(position); - CharSequence ip2ipString = rowView.getContext().getString(R.string.account_type_ip2ip); - holder.loader.add(account.getAccountAlias() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(alias -> { - String subtitle = getUri(account, ip2ipString); - holder.binding.title.setText(alias); - if (alias.equals(subtitle)) { - holder.binding.subtitle.setVisibility(View.GONE); - } else { - holder.binding.subtitle.setVisibility(View.VISIBLE); - holder.binding.subtitle.setText(subtitle); - } - })); - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) holder.binding.title.getLayoutParams(); - params.removeRule(RelativeLayout.CENTER_VERTICAL); - holder.binding.title.setLayoutParams(params); - logoParam.width = logoSize; - logoParam.height = logoSize; - holder.binding.logo.setLayoutParams(logoParam); - holder.loader.add(AvatarDrawable.load(getContext(), account) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(avatar -> holder.binding.logo.setImageDrawable(avatar), e -> Log.e(TAG, "Error loading avatar", e))); - } else { - if (type == TYPE_CREATE_JAMI) - holder.binding.title.setText(R.string.add_ring_account_title); - else - holder.binding.title.setText(R.string.add_sip_account_title); - holder.binding.subtitle.setVisibility(View.GONE); - holder.binding.logo.setImageResource(R.drawable.baseline_add_24); - logoParam.width = ViewGroup.LayoutParams.WRAP_CONTENT; - logoParam.height = ViewGroup.LayoutParams.WRAP_CONTENT; - holder.binding.logo.setLayoutParams(logoParam); - - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) holder.binding.title.getLayoutParams(); - params.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE); - holder.binding.title.setLayoutParams(params); - } - - return rowView; - } - - @Override - public int getItemViewType(int position) { - if (position == super.getCount()) { - return TYPE_CREATE_JAMI; - } - if (position == super.getCount() + 1) { - return TYPE_CREATE_SIP; - } - return TYPE_ACCOUNT; - } - - @Override - public int getCount() { - return super.getCount() + 2; - } - - private static class ViewHolder { - ItemToolbarSpinnerBinding binding; - final CompositeDisposable loader = new CompositeDisposable(); - } - private static class ViewHolderHeader { - ItemToolbarSelectedBinding binding; - final CompositeDisposable loader = new CompositeDisposable(); - } - - private String getUri(Account account, CharSequence defaultNameSip) { - if (account.isIP2IP()) { - return defaultNameSip.toString(); - } - return account.getDisplayUri(); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/client/AccountSpinnerAdapter.kt b/ring-android/app/src/main/java/cx/ring/client/AccountSpinnerAdapter.kt new file mode 100644 index 000000000..4565d7ed3 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/AccountSpinnerAdapter.kt @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.client + +import android.content.Context +import android.util.Log +import android.widget.ArrayAdapter +import cx.ring.R +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import cx.ring.views.AvatarDrawable +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import android.widget.RelativeLayout +import cx.ring.databinding.ItemToolbarSelectedBinding +import cx.ring.databinding.ItemToolbarSpinnerBinding +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.model.Account + +class AccountSpinnerAdapter(context: Context, accounts: List<Account>) : + ArrayAdapter<Account>(context, R.layout.item_toolbar_spinner, accounts) { + private val mInflater: LayoutInflater = LayoutInflater.from(context) + private val logoSize: Int = context.resources.getDimensionPixelSize(R.dimen.list_medium_icon_size) + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var view = convertView + val type = getItemViewType(position) + val holder: ViewHolderHeader + if (view == null) { + holder = ViewHolderHeader(ItemToolbarSelectedBinding.inflate(mInflater, parent, false)) + view = holder.binding.root + view.setTag(holder) + } else { + holder = view.tag as ViewHolderHeader + holder.loader.clear() + } + if (type == TYPE_ACCOUNT) { + val account = getItem(position)!! + holder.loader.add(AvatarDrawable.load(context, account) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ avatar -> holder.binding.logo.setImageDrawable(avatar) + }) { e: Throwable -> Log.e(TAG, "Error loading avatar", e) }) + holder.loader.add(account.accountAlias + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ alias -> holder.binding.title.text = alias.ifEmpty { context.getString(R.string.ring_account) } + }) { e: Throwable -> Log.e(TAG, "Error loading title", e) }) + } + return view + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + val type = getItemViewType(position) + val holder: ViewHolder + var rowView = convertView + if (rowView == null) { + holder = ViewHolder(ItemToolbarSpinnerBinding.inflate(mInflater, parent, false)) + rowView = holder.binding.root + rowView.setTag(holder) + } else { + holder = rowView.tag as ViewHolder + holder.loader.clear() + } + holder.binding.logo.visibility = View.VISIBLE + val logoParam = holder.binding.logo.layoutParams + if (type == TYPE_ACCOUNT) { + val account = getItem(position)!! + val ip2ipString = rowView.context.getString(R.string.account_type_ip2ip) + holder.loader.add(account.accountAlias + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { alias -> + val subtitle = getUri(account, ip2ipString) + holder.binding.title.text = alias.ifEmpty { context.getString(R.string.ring_account) } + if (alias == subtitle) { + holder.binding.subtitle.visibility = View.GONE + } else { + holder.binding.subtitle.visibility = View.VISIBLE + holder.binding.subtitle.text = subtitle + } + }) + val params = holder.binding.title.layoutParams as RelativeLayout.LayoutParams + params.removeRule(RelativeLayout.CENTER_VERTICAL) + holder.binding.title.layoutParams = params + logoParam.width = logoSize + logoParam.height = logoSize + holder.binding.logo.layoutParams = logoParam + holder.loader.add(AvatarDrawable.load(context, account) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ avatar -> holder.binding.logo.setImageDrawable(avatar) }) + { e -> Log.e(TAG, "Error loading avatar", e) }) + } else { + holder.binding.title.setText( + if (type == TYPE_CREATE_JAMI) R.string.add_ring_account_title else R.string.add_sip_account_title) + + holder.binding.subtitle.visibility = View.GONE + holder.binding.logo.setImageResource(R.drawable.baseline_add_24) + logoParam.width = ViewGroup.LayoutParams.WRAP_CONTENT + logoParam.height = ViewGroup.LayoutParams.WRAP_CONTENT + holder.binding.logo.layoutParams = logoParam + val params = holder.binding.title.layoutParams as RelativeLayout.LayoutParams + params.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE) + holder.binding.title.layoutParams = params + } + return rowView + } + + override fun getItemViewType(position: Int): Int { + if (position == super.getCount()) { + return TYPE_CREATE_JAMI + } + return if (position == super.getCount() + 1) { + TYPE_CREATE_SIP + } else TYPE_ACCOUNT + } + + override fun getCount(): Int { + return super.getCount() + 2 + } + + private class ViewHolder(val binding: ItemToolbarSpinnerBinding) { + val loader = CompositeDisposable() + } + + private class ViewHolderHeader(val binding: ItemToolbarSelectedBinding) { + val loader = CompositeDisposable() + } + + private fun getUri(account: Account, defaultNameSip: CharSequence): String { + return if (account.isIP2IP) defaultNameSip.toString() else account.displayUri!! + } + + companion object { + private val TAG = AccountSpinnerAdapter::class.simpleName!! + const val TYPE_ACCOUNT = 0 + const val TYPE_CREATE_JAMI = 1 + const val TYPE_CREATE_SIP = 2 + } + +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/CallActivity.java b/ring-android/app/src/main/java/cx/ring/client/CallActivity.java deleted file mode 100644 index 7f6e7af2a..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/CallActivity.java +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Savard <alexandre.savard@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.client; - -import android.content.Intent; -import android.content.res.Configuration; -import android.media.AudioManager; -import android.os.Build; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; - -import android.os.Handler; -import android.os.Looper; -import android.view.KeyEvent; -import android.view.View; -import android.view.WindowManager; - -import androidx.fragment.app.Fragment; - -import cx.ring.BuildConfig; -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.fragments.CallFragment; -import net.jami.services.NotificationService; -import cx.ring.utils.ConversationPath; -import cx.ring.utils.KeyboardVisibilityManager; -import cx.ring.utils.MediaButtonsHelper; - -public class CallActivity extends AppCompatActivity { - public static final String ACTION_CALL = BuildConfig.APPLICATION_ID + ".action.call"; - public static final String ACTION_CALL_ACCEPT = BuildConfig.APPLICATION_ID + ".action.CALL_ACCEPT"; - - private static final String CALL_FRAGMENT_TAG = "CALL_FRAGMENT_TAG"; - - /* result code sent in case of call failure */ - public static int RESULT_FAILURE = -10; - private View mMainView; - private Handler handler; - private int currentOrientation = Configuration.ORIENTATION_PORTRAIT; - private boolean dimmed = false; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - JamiApplication.getInstance().startDaemon(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - setTurnScreenOn(true); - setShowWhenLocked(true); - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } else { - getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED| - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON| - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } - - setContentView(R.layout.activity_call_layout); - setVolumeControlStream(AudioManager.STREAM_VOICE_CALL); - - handler = new Handler(Looper.getMainLooper()); - - mMainView = findViewById(R.id.main_call_layout); - mMainView.setOnClickListener(v -> { - dimmed = !dimmed; - if (dimmed) { - hideSystemUI(); - } else { - showSystemUI(); - } - }); - - Intent intent = getIntent(); - if(intent != null) - handleNewIntent(intent); - } - - @Override - protected void onResume() { - super.onResume(); - restartNoInteractionTimer(); - } - - @Override - protected void onStop() { - super.onStop(); - if (handler != null) { - handler.removeCallbacks(onNoInteraction); - } - } - - @Override - public void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - handleNewIntent(intent); - } - - private void handleNewIntent(Intent intent) { - String action = intent.getAction(); - if (Intent.ACTION_CALL.equals(action) || ACTION_CALL.equals(action)) { - boolean audioOnly = intent.getBooleanExtra(CallFragment.KEY_AUDIO_ONLY, true); - String contactId = intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER); - CallFragment callFragment = CallFragment.newInstance(CallFragment.ACTION_PLACE_CALL, - ConversationPath.fromIntent(intent), - contactId, - audioOnly); - getSupportFragmentManager().beginTransaction().replace(R.id.main_call_layout, callFragment, CALL_FRAGMENT_TAG).commit(); - } else if (Intent.ACTION_VIEW.equals(action) || ACTION_CALL_ACCEPT.equals(action)) { - String confId = intent.getStringExtra(NotificationService.KEY_CALL_ID); - CallFragment callFragment = CallFragment.newInstance(Intent.ACTION_VIEW.equals(action) ? CallFragment.ACTION_GET_CALL : ACTION_CALL_ACCEPT, confId); - getSupportFragmentManager().beginTransaction().replace(R.id.main_call_layout, callFragment, CALL_FRAGMENT_TAG).commit(); - } - } - - private final Runnable onNoInteraction = () -> { - if (!dimmed) { - dimmed = true; - hideSystemUI(); - } - }; - - public void restartNoInteractionTimer() { - if (handler != null) { - handler.removeCallbacks(onNoInteraction); - handler.postDelayed(onNoInteraction, 4 * 1000); - } - } - - @Override - public void onUserLeaveHint() { - CallFragment callFragment = getCallFragment(); - if (callFragment != null) { - callFragment.onUserLeave(); - } - } - - @Override - public void onUserInteraction() { - restartNoInteractionTimer(); - } - - @Override - public void onConfigurationChanged(@NonNull Configuration newConfig) { - if (currentOrientation != newConfig.orientation) { - currentOrientation = newConfig.orientation; - if (dimmed) - hideSystemUI(); - else - showSystemUI(); - } else { - restartNoInteractionTimer(); - } - super.onConfigurationChanged(newConfig); - } - - private void hideSystemUI() { - KeyboardVisibilityManager.hideKeyboard(this); - if (mMainView != null) { - mMainView.setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LOW_PROFILE - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION // hide nav bar - | View.SYSTEM_UI_FLAG_FULLSCREEN // hide status bar - | View.SYSTEM_UI_FLAG_IMMERSIVE); - - CallFragment callFragment = getCallFragment(); - if(callFragment != null && !callFragment.isChoosePluginMode()) { - callFragment.toggleVideoPluginsCarousel(false); - } - if (handler != null) - handler.removeCallbacks(onNoInteraction); - } - } - - public void showSystemUI() { - if (mMainView != null) { - mMainView.setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LOW_PROFILE - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_IMMERSIVE); - - CallFragment callFragment = getCallFragment(); - if(callFragment != null) { - callFragment.toggleVideoPluginsCarousel(true); - } - restartNoInteractionTimer(); - } - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - CallFragment callFragment = getCallFragment(); - if (callFragment != null) { - return MediaButtonsHelper.handleMediaKeyCode(keyCode, callFragment) - || super.onKeyDown(keyCode, event); - } - - return super.onKeyDown(keyCode, event); - } - - private CallFragment getCallFragment() { - CallFragment callFragment = null; - // Get the call Fragment - Fragment fragment = getSupportFragmentManager().findFragmentByTag(CALL_FRAGMENT_TAG); - if (fragment instanceof CallFragment) { - callFragment = (CallFragment) fragment; - } - return callFragment; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/client/CallActivity.kt b/ring-android/app/src/main/java/cx/ring/client/CallActivity.kt new file mode 100644 index 000000000..c75d471ea --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/CallActivity.kt @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Savard <alexandre.savard@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.client + +import cx.ring.utils.ConversationPath.Companion.fromIntent +import dagger.hilt.android.AndroidEntryPoint +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.os.Build +import android.view.WindowManager +import cx.ring.R +import android.media.AudioManager +import android.os.Looper +import android.content.Intent +import android.content.res.Configuration +import android.os.Handler +import android.view.KeyEvent +import android.view.View +import cx.ring.BuildConfig +import cx.ring.application.JamiApplication +import cx.ring.fragments.CallFragment +import net.jami.services.NotificationService +import cx.ring.utils.KeyboardVisibilityManager +import cx.ring.utils.MediaButtonsHelper + +@AndroidEntryPoint +class CallActivity : AppCompatActivity() { + private var mMainView: View? = null + private var handler: Handler? = null + private var currentOrientation = Configuration.ORIENTATION_PORTRAIT + private var dimmed = false + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + JamiApplication.instance?.startDaemon() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setTurnScreenOn(true) + setShowWhenLocked(true) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + ) + } + setContentView(R.layout.activity_call_layout) + volumeControlStream = AudioManager.STREAM_VOICE_CALL + handler = Handler(Looper.getMainLooper()) + mMainView = findViewById<View>(R.id.main_call_layout)?.apply { + setOnClickListener { + dimmed = !dimmed + if (dimmed) { + hideSystemUI() + } else { + showSystemUI() + } + } + } + intent?.let { handleNewIntent(it) } + } + + override fun onResume() { + super.onResume() + restartNoInteractionTimer() + } + + override fun onStop() { + super.onStop() + if (handler != null) { + handler!!.removeCallbacks(onNoInteraction) + } + } + + public override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleNewIntent(intent) + } + + private fun handleNewIntent(intent: Intent) { + val action = intent.action + if (Intent.ACTION_CALL == action || ACTION_CALL == action) { + val audioOnly = intent.getBooleanExtra(CallFragment.KEY_AUDIO_ONLY, true) + val contactId = intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER) + val callFragment = CallFragment.newInstance( + CallFragment.ACTION_PLACE_CALL, + fromIntent(intent), + contactId, + audioOnly + ) + supportFragmentManager.beginTransaction() + .replace(R.id.main_call_layout, callFragment, CALL_FRAGMENT_TAG).commit() + } else if (Intent.ACTION_VIEW == action || ACTION_CALL_ACCEPT == action) { + val confId = intent.getStringExtra(NotificationService.KEY_CALL_ID) + val callFragment = CallFragment.newInstance( + if (Intent.ACTION_VIEW == action) CallFragment.ACTION_GET_CALL else ACTION_CALL_ACCEPT, + confId + ) + supportFragmentManager.beginTransaction() + .replace(R.id.main_call_layout, callFragment, CALL_FRAGMENT_TAG).commit() + } + } + + private val onNoInteraction = Runnable { + if (!dimmed) { + dimmed = true + hideSystemUI() + } + } + + private fun restartNoInteractionTimer() { + if (handler != null) { + handler!!.removeCallbacks(onNoInteraction) + handler!!.postDelayed(onNoInteraction, (4 * 1000).toLong()) + } + } + + public override fun onUserLeaveHint() { + val callFragment = callFragment + callFragment?.onUserLeave() + } + + override fun onUserInteraction() { + restartNoInteractionTimer() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + if (currentOrientation != newConfig.orientation) { + currentOrientation = newConfig.orientation + if (dimmed) hideSystemUI() else showSystemUI() + } else { + restartNoInteractionTimer() + } + super.onConfigurationChanged(newConfig) + } + + private fun hideSystemUI() { + KeyboardVisibilityManager.hideKeyboard(this) + if (mMainView != null) { + mMainView!!.systemUiVisibility = + (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LOW_PROFILE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION // hide nav bar + or View.SYSTEM_UI_FLAG_FULLSCREEN // hide status bar + or View.SYSTEM_UI_FLAG_IMMERSIVE) + val callFragment = callFragment + if (callFragment != null && !callFragment.isChoosePluginMode) { + callFragment.toggleVideoPluginsCarousel(false) + } + if (handler != null) handler!!.removeCallbacks(onNoInteraction) + } + } + + fun showSystemUI() { + if (mMainView != null) { + mMainView!!.systemUiVisibility = + (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LOW_PROFILE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_IMMERSIVE) + val callFragment = callFragment + callFragment?.toggleVideoPluginsCarousel(true) + restartNoInteractionTimer() + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + val callFragment = callFragment + return if (callFragment != null) { + (MediaButtonsHelper.handleMediaKeyCode(keyCode, callFragment) + || super.onKeyDown(keyCode, event)) + } else super.onKeyDown(keyCode, event) + } + + // Get the call Fragment + private val callFragment: CallFragment? + get() { + var callFragment: CallFragment? = null + // Get the call Fragment + val fragment = supportFragmentManager.findFragmentByTag(CALL_FRAGMENT_TAG) + if (fragment is CallFragment) { + callFragment = fragment + } + return callFragment + } + + companion object { + const val ACTION_CALL = BuildConfig.APPLICATION_ID + ".action.call" + const val ACTION_CALL_ACCEPT = BuildConfig.APPLICATION_ID + ".action.CALL_ACCEPT" + private const val CALL_FRAGMENT_TAG = "CALL_FRAGMENT_TAG" + + /* result code sent in case of call failure */ + var RESULT_FAILURE = -10 + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/ColorChooserBottomSheet.java b/ring-android/app/src/main/java/cx/ring/client/ColorChooserBottomSheet.java deleted file mode 100644 index a985f0f68..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/ColorChooserBottomSheet.java +++ /dev/null @@ -1,83 +0,0 @@ -package cx.ring.client; - -import android.content.res.ColorStateList; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; - -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.widget.ImageViewCompat; -import androidx.recyclerview.widget.RecyclerView; -import cx.ring.R; - -public class ColorChooserBottomSheet extends BottomSheetDialogFragment { - - private static final int[] colors = { - R.color.pink_500, - R.color.purple_500, R.color.deep_purple_500, - R.color.indigo_500, R.color.blue_500, - R.color.cyan_500, R.color.teal_500, - R.color.green_500, R.color.light_green_500, - R.color.grey_500, R.color.lime_500, - R.color.amber_500, R.color.deep_orange_500, - R.color.brown_500, R.color.blue_grey_500 - }; - - interface IColorSelected { - void onColorSelected(int color); - } - - private IColorSelected callback; - - public void setCallback(IColorSelected cb) { - callback = cb; - } - - private class ColorView extends RecyclerView.ViewHolder { - ImageView view; - int color; - ColorView(@NonNull View itemView) { - super(itemView); - view = (ImageView) itemView; - itemView.setOnClickListener(v -> { - if (callback != null) - callback.onColorSelected(color); - dismiss(); - }); - } - } - - class ColorAdapter extends RecyclerView.Adapter<ColorView> { - @NonNull - @Override - public ColorView onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_color, parent, false); - return new ColorView(v); - } - - @Override - public void onBindViewHolder(@NonNull ColorView holder, int position) { - int color = colors[position]; - holder.color = getResources().getColor(color); - ImageViewCompat.setImageTintList(holder.view, ColorStateList.valueOf(holder.color)); - } - - @Override - public int getItemCount() { - return colors.length; - } - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - RecyclerView view = (RecyclerView) inflater.inflate(R.layout.frag_color_chooser, container); - view.setAdapter(new ColorAdapter()); - return view; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/client/ColorChooserBottomSheet.kt b/ring-android/app/src/main/java/cx/ring/client/ColorChooserBottomSheet.kt new file mode 100644 index 000000000..05f076a52 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/ColorChooserBottomSheet.kt @@ -0,0 +1,76 @@ +package cx.ring.client + +import android.content.res.ColorStateList +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.core.widget.ImageViewCompat +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import cx.ring.R + +class ColorChooserBottomSheet : BottomSheetDialogFragment() { + interface IColorSelected { + fun onColorSelected(color: Int) + } + + private var callback: IColorSelected? = null + fun setCallback(cb: IColorSelected?) { + callback = cb + } + + private inner class ColorView(itemView: View) : + RecyclerView.ViewHolder(itemView) { + val view: ImageView = itemView as ImageView + var color = 0 + + init { + itemView.setOnClickListener { + if (callback != null) callback!!.onColorSelected(color) + dismiss() + } + } + } + + private inner class ColorAdapter : RecyclerView.Adapter<ColorView>() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ColorView { + val v = LayoutInflater.from(parent.context).inflate(R.layout.item_color, parent, false) + return ColorView(v) + } + + override fun onBindViewHolder(holder: ColorView, position: Int) { + val color = colors[position] + holder.color = resources.getColor(color) + ImageViewCompat.setImageTintList(holder.view, ColorStateList.valueOf(holder.color)) + } + + override fun getItemCount(): Int { + return colors.size + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.frag_color_chooser, container) as RecyclerView + view.adapter = ColorAdapter() + return view + } + + companion object { + private val colors = intArrayOf( + R.color.pink_500, + R.color.purple_500, R.color.deep_purple_500, + R.color.indigo_500, R.color.blue_500, + R.color.cyan_500, R.color.teal_500, + R.color.green_500, R.color.light_green_500, + R.color.grey_500, R.color.lime_500, + R.color.amber_500, R.color.deep_orange_500, + R.color.brown_500, R.color.blue_grey_500 + ) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/ContactDetailsActivity.java b/ring-android/app/src/main/java/cx/ring/client/ContactDetailsActivity.java deleted file mode 100644 index 677bf3790..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/ContactDetailsActivity.java +++ /dev/null @@ -1,457 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.client; - -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.ColorStateList; -import android.graphics.Color; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Toast; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.view.ViewCompat; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.snackbar.Snackbar; - -import net.jami.facades.ConversationFacade; -import net.jami.model.Call; -import net.jami.model.Conference; -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.model.Uri; -import net.jami.services.AccountService; -import net.jami.services.NotificationService; - -import java.util.ArrayList; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.views.AvatarFactory; -import cx.ring.databinding.ActivityContactDetailsBinding; -import cx.ring.databinding.ItemContactActionBinding; -import cx.ring.databinding.ItemContactHorizontalBinding; -import cx.ring.fragments.CallFragment; -import cx.ring.fragments.ConversationFragment; -import cx.ring.services.SharedPreferencesServiceImpl; -import cx.ring.utils.ConversationPath; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class ContactDetailsActivity extends AppCompatActivity { - private static final String TAG = ContactDetailsActivity.class.getName(); - - @Inject - @Singleton - ConversationFacade mConversationFacade; - - @Inject - @Singleton - AccountService mAccountService; - - private SharedPreferences mPreferences; - private ActivityContactDetailsBinding binding; - private Conversation mConversation; - - interface IContactAction { - void onAction(); - } - - static class ContactAction { - @DrawableRes - final int icon; - final Single<Drawable> drawable; - - final CharSequence title; - final IContactAction callback; - - int iconTint; - CharSequence iconSymbol; - - ContactAction(@DrawableRes int i, int tint, CharSequence t, IContactAction cb) { - icon = i; - iconTint = tint; - title = t; - callback = cb; - drawable = null; - } - - ContactAction(@DrawableRes int i, CharSequence t, IContactAction cb) { - icon = i; - iconTint = Color.BLACK; - title = t; - callback = cb; - drawable = null; - } - ContactAction(Single<Drawable> d, CharSequence t, IContactAction cb) { - drawable = d; - icon = 0; - iconTint = Color.BLACK; - title = t; - callback = cb; - } - - void setIconTint(int tint) { - iconTint = tint; - } - - void setSymbol(CharSequence t) { - iconSymbol = t; - } - } - - static class ContactActionView extends RecyclerView.ViewHolder { - final ItemContactActionBinding binding; - IContactAction callback; - final CompositeDisposable disposable = new CompositeDisposable(); - - ContactActionView(@NonNull ItemContactActionBinding b, CompositeDisposable parentDisposable) { - super(b.getRoot()); - binding = b; - parentDisposable.add(disposable); - itemView.setOnClickListener(view -> { - try { - if (callback != null) - callback.onAction(); - } catch (Exception e) { - Log.w(TAG, "Error performing action", e); - } - }); - } - } - - private static class ContactActionAdapter extends RecyclerView.Adapter<ContactActionView> { - private final ArrayList<ContactAction> actions = new ArrayList<>(); - private final CompositeDisposable disposable; - - private ContactActionAdapter(CompositeDisposable disposable) { - this.disposable = disposable; - } - - @NonNull - @Override - public ContactActionView onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); - ItemContactActionBinding itemBinding = ItemContactActionBinding.inflate(layoutInflater, parent, false); - return new ContactActionView(itemBinding, disposable); - } - - @Override - public void onBindViewHolder(@NonNull ContactActionView holder, int position) { - ContactAction action = actions.get(position); - holder.disposable.clear(); - if (action.drawable != null) { - holder.disposable.add(action.drawable.subscribe(holder.binding.actionIcon::setBackground)); - } else { - holder.binding.actionIcon.setBackgroundResource(action.icon); - holder.binding.actionIcon.setText(action.iconSymbol); - if (action.iconTint != Color.BLACK) - ViewCompat.setBackgroundTintList(holder.binding.actionIcon, ColorStateList.valueOf(action.iconTint)); - } - holder.binding.actionTitle.setText(action.title); - holder.callback = action.callback; - } - - @Override - public void onViewRecycled(@NonNull ContactActionView holder) { - holder.disposable.clear(); - holder.binding.actionIcon.setBackground(null); - } - - @Override - public int getItemCount() { - return actions.size(); - } - } - - static class ContactView extends RecyclerView.ViewHolder { - final ItemContactHorizontalBinding binding; - IContactAction callback; - final CompositeDisposable disposable = new CompositeDisposable(); - - ContactView(@NonNull ItemContactHorizontalBinding b, CompositeDisposable parentDisposable) { - super(b.getRoot()); - binding = b; - parentDisposable.add(disposable); - itemView.setOnClickListener(view -> { - try { - if (callback != null) - callback.onAction(); - } catch (Exception e) { - Log.w(TAG, "Error performing action", e); - } - }); - } - } - private static class ContactViewAdapter extends RecyclerView.Adapter<ContactView> { - private final List<Contact> contacts; - private final CompositeDisposable disposable; - interface ContactCallback { - void onContactClicked(Contact contact); - } - private final ContactCallback callback; - - private ContactViewAdapter(CompositeDisposable disposable, List<Contact> contacts, ContactCallback cb) { - this.disposable = disposable; - this.contacts = contacts; - this.callback = cb; - } - - @NonNull - @Override - public ContactView onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); - ItemContactHorizontalBinding itemBinding = ItemContactHorizontalBinding.inflate(layoutInflater, parent, false); - return new ContactView(itemBinding, disposable); - } - - @Override - public void onBindViewHolder(@NonNull ContactView holder, int position) { - Contact contact = contacts.get(position); - holder.disposable.clear(); - holder.disposable.add(AvatarFactory.getAvatar(holder.itemView.getContext(), contact, false).subscribe(holder.binding.photo::setImageDrawable)); - holder.binding.displayName.setText(contact.isUser() ? holder.itemView.getContext().getText(R.string.conversation_info_contact_you) : contact.getDisplayName()); - holder.itemView.setOnClickListener(v -> callback.onContactClicked(contact)); - } - - @Override - public void onViewRecycled(@NonNull ContactView holder) { - holder.disposable.clear(); - holder.binding.photo.setImageDrawable(null); - } - - @Override - public int getItemCount() { - return contacts.size(); - } - } - - private final CompositeDisposable mDisposableBag = new CompositeDisposable(); - private final ContactActionAdapter adapter = new ContactActionAdapter(mDisposableBag); - - private ContactAction colorAction; - private ContactAction symbolAction; - private int colorActionPosition; - private int symbolActionPosition; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - ConversationPath path = ConversationPath.fromIntent(getIntent()); - if (path == null) { - finish(); - return; - } - binding = ActivityContactDetailsBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - JamiApplication.getInstance().getInjectionComponent().inject(this); - - //CollapsingToolbarLayout collapsingToolbarLayout = findViewById(R.id.toolbar_layout); - //collapsingToolbarLayout.setTitle(""); - - setSupportActionBar(binding.toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - getSupportActionBar().setDisplayShowHomeEnabled(true); - - //FloatingActionButton fab = binding.sendMessage; - //fab.setOnClickListener(view -> goToConversationActivity(mConversation.getAccountId(), mConversation.getUri())); - - colorActionPosition = 0; - symbolActionPosition = 1; - - Conversation conversation = mConversationFacade - .startConversation(path.getAccountId(), path.getConversationUri()) - .blockingGet(); - - mConversation = conversation; - mPreferences = SharedPreferencesServiceImpl.getConversationPreferences(this, conversation.getAccountId(), conversation.getUri()); - binding.contactImage.setImageDrawable( - new AvatarDrawable.Builder() - .withConversation(conversation) - .withPresence(false) - .withCircleCrop(true) - .build(this) - ); - - /*Map<String, String> details = Ringservice.getCertificateDetails(conversation.getContact().getUri().getRawRingId()); - for (Map.Entry<String, String> e : details.entrySet()) { - Log.w(TAG, e.getKey() + " -> " + e.getValue()); - }*/ - - @StringRes int infoString = conversation.isSwarm() - ? (conversation.getMode() == Conversation.Mode.OneToOne - ? R.string.conversation_type_private - : R.string.conversation_type_group) - : R.string.conversation_type_contact; - /*@DrawableRes int infoIcon = conversation.isSwarm() - ? (conversation.getMode() == Conversation.Mode.OneToOne - ? R.drawable.baseline_person_24 - : R.drawable.baseline_group_24) - : R.drawable.baseline_person_24;*/ - //adapter.actions.add(new ContactAction(R.drawable.baseline_info_24, getText(infoString), () -> {})); - binding.conversationType.setText(infoString); - //binding.conversationType.setCompoundDrawables(getDrawable(infoIcon), null, null, null); - - colorAction = new ContactAction(R.drawable.item_color_background, 0, getText(R.string.conversation_preference_color), () -> { - ColorChooserBottomSheet frag = new ColorChooserBottomSheet(); - frag.setCallback(color -> { - /*collapsingToolbarLayout.setBackgroundColor(color); - collapsingToolbarLayout.setContentScrimColor(color); - collapsingToolbarLayout.setStatusBarScrimColor(color);*/ - colorAction.setIconTint(color); - adapter.notifyItemChanged(colorActionPosition); - mPreferences.edit().putInt(ConversationFragment.KEY_PREFERENCE_CONVERSATION_COLOR, color).apply(); - }); - frag.show(getSupportFragmentManager(), "colorChooser"); - }); - int color = mPreferences.getInt(ConversationFragment.KEY_PREFERENCE_CONVERSATION_COLOR, getResources().getColor(R.color.color_primary_light)); - colorAction.setIconTint(color); - /*collapsingToolbarLayout.setBackgroundColor(color); - collapsingToolbarLayout.setTitle(conversation.getTitle()); - collapsingToolbarLayout.setContentScrimColor(color); - collapsingToolbarLayout.setStatusBarScrimColor(color);*/ - adapter.actions.add(colorAction); - - symbolAction = new ContactAction(0, getText(R.string.conversation_preference_emoji), () -> { - EmojiChooserBottomSheet frag = new EmojiChooserBottomSheet(); - frag.setCallback(s -> { - symbolAction.setSymbol(s); - adapter.notifyItemChanged(symbolActionPosition); - mPreferences.edit().putString(ConversationFragment.KEY_PREFERENCE_CONVERSATION_SYMBOL, s).apply(); - }); - frag.show(getSupportFragmentManager(), "colorChooser"); - }); - symbolAction.setSymbol(mPreferences.getString(ConversationFragment.KEY_PREFERENCE_CONVERSATION_SYMBOL, getResources().getString(R.string.conversation_default_emoji))); - adapter.actions.add(symbolAction); - - String conversationUri = conversation.isSwarm() ? conversation.getUri().toString() : conversation.getUriTitle(); - if (conversation.getContacts().size() <= 2) { - Contact contact = conversation.getContact(); - adapter.actions.add(new ContactAction(R.drawable.baseline_call_24, getText(R.string.ab_action_audio_call), () -> - goToCallActivity(conversation, contact.getUri(), true))); - adapter.actions.add(new ContactAction(R.drawable.baseline_videocam_24, getText(R.string.ab_action_video_call), () -> - goToCallActivity(conversation, contact.getUri(), false))); - if (!conversation.isSwarm()) { - adapter.actions.add(new ContactAction(R.drawable.baseline_clear_all_24, getText(R.string.conversation_action_history_clear), () -> - new MaterialAlertDialogBuilder(ContactDetailsActivity.this) - .setTitle(R.string.clear_history_dialog_title) - .setMessage(R.string.clear_history_dialog_message) - .setPositiveButton(R.string.conversation_action_history_clear, (b, i) -> { - mConversationFacade.clearHistory(conversation.getAccountId(), contact.getUri()).subscribe(); - Snackbar.make(binding.getRoot(), R.string.clear_history_completed, Snackbar.LENGTH_LONG).show(); - }) - .setNegativeButton(android.R.string.cancel, null) - .create() - .show())); - } - adapter.actions.add(new ContactAction(R.drawable.baseline_block_24, getText(R.string.conversation_action_block_this), () -> - new MaterialAlertDialogBuilder(ContactDetailsActivity.this) - .setTitle(getString(R.string.block_contact_dialog_title, conversationUri)) - .setMessage(getString(R.string.block_contact_dialog_message, conversationUri)) - .setPositiveButton(R.string.conversation_action_block_this, (b, i) -> { - mAccountService.removeContact(conversation.getAccountId(), contact.getUri().getRawRingId(), true); - Toast.makeText(getApplicationContext(), getString(R.string.block_contact_completed, conversationUri), Toast.LENGTH_LONG).show(); - finish(); - }) - .setNegativeButton(android.R.string.cancel, null) - .create() - .show())); - } - getSupportActionBar().setTitle(conversation.getTitle()); - //new ContactAction(conversation.isSwarm() ? R.drawable.baseline_group_24 : R.drawable.baseline_person_24, conversationUri, () -> {}); - binding.conversationId.setText(conversationUri); - binding.infoCard.setOnClickListener(v -> copyAndShow(path.getConversationId())); - //adapter.actions.add(contactAction); - binding.contactActionList.setAdapter(adapter); - - binding.contactListLayout.setVisibility(conversation.isSwarm() ? View.VISIBLE : View.GONE); - if (conversation.isSwarm()) { - binding.contactList.setAdapter(new ContactViewAdapter(mDisposableBag, conversation.getContacts(), contact -> copyAndShow(contact.getUri().getRawUriString()))); - } - } - - void copyAndShow(String toCopy) { - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - if (clipboard != null) { - clipboard.setPrimaryClip(ClipData.newPlainText(getText(R.string.clip_contact_uri), toCopy)); - Snackbar.make(binding.getRoot(), getString(R.string.conversation_action_copied_peer_number_clipboard, toCopy), Snackbar.LENGTH_LONG).show(); - } - } - - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == android.R.id.home) { - finishAfterTransition(); - } - return super.onOptionsItemSelected(item); - } - - @Override - protected void onDestroy() { - adapter.actions.clear(); - mDisposableBag.dispose(); - super.onDestroy(); - colorAction = null; - mPreferences = null; - binding = null; - } - - private void goToCallActivity(Conversation conversation, Uri contactUri, boolean audioOnly) { - Conference conf = mConversation.getCurrentCall(); - if (conf != null - && !conf.getParticipants().isEmpty() - && conf.getParticipants().get(0).getCallStatus() != Call.CallStatus.INACTIVE - && conf.getParticipants().get(0).getCallStatus() != Call.CallStatus.FAILURE) { - startActivity(new Intent(Intent.ACTION_VIEW) - .setClass(getApplicationContext(), CallActivity.class) - .putExtra(NotificationService.KEY_CALL_ID, conf.getId())); - } else { - Intent intent = new Intent(Intent.ACTION_CALL) - .setClass(getApplicationContext(), CallActivity.class) - .putExtras(ConversationPath.toBundle(conversation)) - .putExtra(Intent.EXTRA_PHONE_NUMBER, contactUri.getUri()) - .putExtra(CallFragment.KEY_AUDIO_ONLY, audioOnly); - startActivityForResult(intent, HomeActivity.REQUEST_CODE_CALL); - } - } - - private void goToConversationActivity(String accountId, Uri conversationUri) { - startActivity(new Intent(Intent.ACTION_VIEW, ConversationPath.toUri(accountId, conversationUri), getApplicationContext(), ConversationActivity.class)); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/client/ContactDetailsActivity.kt b/ring-android/app/src/main/java/cx/ring/client/ContactDetailsActivity.kt new file mode 100644 index 000000000..681b9b4f5 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/ContactDetailsActivity.kt @@ -0,0 +1,439 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.client + +import android.content.* +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import cx.ring.R +import cx.ring.client.ColorChooserBottomSheet.IColorSelected +import cx.ring.client.EmojiChooserBottomSheet.IEmojiSelected +import cx.ring.databinding.ActivityContactDetailsBinding +import cx.ring.databinding.ItemContactActionBinding +import cx.ring.databinding.ItemContactHorizontalBinding +import cx.ring.fragments.CallFragment +import cx.ring.fragments.ConversationFragment +import cx.ring.services.SharedPreferencesServiceImpl.Companion.getConversationPreferences +import cx.ring.utils.ConversationPath +import cx.ring.views.AvatarDrawable +import cx.ring.views.AvatarFactory +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.functions.Consumer +import net.jami.services.ConversationFacade +import net.jami.model.Call +import net.jami.model.Contact +import net.jami.model.Conversation +import net.jami.model.Uri +import net.jami.services.AccountService +import net.jami.services.NotificationService +import javax.inject.Inject +import javax.inject.Singleton + +@AndroidEntryPoint +class ContactDetailsActivity : AppCompatActivity() { + @Inject + @Singleton lateinit + var mConversationFacade: ConversationFacade + + @Inject + @Singleton lateinit + var mAccountService: AccountService + + private var binding: ActivityContactDetailsBinding? = null + + internal class ContactAction { + @DrawableRes + val icon: Int + val drawable: Single<Drawable>? + val title: CharSequence + val callback: () -> Unit + var iconTint: Int + var iconSymbol: CharSequence? = null + + constructor(@DrawableRes i: Int, tint: Int, t: CharSequence, cb: () -> Unit) { + icon = i + iconTint = tint + title = t + callback = cb + drawable = null + } + + constructor(@DrawableRes i: Int, t: CharSequence, cb: () -> Unit) { + icon = i + iconTint = Color.BLACK + title = t + callback = cb + drawable = null + } + + constructor(d: Single<Drawable>?, t: CharSequence, cb: () -> Unit) { + drawable = d + icon = 0 + iconTint = Color.BLACK + title = t + callback = cb + } + + fun setSymbol(t: CharSequence?) { + iconSymbol = t + } + } + + internal class ContactActionView( + val binding: ItemContactActionBinding, + parentDisposable: CompositeDisposable + ) : RecyclerView.ViewHolder( + binding.root + ) { + var callback: (() -> Unit)? = null + val disposable = CompositeDisposable() + + init { + parentDisposable.add(disposable) + itemView.setOnClickListener { + try { + callback?.invoke() + } catch (e: Exception) { + Log.w(TAG, "Error performing action", e) + } + } + } + } + + private class ContactActionAdapter(private val disposable: CompositeDisposable) : + RecyclerView.Adapter<ContactActionView>() { + val actions = ArrayList<ContactAction>() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactActionView { + val layoutInflater = LayoutInflater.from(parent.context) + val itemBinding = ItemContactActionBinding.inflate(layoutInflater, parent, false) + return ContactActionView(itemBinding, disposable) + } + + override fun onBindViewHolder(holder: ContactActionView, position: Int) { + val action = actions[position] + holder.disposable.clear() + if (action.drawable != null) { + holder.disposable.add(action.drawable.subscribe(Consumer { background: Drawable? -> + holder.binding.actionIcon.background = background + })) + } else { + holder.binding.actionIcon.setBackgroundResource(action.icon) + holder.binding.actionIcon.text = action.iconSymbol + if (action.iconTint != Color.BLACK) ViewCompat.setBackgroundTintList( + holder.binding.actionIcon, + ColorStateList.valueOf(action.iconTint) + ) + } + holder.binding.actionTitle.text = action.title + holder.callback = action.callback + } + + override fun onViewRecycled(holder: ContactActionView) { + holder.disposable.clear() + holder.binding.actionIcon.background = null + } + + override fun getItemCount(): Int { + return actions.size + } + } + + internal class ContactView( + val binding: ItemContactHorizontalBinding, + parentDisposable: CompositeDisposable + ) : RecyclerView.ViewHolder( + binding.root + ) { + var callback: (() -> Unit)? = null + val disposable = CompositeDisposable() + + init { + parentDisposable.add(disposable) + itemView.setOnClickListener { + try { + callback?.invoke() + } catch (e: Exception) { + Log.w(TAG, "Error performing action", e) + } + } + } + } + + private class ContactViewAdapter( + private val disposable: CompositeDisposable, + private val contacts: List<Contact>, + private val callback: (Contact) -> Unit + ) : RecyclerView.Adapter<ContactView>() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactView { + val layoutInflater = LayoutInflater.from(parent.context) + val itemBinding = ItemContactHorizontalBinding.inflate(layoutInflater, parent, false) + return ContactView(itemBinding, disposable) + } + + override fun onBindViewHolder(holder: ContactView, position: Int) { + val contact = contacts[position] + holder.disposable.clear() + holder.disposable.add( + AvatarFactory.getAvatar(holder.itemView.context, contact, false) + .subscribe { drawable: Drawable -> + holder.binding.photo.setImageDrawable(drawable) + }) + holder.binding.displayName.text = + if (contact.isUser) holder.itemView.context.getText(R.string.conversation_info_contact_you) else contact.displayName + holder.itemView.setOnClickListener { callback.invoke(contact) } + } + + override fun onViewRecycled(holder: ContactView) { + holder.disposable.clear() + holder.binding.photo.setImageDrawable(null) + } + + override fun getItemCount(): Int { + return contacts.size + } + } + + private val mDisposableBag = CompositeDisposable() + private val adapter = ContactActionAdapter(mDisposableBag) + private var colorAction: ContactAction? = null + private var symbolAction: ContactAction? = null + private var colorActionPosition = 0 + private var symbolActionPosition = 0 + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val path = ConversationPath.fromIntent(intent) + if (path == null) { + finish() + return + } + binding = ActivityContactDetailsBinding.inflate(layoutInflater) + setContentView(binding!!.root) + //JamiApplication.getInstance().getInjectionComponent().inject(this); + + //CollapsingToolbarLayout collapsingToolbarLayout = findViewById(R.id.toolbar_layout); + //collapsingToolbarLayout.setTitle(""); + setSupportActionBar(binding!!.toolbar) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + supportActionBar!!.setDisplayShowHomeEnabled(true) + + //FloatingActionButton fab = binding.sendMessage; + //fab.setOnClickListener(view -> goToConversationActivity(mConversation.getAccountId(), mConversation.getUri())); + colorActionPosition = 0 + symbolActionPosition = 1 + val conversation = mConversationFacade + .startConversation(path.accountId, path.conversationUri) + .blockingGet() + val preferences = getConversationPreferences(this, conversation.accountId, conversation.uri) + binding!!.contactImage.setImageDrawable( + AvatarDrawable.Builder() + .withConversation(conversation) + .withPresence(false) + .withCircleCrop(true) + .build(this) + ) + + /*Map<String, String> details = Ringservice.getCertificateDetails(conversation.getContact().getUri().getRawRingId()); + for (Map.Entry<String, String> e : details.entrySet()) { + Log.w(TAG, e.getKey() + " -> " + e.getValue()); + }*/ + @StringRes val infoString = + if (conversation.isSwarm) + if (conversation.mode.blockingFirst() == Conversation.Mode.OneToOne) + R.string.conversation_type_private + else + R.string.conversation_type_group + else R.string.conversation_type_contact + /*@DrawableRes int infoIcon = conversation.isSwarm() + ? (conversation.getMode() == Conversation.Mode.OneToOne + ? R.drawable.baseline_person_24 + : R.drawable.baseline_group_24) + : R.drawable.baseline_person_24;*/ + //adapter.actions.add(new ContactAction(R.drawable.baseline_info_24, getText(infoString), () -> {})); + binding!!.conversationType.setText(infoString) + //binding.conversationType.setCompoundDrawables(getDrawable(infoIcon), null, null, null); + colorAction = ContactAction( + R.drawable.item_color_background, + 0, + getText(R.string.conversation_preference_color) + ) { + val frag = ColorChooserBottomSheet() + frag.setCallback(object : IColorSelected { + override fun onColorSelected(color: Int) { + /*collapsingToolbarLayout.setBackgroundColor(color); + collapsingToolbarLayout.setContentScrimColor(color); + collapsingToolbarLayout.setStatusBarScrimColor(color);*/ + colorAction!!.iconTint = color + adapter.notifyItemChanged(colorActionPosition) + preferences.edit() + .putInt(ConversationFragment.KEY_PREFERENCE_CONVERSATION_COLOR, color) + .apply() + } + }) + frag.show(supportFragmentManager, "colorChooser") + } + val color = preferences.getInt( + ConversationFragment.KEY_PREFERENCE_CONVERSATION_COLOR, + resources.getColor(R.color.color_primary_light) + ) + colorAction!!.iconTint = color + /*collapsingToolbarLayout.setBackgroundColor(color); + collapsingToolbarLayout.setTitle(conversation.getTitle()); + collapsingToolbarLayout.setContentScrimColor(color); + collapsingToolbarLayout.setStatusBarScrimColor(color);*/ + adapter.actions.add(colorAction!!) + symbolAction = ContactAction(0, getText(R.string.conversation_preference_emoji)) { + EmojiChooserBottomSheet().apply { + setCallback(object : IEmojiSelected { + override fun onEmojiSelected(emoji: String?) { + symbolAction?.setSymbol(emoji) + adapter.notifyItemChanged(symbolActionPosition) + preferences.edit() + .putString(ConversationFragment.KEY_PREFERENCE_CONVERSATION_SYMBOL, emoji) + .apply() + } + }) + show(supportFragmentManager, "colorChooser") + } + }.apply { + setSymbol(preferences.getString(ConversationFragment.KEY_PREFERENCE_CONVERSATION_SYMBOL, resources.getString(R.string.conversation_default_emoji))) + adapter.actions.add(this) + } + val conversationUri = if (conversation.isSwarm) conversation.uri.toString() else conversation.uriTitle + if (conversation.contacts.size <= 2 && conversation.contacts.isNotEmpty()) { + val contact = conversation.contact!! + adapter.actions.add(ContactAction(R.drawable.baseline_call_24, getText(R.string.ab_action_audio_call)) { + goToCallActivity(conversation, contact.uri, true) + }) + adapter.actions.add(ContactAction(R.drawable.baseline_videocam_24, getText(R.string.ab_action_video_call)) { + goToCallActivity(conversation, contact.uri, false) + }) + if (!conversation.isSwarm) { + adapter.actions.add(ContactAction(R.drawable.baseline_clear_all_24, getText(R.string.conversation_action_history_clear)) { + MaterialAlertDialogBuilder(this@ContactDetailsActivity) + .setTitle(R.string.clear_history_dialog_title) + .setMessage(R.string.clear_history_dialog_message) + .setPositiveButton(R.string.conversation_action_history_clear) { _: DialogInterface?, _: Int -> + mConversationFacade.clearHistory(conversation.accountId, contact.uri).subscribe() + Snackbar.make(binding!!.root, R.string.clear_history_completed, Snackbar.LENGTH_LONG).show() + } + .setNegativeButton(android.R.string.cancel, null) + .create() + .show() + }) + } + adapter.actions.add(ContactAction(R.drawable.baseline_block_24, getText(R.string.conversation_action_block_this)) { + MaterialAlertDialogBuilder(this@ContactDetailsActivity) + .setTitle(getString(R.string.block_contact_dialog_title, conversationUri)) + .setMessage(getString(R.string.block_contact_dialog_message, conversationUri)) + .setPositiveButton(R.string.conversation_action_block_this) { _: DialogInterface?, _: Int -> + mAccountService.removeContact(conversation.accountId, contact.uri.rawRingId,true) + Toast.makeText(applicationContext, getString(R.string.block_contact_completed, conversationUri), Toast.LENGTH_LONG).show() + finish() + } + .setNegativeButton(android.R.string.cancel, null) + .create() + .show() + }) + } + supportActionBar?.title = conversation.title + //new ContactAction(conversation.isSwarm() ? R.drawable.baseline_group_24 : R.drawable.baseline_person_24, conversationUri, () -> {}); + binding!!.conversationId.text = conversationUri + binding!!.infoCard.setOnClickListener { copyAndShow(path.conversationId) } + //adapter.actions.add(contactAction); + binding!!.contactActionList.adapter = adapter + binding!!.contactListLayout.visibility = + if (conversation.isSwarm) View.VISIBLE else View.GONE + if (conversation.isSwarm) { + binding!!.contactList.adapter = ContactViewAdapter(mDisposableBag, conversation.contacts) + { contact -> copyAndShow(contact.uri.rawUriString) } + } + } + + private fun copyAndShow(toCopy: String) { + val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(getText(R.string.clip_contact_uri), toCopy)) + Snackbar.make(binding!!.root, getString(R.string.conversation_action_copied_peer_number_clipboard, toCopy), Snackbar.LENGTH_LONG).show() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finishAfterTransition() + } + return super.onOptionsItemSelected(item) + } + + override fun onDestroy() { + adapter.actions.clear() + mDisposableBag.dispose() + super.onDestroy() + colorAction = null + binding = null + } + + private fun goToCallActivity(conversation: Conversation, contactUri: Uri, audioOnly: Boolean) { + val conf = conversation.currentCall + if (conf != null && conf.participants.isNotEmpty() + && conf.participants[0].callStatus != Call.CallStatus.INACTIVE + && conf.participants[0].callStatus != Call.CallStatus.FAILURE) { + startActivity(Intent(Intent.ACTION_VIEW) + .setClass(applicationContext, CallActivity::class.java) + .putExtra(NotificationService.KEY_CALL_ID, conf.id)) + } else { + val intent = Intent(Intent.ACTION_CALL) + .setClass(applicationContext, CallActivity::class.java) + .putExtras(ConversationPath.toBundle(conversation)) + .putExtra(Intent.EXTRA_PHONE_NUMBER, contactUri.uri) + .putExtra(CallFragment.KEY_AUDIO_ONLY, audioOnly) + startActivityForResult(intent, HomeActivity.REQUEST_CODE_CALL) + } + } + + private fun goToConversationActivity(accountId: String, conversationUri: Uri) { + startActivity( + Intent( + Intent.ACTION_VIEW, + ConversationPath.toUri(accountId, conversationUri), + applicationContext, + ConversationActivity::class.java + ) + ) + } + + companion object { + private val TAG = ContactDetailsActivity::class.simpleName!! + } +} diff --git a/ring-android/app/src/main/java/cx/ring/client/ConversationActivity.java b/ring-android/app/src/main/java/cx/ring/client/ConversationActivity.java deleted file mode 100644 index 267189898..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/ConversationActivity.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.client; - -import android.content.Intent; -import android.os.Bundle; -import android.view.KeyEvent; -import android.view.Menu; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.databinding.ActivityConversationBinding; -import cx.ring.fragments.ConversationFragment; -import cx.ring.interfaces.Colorable; -import cx.ring.services.NotificationServiceImpl; -import cx.ring.utils.ConversationPath; - -public class ConversationActivity extends AppCompatActivity implements Colorable { - - private ConversationFragment mConversationFragment; - private ConversationPath conversationPath = null; - - private Intent mPendingIntent = null; - private ActivityConversationBinding binding; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Intent intent = getIntent(); - String action = intent == null ? null : intent.getAction(); - if (intent != null) { - conversationPath = ConversationPath.fromIntent(intent); - } else if (savedInstanceState != null) { - conversationPath = ConversationPath.fromBundle(savedInstanceState); - } - if (conversationPath == null) { - finish(); - return; - } - boolean isBubble = getIntent().getBooleanExtra(NotificationServiceImpl.EXTRA_BUBBLE, false); - - JamiApplication.getInstance().startDaemon(); - binding = ActivityConversationBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - setSupportActionBar(binding.mainToolbar); - ActionBar ab = getSupportActionBar(); - if (ab != null) - ab.setDisplayHomeAsUpEnabled(true); - - binding.contactImage.setOnClickListener(v -> { - if (mConversationFragment != null) - mConversationFragment.openContact(); - }); - - if (mConversationFragment == null) { - Bundle bundle = conversationPath.toBundle(); - bundle.putBoolean(NotificationServiceImpl.EXTRA_BUBBLE, isBubble); - - mConversationFragment = new ConversationFragment(); - mConversationFragment.setArguments(bundle); - getSupportFragmentManager().beginTransaction() - .replace(R.id.main_frame, mConversationFragment, null) - .commitNow(); - } - if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action) || Intent.ACTION_VIEW.equals(action)) { - mPendingIntent = intent; - } - } - - @Override - public void onContextMenuClosed(@NonNull Menu menu) { - mConversationFragment.updateAdapterItem(); - super.onContextMenuClosed(menu); - } - - @Override - protected void onStart() { - super.onStart(); - if (mPendingIntent != null) { - handleShareIntent(mPendingIntent); - mPendingIntent = null; - } - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - handleShareIntent(intent); - } - - private void handleShareIntent(Intent intent) { - if (mConversationFragment != null) - mConversationFragment.handleShareIntent(intent); - } - - @Override - protected void onSaveInstanceState(@NonNull Bundle outState) { - conversationPath.toBundle(outState); - super.onSaveInstanceState(outState); - } - - @Override - public boolean dispatchKeyEvent(@NonNull KeyEvent event) { - if (event.getAction() == KeyEvent.ACTION_DOWN && event.isCtrlPressed()) { - if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) { - if (mConversationFragment != null) - mConversationFragment.sendMessageText(); - return true; - } - } - return super.dispatchKeyEvent(event); - } - - public void setColor(@ColorInt int color) { - //colouriseToolbar(binding.mainToolbar, color); - //mToolbar.setBackground(new ColorDrawable(color)); - //getWindow().setStatusBarColor(color); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/client/ConversationActivity.kt b/ring-android/app/src/main/java/cx/ring/client/ConversationActivity.kt new file mode 100644 index 000000000..8cf134f01 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/ConversationActivity.kt @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.client + +import android.content.Intent +import android.os.Bundle +import android.view.KeyEvent +import android.view.Menu +import android.view.View +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import cx.ring.R +import cx.ring.application.JamiApplication +import cx.ring.databinding.ActivityConversationBinding +import cx.ring.fragments.ConversationFragment +import cx.ring.interfaces.Colorable +import cx.ring.services.NotificationServiceImpl +import cx.ring.utils.ConversationPath +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ConversationActivity : AppCompatActivity(), Colorable { + private var mConversationFragment: ConversationFragment? = null + private lateinit var conversationPath: ConversationPath + private var mPendingIntent: Intent? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val intent = intent + val action = intent?.action + var path: ConversationPath? = null + if (intent != null) { + path = ConversationPath.fromIntent(intent) + } else if (savedInstanceState != null) { + path = ConversationPath.fromBundle(savedInstanceState) + } + if (path == null) { + finish() + return + } + conversationPath = path; + val isBubble = getIntent().getBooleanExtra(NotificationServiceImpl.EXTRA_BUBBLE, false) + JamiApplication.instance!!.startDaemon() + val binding = ActivityConversationBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.mainToolbar) + val ab = supportActionBar + ab?.setDisplayHomeAsUpEnabled(true) + binding.contactImage.setOnClickListener { v: View? -> if (mConversationFragment != null) mConversationFragment!!.openContact() } + if (mConversationFragment == null) { + val bundle = conversationPath.toBundle() + bundle.putBoolean(NotificationServiceImpl.EXTRA_BUBBLE, isBubble) + mConversationFragment = ConversationFragment() + mConversationFragment!!.arguments = bundle + supportFragmentManager.beginTransaction() + .replace(R.id.main_frame, mConversationFragment!!, null) + .commitNow() + } + if (Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action || Intent.ACTION_VIEW == action) { + mPendingIntent = intent + } + } + + override fun onContextMenuClosed(menu: Menu) { + mConversationFragment!!.updateAdapterItem() + super.onContextMenuClosed(menu) + } + + override fun onStart() { + super.onStart() + if (mPendingIntent != null) { + handleShareIntent(mPendingIntent!!) + mPendingIntent = null + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleShareIntent(intent) + } + + private fun handleShareIntent(intent: Intent) { + if (mConversationFragment != null) mConversationFragment!!.handleShareIntent(intent) + } + + override fun onSaveInstanceState(outState: Bundle) { + conversationPath.toBundle(outState) + super.onSaveInstanceState(outState) + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN && event.isCtrlPressed) { + if (event.keyCode == KeyEvent.KEYCODE_ENTER) { + if (mConversationFragment != null) mConversationFragment!!.sendMessageText() + return true + } + } + return super.dispatchKeyEvent(event) + } + + override fun setColor(@ColorInt color: Int) { + //colouriseToolbar(binding.mainToolbar, color); + //mToolbar.setBackground(new ColorDrawable(color)); + //getWindow().setStatusBarColor(color); + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/ConversationSelectionActivity.java b/ring-android/app/src/main/java/cx/ring/client/ConversationSelectionActivity.java deleted file mode 100644 index 77b60d7ca..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/ConversationSelectionActivity.java +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.client; - -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; -import android.text.TextUtils; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import cx.ring.R; -import cx.ring.adapters.SmartListAdapter; -import cx.ring.application.JamiApplication; -import net.jami.facades.ConversationFacade; -import cx.ring.fragments.CallFragment; - -import net.jami.model.Contact; -import net.jami.model.Conference; -import net.jami.model.Call; -import net.jami.services.CallService; -import net.jami.smartlist.SmartListViewModel; -import cx.ring.utils.ConversationPath; -import cx.ring.viewholders.SmartListViewHolder; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class ConversationSelectionActivity extends AppCompatActivity { - private final static String TAG = ConversationSelectionActivity.class.getSimpleName(); - - private final CompositeDisposable mDisposable = new CompositeDisposable(); - - @Inject - @Singleton - ConversationFacade mConversationFacade; - - @Inject - @Singleton - CallService mCallService; - - private SmartListAdapter adapter; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - JamiApplication.getInstance().getInjectionComponent().inject(this); - setContentView(R.layout.frag_selectconv); - RecyclerView list = findViewById(R.id.conversationList); - - /*Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - ActionBar ab = getSupportActionBar(); - if (ab != null) - ab.setDisplayHomeAsUpEnabled(true);*/ - - adapter = new SmartListAdapter(null, new SmartListViewHolder.SmartListListeners() { - @Override - public void onItemClick(SmartListViewModel smartListViewModel) { - Intent intent = new Intent(); - intent.setData(ConversationPath.toUri(smartListViewModel.getAccountId(), smartListViewModel.getUri())); - setResult(Activity.RESULT_OK, intent); - finish(); - } - - @Override - public void onItemLongClick(SmartListViewModel smartListViewModel) { - } - }, mDisposable); - list.setLayoutManager(new LinearLayoutManager(this)); - list.setAdapter(adapter); - } - - @Override - public void onStart() { - super.onStart(); - - Conference conference = null; - Intent intent = getIntent(); - if (intent != null) { - String confId = intent.getStringExtra(CallFragment.KEY_CONF_ID); - if (!TextUtils.isEmpty(confId)) { - conference = mCallService.getConference(confId); - } - } - - final Conference conf = conference; - mDisposable.add(mConversationFacade - .getCurrentAccountSubject() - .switchMap(a -> a.getConversationsViewModels(false)) - .map(vm -> { - if (conf == null) - return vm; - List<SmartListViewModel> filteredVms = new ArrayList<>(vm.size()); - models: for (SmartListViewModel v : vm) { - List<Contact> contacts = v.getContacts(); - if (contacts.size() != 1) - continue; - for (Call call : conf.getParticipants()) { - if (call.getContact() == v.getContacts().get(0)) { - continue models; - } - } - filteredVms.add(v); - } - return filteredVms; - }) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(list -> { - if (adapter != null) - adapter.update(list); - })); - } - - @Override - public void onStop() { - super.onStop(); - mDisposable.clear(); - } - - - @Override - public void onDestroy() { - super.onDestroy(); - adapter = null; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/client/ConversationSelectionActivity.kt b/ring-android/app/src/main/java/cx/ring/client/ConversationSelectionActivity.kt new file mode 100644 index 000000000..673b3cb95 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/ConversationSelectionActivity.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.client + +import android.content.Intent +import android.os.Bundle +import android.text.TextUtils +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import cx.ring.R +import cx.ring.adapters.SmartListAdapter +import cx.ring.fragments.CallFragment +import cx.ring.utils.ConversationPath +import cx.ring.viewholders.SmartListViewHolder.SmartListListeners +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.services.ConversationFacade +import net.jami.model.Account +import net.jami.model.Conference +import net.jami.services.CallService +import net.jami.smartlist.SmartListViewModel +import javax.inject.Inject +import javax.inject.Singleton + +@AndroidEntryPoint +class ConversationSelectionActivity : AppCompatActivity() { + private val mDisposable = CompositeDisposable() + + @Inject + @Singleton lateinit + var mConversationFacade: ConversationFacade + + @Inject + @Singleton lateinit + var mCallService: CallService + + private val adapter: SmartListAdapter = SmartListAdapter(null, object : SmartListListeners { + override fun onItemClick(smartListViewModel: SmartListViewModel) { + val intent = Intent() + intent.data = ConversationPath.toUri(smartListViewModel.accountId, smartListViewModel.uri) + setResult(RESULT_OK, intent) + finish() + } + + override fun onItemLongClick(smartListViewModel: SmartListViewModel) {} + }, mDisposable) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.frag_selectconv) + val list = findViewById<RecyclerView>(R.id.conversationList) + list.layoutManager = LinearLayoutManager(this) + list.adapter = adapter + } + + public override fun onStart() { + super.onStart() + val conference: Conference? = intent?.getStringExtra(CallFragment.KEY_CONF_ID)?.let { confId -> mCallService.getConference(confId) } + mDisposable.add(mConversationFacade + .currentAccountSubject + .switchMap { a: Account -> a.getConversationsViewModels(false) } + .map { vm: MutableList<SmartListViewModel> -> + if (conference == null) return@map vm + val filteredVms: MutableList<SmartListViewModel> = ArrayList(vm.size) + models@ for (v in vm) { + val contact = v.contact ?: continue // We only add contacts and one to one + for (call in conference.participants) { + if (call.contact === contact) { + continue@models + } + } + filteredVms.add(v) + } + filteredVms + } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { list -> adapter.update(list) }) + } + + public override fun onStop() { + super.onStop() + mDisposable.clear() + } + + public override fun onDestroy() { + super.onDestroy() + findViewById<RecyclerView>(R.id.conversationList).adapter = null + adapter.update(ArrayList()) + } +} diff --git a/ring-android/app/src/main/java/cx/ring/client/EmojiChooserBottomSheet.java b/ring-android/app/src/main/java/cx/ring/client/EmojiChooserBottomSheet.java deleted file mode 100644 index ee7e294cd..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/EmojiChooserBottomSheet.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.client; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.ArrayRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; - -import cx.ring.R; - -public class EmojiChooserBottomSheet extends BottomSheetDialogFragment { - - interface IEmojiSelected { - void onEmojiSelected(String emoji); - } - - private IEmojiSelected callback; - - public void setCallback(IEmojiSelected cb) { - callback = cb; - } - - private class EmojiView extends RecyclerView.ViewHolder { - TextView view; - String emoji; - - EmojiView(@NonNull View itemView) { - super(itemView); - view = (TextView) itemView; - itemView.setOnClickListener(v -> { - if (callback != null) - callback.onEmojiSelected(emoji); - dismiss(); - }); - } - } - - class ColorAdapter extends RecyclerView.Adapter<EmojiView> { - private final String[] emojis; - - public ColorAdapter(@ArrayRes int arrayResId) { - emojis = getResources().getStringArray(arrayResId); - } - - @NonNull - @Override - public EmojiView onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_emoji, parent, false); - return new EmojiView(v); - } - - @Override - public void onBindViewHolder(@NonNull EmojiView holder, int position) { - holder.emoji = emojis[position]; - holder.view.setText(holder.emoji); - } - - @Override - public int getItemCount() { - return emojis.length; - } - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - RecyclerView view = (RecyclerView) inflater.inflate(R.layout.frag_color_chooser, container); - view.setAdapter(new ColorAdapter(R.array.conversation_emojis)); - return view; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/client/EmojiChooserBottomSheet.kt b/ring-android/app/src/main/java/cx/ring/client/EmojiChooserBottomSheet.kt new file mode 100644 index 000000000..a9492f0da --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/EmojiChooserBottomSheet.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.client + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.ArrayRes +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import cx.ring.R + +class EmojiChooserBottomSheet : BottomSheetDialogFragment() { + interface IEmojiSelected { + fun onEmojiSelected(emoji: String?) + } + + private var callback: IEmojiSelected? = null + fun setCallback(cb: IEmojiSelected?) { + callback = cb + } + + private inner class EmojiView constructor(itemView: View) : + RecyclerView.ViewHolder(itemView) { + val view: TextView = itemView as TextView + var emoji: String? = null + + init { + itemView.setOnClickListener { v: View? -> + if (callback != null) callback!!.onEmojiSelected(emoji) + dismiss() + } + } + } + + private inner class ColorAdapter(@ArrayRes arrayResId: Int) : + RecyclerView.Adapter<EmojiView>() { + private val emojis: Array<String> = resources.getStringArray(arrayResId) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiView { + val v = LayoutInflater.from(parent.context).inflate(R.layout.item_emoji, parent, false) + return EmojiView(v) + } + + override fun onBindViewHolder(holder: EmojiView, position: Int) { + holder.emoji = emojis[position] + holder.view.text = holder.emoji + } + + override fun getItemCount(): Int { + return emojis.size + } + + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.frag_color_chooser, container) as RecyclerView + view.adapter = ColorAdapter(R.array.conversation_emojis) + return view + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/HomeActivity.java b/ring-android/app/src/main/java/cx/ring/client/HomeActivity.java deleted file mode 100644 index 12a722d7f..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/HomeActivity.java +++ /dev/null @@ -1,835 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> - * Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.client; - -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.Typeface; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.util.Log; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewOutlineProvider; -import android.widget.AdapterView; -import android.widget.Spinner; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.Person; -import androidx.core.content.ContextCompat; -import androidx.core.content.pm.ShortcutInfoCompat; -import androidx.core.content.pm.ShortcutManagerCompat; -import androidx.core.graphics.drawable.IconCompat; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentTransaction; - -import com.google.android.material.bottomnavigation.BottomNavigationView; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import net.jami.facades.ConversationFacade; -import net.jami.model.Account; -import net.jami.model.AccountConfig; -import net.jami.model.Conversation; -import net.jami.services.AccountService; -import net.jami.services.ContactService; -import net.jami.services.NotificationService; -import net.jami.smartlist.SmartListViewModel; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.Future; - -import javax.inject.Inject; - -import cx.ring.BuildConfig; -import cx.ring.R; -import cx.ring.about.AboutFragment; -import cx.ring.account.AccountEditionFragment; -import cx.ring.account.AccountWizardActivity; -import cx.ring.application.JamiApplication; -import cx.ring.contactrequests.ContactRequestsFragment; -import cx.ring.services.ContactServiceImpl; -import cx.ring.utils.BitmapUtils; -import cx.ring.views.AvatarFactory; -import cx.ring.databinding.ActivityHomeBinding; -import cx.ring.fragments.ConversationFragment; -import cx.ring.fragments.SmartListFragment; -import cx.ring.interfaces.BackHandlerInterface; -import cx.ring.interfaces.Colorable; -import cx.ring.service.DRingService; -import cx.ring.settings.SettingsFragment; -import cx.ring.settings.VideoSettingsFragment; -import cx.ring.settings.pluginssettings.PluginDetails; -import cx.ring.settings.pluginssettings.PluginPathPreferenceFragment; -import cx.ring.settings.pluginssettings.PluginSettingsFragment; -import cx.ring.settings.pluginssettings.PluginsListSettingsFragment; -import cx.ring.utils.ContentUriHandler; -import cx.ring.utils.ConversationPath; -import cx.ring.utils.DeviceUtils; -import cx.ring.views.SwitchButton; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class HomeActivity extends AppCompatActivity implements BottomNavigationView.OnNavigationItemSelectedListener, - Spinner.OnItemSelectedListener, Colorable { - static final String TAG = HomeActivity.class.getSimpleName(); - - public static final int REQUEST_CODE_CALL = 3; - public static final int REQUEST_CODE_CONVERSATION = 4; - public static final int REQUEST_CODE_PHOTO = 5; - public static final int REQUEST_CODE_GALLERY = 6; - public static final int REQUEST_CODE_QR_CONVERSATION = 7; - public static final int REQUEST_PERMISSION_CAMERA = 113; - public static final int REQUEST_PERMISSION_READ_STORAGE = 114; - - private static final int NAVIGATION_CONTACT_REQUESTS = 0; - private static final int NAVIGATION_CONVERSATIONS = 1; - private static final int NAVIGATION_ACCOUNT = 2; - - public static final String HOME_TAG = "Home"; - public static final String CONTACT_REQUESTS_TAG = "Trust request"; - public static final String ACCOUNTS_TAG = "Accounts"; - public static final String ABOUT_TAG = "About"; - public static final String SETTINGS_TAG = "Prefs"; - public static final String VIDEO_SETTINGS_TAG = "VideoPrefs"; - - public static final String ACTION_PRESENT_TRUST_REQUEST_FRAGMENT = BuildConfig.APPLICATION_ID + "presentTrustRequestFragment"; - - public static final String PLUGINS_LIST_SETTINGS_TAG = "PluginsListSettings"; - public static final String PLUGIN_SETTINGS_TAG = "PluginSettings"; - public static final String PLUGIN_PATH_PREFERENCE_TAG = "PluginPathPreference"; - - private static final String CONVERSATIONS_CATEGORY = "conversations"; - - protected Fragment fContent; - protected ConversationFragment fConversation; - - private AccountSpinnerAdapter mAccountAdapter; - private BackHandlerInterface mAccountFragmentBackHandlerInterface; - - private ViewOutlineProvider mOutlineProvider; - - private int mOrientation; - - @Inject - ContactService mContactService; - @Inject - AccountService mAccountService; - @Inject - ConversationFacade mConversationFacade; - @Inject - NotificationService mNotificationService; - - private ActivityHomeBinding mBinding; - - private AlertDialog mMigrationDialog; - //private String mAccountWithPendingrequests = null; - - private final CompositeDisposable mDisposable = new CompositeDisposable(); - - /* called before activity is killed, e.g. rotation */ - @Override - protected void onSaveInstanceState(@NonNull Bundle bundle) { - super.onSaveInstanceState(bundle); - bundle.putInt("orientation", mOrientation); - } - - @Override - protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - mOrientation = savedInstanceState.getInt("orientation"); - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - JamiApplication.getInstance().startDaemon(); - - mBinding = ActivityHomeBinding.inflate(getLayoutInflater()); - setContentView(mBinding.getRoot()); - - // dependency injection - JamiApplication.getInstance().getInjectionComponent().inject(this); - - setSupportActionBar(mBinding.mainToolbar); - ActionBar ab = getSupportActionBar(); - if (ab != null) { - ab.setTitle(""); - } - - mBinding.navigationView.setOnNavigationItemSelectedListener(this); - mBinding.navigationView.getMenu().getItem(NAVIGATION_CONVERSATIONS).setChecked(true); - - mOutlineProvider = mBinding.appBar.getOutlineProvider(); - - if (!DeviceUtils.isTablet(this) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - getWindow().setNavigationBarColor(ContextCompat.getColor(this, R.color.bottom_navigation)); - } - - mBinding.spinnerToolbar.setOnItemSelectedListener(this); - mBinding.accountSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> enableAccount(isChecked)); - - if (mBinding.contactImage != null) { - mBinding.contactImage.setOnClickListener(v -> { - if (fConversation != null) { - fConversation.openContact(); - } - }); - } - - handleIntent(getIntent()); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (mMigrationDialog != null) { - if (mMigrationDialog.isShowing()) - mMigrationDialog.dismiss(); - mMigrationDialog = null; - } - fContent = null; - mDisposable.dispose(); - mBinding = null; - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - handleIntent(intent); - } - - private void handleIntent(Intent intent) { - Log.d(TAG, "handleIntent: " + intent); - Bundle extra = intent.getExtras(); - String action = intent.getAction(); - if (ACTION_PRESENT_TRUST_REQUEST_FRAGMENT.equals(action)) { - if (extra == null || extra.getString(ContactRequestsFragment.ACCOUNT_ID) == null) { - return; - } - //mAccountWithPendingrequests = extra.getString(ContactRequestsFragment.ACCOUNT_ID); - presentTrustRequestFragment(extra.getString(ContactRequestsFragment.ACCOUNT_ID)); - } else if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { - ConversationPath path = ConversationPath.fromBundle(extra); - if (path != null) { - startConversation(path); - } else { - intent.setClass(getApplicationContext(), ShareActivity.class); - startActivity(intent); - } - } else if (DRingService.ACTION_CONV_ACCEPT.equals(action) || Intent.ACTION_VIEW.equals(action)) { - startConversation(ConversationPath.fromIntent(intent)); - } //else { - FragmentManager fragmentManager = getSupportFragmentManager(); - fContent = fragmentManager.findFragmentById(R.id.main_frame); - if (fContent == null || Intent.ACTION_SEARCH.equals(action)) { - if (fContent instanceof SmartListFragment) { - ((SmartListFragment)fContent).handleIntent(intent); - } else { - fContent = new SmartListFragment(); - fragmentManager.beginTransaction() - .replace(R.id.main_frame, fContent, HOME_TAG) - .commitNow(); - } - } - /*if (mAccountWithPendingrequests != null) { - presentTrustRequestFragment(mAccountWithPendingrequests); - mAccountWithPendingrequests = null; - }*/ - //} - } - - private void showMigrationDialog() { - if (mMigrationDialog != null) { - return; - } - mMigrationDialog = new MaterialAlertDialogBuilder(this) - .setTitle(R.string.account_migration_title_dialog) - .setMessage(R.string.account_migration_message_dialog) - .setIcon(R.drawable.baseline_warning_24) - .setPositiveButton(android.R.string.ok, (dialog, which) -> selectNavigationItem(R.id.navigation_settings)) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - public void setToolbarState(@StringRes int titleRes) { - setToolbarState(getString(titleRes) , null); - } - - public void setToolbarState(String title, String subtitle) { - mBinding.mainToolbar.setLogo(null); - mBinding.mainToolbar.setTitle(title); - mBinding.mainToolbar.setSubtitle(subtitle); - } - - private void showProfileInfo() { - mBinding.spinnerToolbar.setVisibility(View.VISIBLE); - mBinding.mainToolbar.setTitle(null); - mBinding.mainToolbar.setSubtitle(null); - } - - @Override - protected void onStart() { - super.onStart(); - mDisposable.add(mAccountService.getObservableAccountList() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(accounts -> { - if (accounts.isEmpty()) { - startActivity(new Intent(this, AccountWizardActivity.class)); - } - for (Account account : accounts) { - if (account.needsMigration()) { - showMigrationDialog(); - break; - } - } - })); - - mDisposable.add(mAccountService.getObservableAccountList() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(accounts -> { - if (mAccountAdapter == null) { - mAccountAdapter = new AccountSpinnerAdapter(HomeActivity.this, new ArrayList<>(accounts)); - mAccountAdapter.setNotifyOnChange(false); - mBinding.spinnerToolbar.setAdapter(mAccountAdapter); - } else { - mAccountAdapter.clear(); - mAccountAdapter.addAll(accounts); - mAccountAdapter.notifyDataSetChanged(); - if (accounts.size() > 0) { - mBinding.spinnerToolbar.setSelection(0); - } - } - if (fContent instanceof SmartListFragment) { - showProfileInfo(); - } - }, e -> Log.e(TAG, "Error loading account list !", e))); - - mDisposable.add((mAccountService - .getCurrentAccountSubject() - .switchMap(Account::getUnreadPending) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(count -> setBadge(R.id.navigation_requests, count)))); - - mDisposable.add((mAccountService - .getCurrentAccountSubject() - .switchMap(Account::getUnreadConversations) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(count -> setBadge(R.id.navigation_home, count)))); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - mDisposable.add((mAccountService - .getCurrentAccountSubject() - .flatMap(Account::getConversationsSubject) - .observeOn(Schedulers.io()) - .subscribe(this::setShareShortcuts, e -> Log.e(TAG, "Error generating conversation shortcuts", e)))); - } - - if (fConversation == null) - fConversation = (ConversationFragment) getSupportFragmentManager().findFragmentByTag(ConversationFragment.class.getSimpleName()); - - int newOrientation = getResources().getConfiguration().orientation; - if (mOrientation != newOrientation) { - mOrientation = newOrientation; - hideTabletToolbar(); - if (DeviceUtils.isTablet(this)) { - selectNavigationItem(R.id.navigation_home); - showTabletToolbar(); - } else { - // Remove ConversationFragment that might have been restored after an orientation change - if (fConversation != null) { - getSupportFragmentManager() - .beginTransaction() - .remove(fConversation) - .commitNow(); - fConversation = null; - } - } - } - - // Select first conversation in tablet mode - if (DeviceUtils.isTablet(this)) { - Intent intent = getIntent(); - Uri uri = intent == null ? null : intent.getData(); - if ((intent == null || uri == null) && fConversation == null) { - Observable<List<Observable<SmartListViewModel>>> smartlist = null; - if (fContent instanceof SmartListFragment) - smartlist = mConversationFacade.getSmartList(false); - else if (fContent instanceof ContactRequestsFragment) - smartlist = mConversationFacade.getPendingList(); - - if (smartlist != null) { - mDisposable.add(smartlist - .filter(list -> !list.isEmpty()) - .map(list -> list.get(0).firstOrError()) - .firstElement() - .flatMapSingle(e -> e) - .subscribe(element -> startConversation(element.getAccountId(), element.getUri()))); - } - } - } - } - - @Override - protected void onStop() { - super.onStop(); - mDisposable.clear(); - } - - public void startConversation(String conversationId) { - mDisposable.add(mAccountService.getCurrentAccountSubject() - .firstElement() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(account -> startConversation(account.getAccountID(), net.jami.model.Uri.fromString(conversationId)))); - } - public void startConversation(String accountId, net.jami.model.Uri conversationId) { - startConversation(new ConversationPath(accountId, conversationId)); - } - public void startConversation(ConversationPath path) { - Log.w(TAG, "startConversation " + path); - if (!DeviceUtils.isTablet(this)) { - startActivity(new Intent(Intent.ACTION_VIEW, path.toUri(), this, ConversationActivity.class)); - } else { - startConversationTablet(path.toBundle()); - } - } - - public void startConversationTablet(Bundle bundle) { - fConversation = new ConversationFragment(); - fConversation.setArguments(bundle); - - if (!(fContent instanceof ContactRequestsFragment)) { - selectNavigationItem(R.id.navigation_home); - } - - showTabletToolbar(); - - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.conversation_container, fConversation, ConversationFragment.class.getSimpleName()) - .commit(); - } - - private void presentTrustRequestFragment(String accountID) { - mNotificationService.cancelTrustRequestNotification(accountID); - if (fContent instanceof ContactRequestsFragment) { - ((ContactRequestsFragment) fContent).presentForAccount(accountID); - return; - } - Bundle bundle = new Bundle(); - bundle.putString(ContactRequestsFragment.ACCOUNT_ID, accountID); - fContent = new ContactRequestsFragment(); - fContent.setArguments(bundle); - mBinding.navigationView.getMenu().getItem(NAVIGATION_CONTACT_REQUESTS).setChecked(true); - getSupportFragmentManager().beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(R.id.main_frame, fContent, CONTACT_REQUESTS_TAG) - .addToBackStack(CONTACT_REQUESTS_TAG).commit(); - } - - @Override - public void onBackPressed() { - if (mAccountFragmentBackHandlerInterface != null && mAccountFragmentBackHandlerInterface.onBackPressed()) { - return; - } - super.onBackPressed(); - fContent = getSupportFragmentManager().findFragmentById(R.id.main_frame); - if (fContent instanceof SmartListFragment) { - mBinding.navigationView.getMenu().getItem(NAVIGATION_CONVERSATIONS).setChecked(true); - //showProfileInfo(); - showToolbarSpinner(); - hideTabletToolbar(); - } - } - - private void popCustomBackStack() { - FragmentManager fragmentManager = getSupportFragmentManager(); - int entryCount = fragmentManager.getBackStackEntryCount(); - for (int i = 0; i < entryCount; ++i) { - fragmentManager.popBackStack(); - } - //fContent = fragmentManager.findFragmentById(R.id.main_frame); - hideTabletToolbar(); - setToolbarElevation(false); - } - - public void goToSettings() { - if (fContent instanceof SettingsFragment) { - return; - } - popCustomBackStack(); - hideToolbarSpinner(); - fContent = new SettingsFragment(); - getSupportFragmentManager() - .beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(getFragmentContainerId(), fContent, SETTINGS_TAG) - .addToBackStack(SETTINGS_TAG).commit(); - } - - public void goToAbout() { - if (fContent instanceof AboutFragment) { - return; - } - popCustomBackStack(); - hideToolbarSpinner(); - fContent = new AboutFragment(); - getSupportFragmentManager() - .beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(getFragmentContainerId(), fContent, ABOUT_TAG) - .addToBackStack(ABOUT_TAG).commit(); - } - - public void goToVideoSettings() { - if (fContent instanceof VideoSettingsFragment) { - return; - } - fContent = new VideoSettingsFragment(); - getSupportFragmentManager() - .beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(getFragmentContainerId(), fContent, VIDEO_SETTINGS_TAG) - .addToBackStack(VIDEO_SETTINGS_TAG).commit(); - } - - @Override - public boolean onNavigationItemSelected(@NonNull MenuItem item) { - Account account = mAccountService.getCurrentAccount(); - if (account == null) - return false; - - Bundle bundle = new Bundle(); - int itemId = item.getItemId(); - if (itemId == R.id.navigation_requests) { - if (fContent instanceof ContactRequestsFragment) { - ((ContactRequestsFragment) fContent).presentForAccount(null); - return true; - } - popCustomBackStack(); - fContent = new ContactRequestsFragment(); - getSupportFragmentManager().beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(R.id.main_frame, fContent, CONTACT_REQUESTS_TAG) - .setReorderingAllowed(true) - .addToBackStack(CONTACT_REQUESTS_TAG) - .commit(); - //showProfileInfo(); - showToolbarSpinner(); - } else if (itemId == R.id.navigation_home) { - if (fContent instanceof SmartListFragment) { - return true; - } - popCustomBackStack(); - fContent = new SmartListFragment(); - getSupportFragmentManager().beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(R.id.main_frame, fContent, HOME_TAG) - .setReorderingAllowed(true) - .commit(); - //showProfileInfo(); - showToolbarSpinner(); - } else if (itemId == R.id.navigation_settings) { - if (account.needsMigration()) { - Log.d(TAG, "launchAccountMigrationActivity: Launch account migration activity"); - - Intent intent = new Intent() - .setClass(this, AccountWizardActivity.class) - .setData(Uri.withAppendedPath(ContentUriHandler.ACCOUNTS_CONTENT_URI, account.getAccountID())); - startActivityForResult(intent, 1); - } else { - Log.d(TAG, "launchAccountEditFragment: Launch account edit fragment"); - bundle.putString(AccountEditionFragment.ACCOUNT_ID, account.getAccountID()); - - if (fContent instanceof AccountEditionFragment) { - return true; - } - popCustomBackStack(); - fContent = new AccountEditionFragment(); - fContent.setArguments(bundle); - getSupportFragmentManager().beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(getFragmentContainerId(), fContent, ACCOUNTS_TAG) - .addToBackStack(ACCOUNTS_TAG) - .commit(); - //showProfileInfo(); - showToolbarSpinner(); - } - } - - return true; - } - - @Override - public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { - int type = mAccountAdapter.getItemViewType(position); - if (type == AccountSpinnerAdapter.TYPE_ACCOUNT) { - Account account = mAccountAdapter.getItem(position); - mAccountService.setCurrentAccount(account); - showAccountStatus(fContent instanceof AccountEditionFragment && !account.isSip()); - } else { - Intent intent = new Intent(HomeActivity.this, AccountWizardActivity.class); - if (type == AccountSpinnerAdapter.TYPE_CREATE_SIP) { - intent.setAction(AccountConfig.ACCOUNT_TYPE_SIP); - } - startActivity(intent); - mBinding.spinnerToolbar.setSelection(mAccountService.getCurrentAccountIndex()); - } - } - - @Override - public void onNothingSelected(AdapterView<?> parent) { - - } - - public void setBadge(int menuId, int number) { - if (number == 0) - mBinding.navigationView.removeBadge(menuId); - else - mBinding.navigationView.getOrCreateBadge(menuId).setNumber(number); - } - - private void hideTabletToolbar() { - if (mBinding != null && mBinding.tabletToolbar != null) { - mBinding.contactTitle.setText(null); - mBinding.contactSubtitle.setText(null); - mBinding.contactImage.setImageDrawable(null); - mBinding.tabletToolbar.setVisibility(View.GONE); - } - } - - private void showTabletToolbar() { - if (mBinding != null && mBinding.tabletToolbar != null && DeviceUtils.isTablet(this)) { - mBinding.tabletToolbar.setVisibility(View.VISIBLE); - } - } - - public void setTabletTitle(@StringRes int titleRes) { - if (mBinding.tabletToolbar != null) { - mBinding.tabletToolbar.setVisibility(View.VISIBLE); - mBinding.contactTitle.setText(titleRes); - mBinding.contactTitle.setTextSize(19); - mBinding.contactTitle.setTypeface(null, Typeface.BOLD); - mBinding.contactImage.setVisibility(View.GONE); - } - /*RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.contactTitle.getLayoutParams(); - params.removeRule(RelativeLayout.ALIGN_TOP); - params.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE); - binding.contactTitle.setLayoutParams(params);*/ - } - - public void setToolbarTitle(@StringRes int titleRes) { - if (DeviceUtils.isTablet(this)) { - setTabletTitle(titleRes); - } else { - setToolbarState(titleRes); - } - } - - public void showAccountStatus(boolean show){ - mBinding.accountSwitch.setVisibility(show? View.VISIBLE : View.GONE); - } - - private void showToolbarSpinner() { - mBinding.spinnerToolbar.setVisibility(View.VISIBLE); - } - - private void hideToolbarSpinner() { - if (mBinding != null && !DeviceUtils.isTablet(this)) { - mBinding.spinnerToolbar.setVisibility(View.GONE); - } - } - - private int getFragmentContainerId() { - if (DeviceUtils.isTablet(HomeActivity.this)) { - return R.id.conversation_container; - } - - return R.id.main_frame; - } - - public void setAccountFragmentOnBackPressedListener(BackHandlerInterface backPressedListener) { - mAccountFragmentBackHandlerInterface = backPressedListener; - } - - /** - * Changes the current main fragment to a plugins list settings fragment - */ - public void goToPluginsListSettings() { - if (fContent instanceof PluginsListSettingsFragment) { - return; - } - - fContent = new PluginsListSettingsFragment(); - getSupportFragmentManager() - .beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(getFragmentContainerId(), fContent, PLUGINS_LIST_SETTINGS_TAG) - .addToBackStack(PLUGINS_LIST_SETTINGS_TAG).commit(); - } - - /** - * Changes the current main fragment to a plugin settings fragment - * @param pluginDetails - */ - public void gotToPluginSettings(PluginDetails pluginDetails){ - if (fContent instanceof PluginSettingsFragment) { - return; - } - fContent = PluginSettingsFragment.newInstance(pluginDetails); - getSupportFragmentManager() - .beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(getFragmentContainerId(), fContent, PLUGIN_SETTINGS_TAG) - .addToBackStack(PLUGIN_SETTINGS_TAG).commit(); - } - - /** - * Changes the current main fragment to a plugin PATH preference fragment - */ - public void gotToPluginPathPreference(PluginDetails pluginDetails, String preferenceKey){ - if (fContent instanceof PluginPathPreferenceFragment) { - return; - } - fContent = PluginPathPreferenceFragment.newInstance(pluginDetails, preferenceKey); - getSupportFragmentManager() - .beginTransaction() - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(getFragmentContainerId(), fContent, PLUGIN_PATH_PREFERENCE_TAG) - .addToBackStack(PLUGIN_PATH_PREFERENCE_TAG).commit(); - } - - @Override - public void setColor(int color) { - //mToolbar.setBackground(new ColorDrawable(color)); - } - - public void setToolbarElevation(boolean enable) { - if (mBinding != null) - mBinding.appBar.setElevation(enable ? getResources().getDimension(R.dimen.toolbar_elevation) : 0); - } - - public void setToolbarOutlineState(boolean enabled) { - if (mBinding != null) { - if (!enabled) { - mBinding.appBar.setOutlineProvider(null); - } else { - mBinding.appBar.setOutlineProvider(mOutlineProvider); - } - } - } - - public void popFragmentImmediate() { - FragmentManager fm = getSupportFragmentManager(); - fm.popBackStackImmediate(); - FragmentManager.BackStackEntry entry = fm.getBackStackEntryAt(fm.getBackStackEntryCount()-1); - fContent = fm.findFragmentById(entry.getId()); - } - - public void selectNavigationItem(int id) { - if (mBinding != null) - mBinding.navigationView.setSelectedItemId(id); - } - - public void enableAccount(boolean newValue) { - Account account = mAccountService.getCurrentAccount(); - if (account == null) { - Log.w(TAG, "account not found!"); - return; - } - - account.setEnabled(newValue); - mAccountService.setAccountEnabled(account.getAccountID(), newValue); - } - - public SwitchButton getSwitchButton() { - return mBinding.accountSwitch; - } - - private void setShareShortcuts(Collection<Conversation> conversations) { - int targetSize = (int) (AvatarFactory.SIZE_NOTIF * getResources().getDisplayMetrics().density); - int i = 0; - int maxCount = ShortcutManagerCompat.getMaxShortcutCountPerActivity(this); - if (maxCount == 0) - maxCount = 4; - - List<Future<Bitmap>> futureIcons = new ArrayList<>(Math.min(conversations.size(), maxCount)); - for (Conversation conversation : conversations) { - futureIcons.add(((ContactServiceImpl)mContactService).loadConversationAvatar(this, conversation) - .map(d -> BitmapUtils.drawableToBitmap(d, targetSize)) - .subscribeOn(Schedulers.computation()) - .toFuture()); - if (++i == maxCount) - break; - } - List<ShortcutInfoCompat> shortcutInfoList = new ArrayList<>(futureIcons.size()); - - i = 0; - for (Conversation conversation : conversations) { - IconCompat icon = null; - try { - icon = IconCompat.createWithBitmap(futureIcons.get(i).get()); - } catch (Exception e) { - Log.w(TAG, "Failed to load icon", e); - } - - ConversationPath path = new ConversationPath(conversation); - String key = path.toKey(); - - Person person = new Person.Builder() - .setName(conversation.getTitle()) - .setKey(key) - .build(); - - ShortcutInfoCompat shortcutInfo = new ShortcutInfoCompat.Builder(this, key) - .setShortLabel(conversation.getTitle()) - .setPerson(person) - .setLongLived(true) - .setIcon(icon) - .setCategories(Collections.singleton(CONVERSATIONS_CATEGORY)) - .setIntent(new Intent(Intent.ACTION_SEND, Uri.EMPTY, this, HomeActivity.class) - .putExtras(path.toBundle())) - .build(); - - shortcutInfoList.add(shortcutInfo); - if (++i == maxCount) - break; - } - - try { - Log.d(TAG, "Adding shortcuts: " + shortcutInfoList.size()); - ShortcutManagerCompat.removeAllDynamicShortcuts(this); - ShortcutManagerCompat.addDynamicShortcuts(this, shortcutInfoList); - } catch (Exception e) { - Log.w(TAG, "Error adding shortcuts", e); - } - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/client/HomeActivity.kt b/ring-android/app/src/main/java/cx/ring/client/HomeActivity.kt new file mode 100644 index 000000000..40643d7b6 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/HomeActivity.kt @@ -0,0 +1,792 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> + * Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.client + +import android.content.DialogInterface +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Typeface +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.MenuItem +import android.view.View +import android.view.ViewOutlineProvider +import android.widget.AdapterView +import android.widget.CompoundButton +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.Person +import androidx.core.content.ContextCompat +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.navigation.NavigationBarView +import cx.ring.BuildConfig +import cx.ring.R +import cx.ring.about.AboutFragment +import cx.ring.account.AccountEditionFragment +import cx.ring.account.AccountWizardActivity +import cx.ring.application.JamiApplication +import cx.ring.contactrequests.ContactRequestsFragment +import cx.ring.databinding.ActivityHomeBinding +import cx.ring.fragments.ConversationFragment +import cx.ring.fragments.SmartListFragment +import cx.ring.interfaces.BackHandlerInterface +import cx.ring.interfaces.Colorable +import cx.ring.service.DRingService +import cx.ring.services.ContactServiceImpl +import cx.ring.settings.SettingsFragment +import cx.ring.settings.VideoSettingsFragment +import cx.ring.settings.pluginssettings.PluginDetails +import cx.ring.settings.pluginssettings.PluginPathPreferenceFragment +import cx.ring.settings.pluginssettings.PluginSettingsFragment +import cx.ring.settings.pluginssettings.PluginsListSettingsFragment +import cx.ring.utils.BitmapUtils +import cx.ring.utils.ContentUriHandler +import cx.ring.utils.ConversationPath +import cx.ring.utils.DeviceUtils +import cx.ring.views.AvatarFactory +import cx.ring.views.SwitchButton +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.services.ConversationFacade +import net.jami.model.Account +import net.jami.model.AccountConfig +import net.jami.model.Conversation +import net.jami.model.Uri +import net.jami.services.AccountService +import net.jami.services.ContactService +import net.jami.services.NotificationService +import net.jami.smartlist.SmartListViewModel +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@AndroidEntryPoint +class HomeActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener, + AdapterView.OnItemSelectedListener, Colorable { + private var fContent: Fragment? = null + private var fConversation: ConversationFragment? = null + private var mAccountAdapter: AccountSpinnerAdapter? = null + private var mAccountFragmentBackHandlerInterface: BackHandlerInterface? = null + private var mOutlineProvider: ViewOutlineProvider? = null + private var mOrientation = 0 + + @Inject lateinit + var mContactService: ContactService + + @Inject lateinit + var mAccountService: AccountService + + @Inject lateinit + var mConversationFacade: ConversationFacade + + @Inject lateinit + var mNotificationService: NotificationService + + private var mBinding: ActivityHomeBinding? = null + private var mMigrationDialog: AlertDialog? = null + + //private String mAccountWithPendingrequests = null; + private val mDisposable = CompositeDisposable() + + /* called before activity is killed, e.g. rotation */ + override fun onSaveInstanceState(bundle: Bundle) { + super.onSaveInstanceState(bundle) + bundle.putInt("orientation", mOrientation) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + mOrientation = savedInstanceState.getInt("orientation") + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + JamiApplication.instance?.startDaemon() + mBinding = ActivityHomeBinding.inflate(layoutInflater).also { binding -> + setContentView(binding.root) + setSupportActionBar(binding.mainToolbar) + supportActionBar?.title = "" + binding.navigationView.setOnItemSelectedListener(this) + binding.navigationView.menu.getItem(NAVIGATION_CONVERSATIONS).isChecked = true + mOutlineProvider = binding.appBar.outlineProvider + binding.spinnerToolbar.onItemSelectedListener = this + binding.accountSwitch.setOnCheckedChangeListener { _: CompoundButton, isChecked: Boolean -> + enableAccount(isChecked) + } + binding.contactImage?.setOnClickListener { fConversation?.openContact() } + } + if (!DeviceUtils.isTablet(this) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + window.navigationBarColor = ContextCompat.getColor(this, R.color.bottom_navigation) + } + handleIntent(intent) + } + + override fun onDestroy() { + super.onDestroy() + mMigrationDialog?.apply { + if (isShowing) dismiss() + mMigrationDialog = null + } + fContent = null + mDisposable.dispose() + mBinding = null + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent) { + Log.d(TAG, "handleIntent: $intent") + val extra = intent.extras + val action = intent.action + if (ACTION_PRESENT_TRUST_REQUEST_FRAGMENT == action) { + if (extra?.getString(ContactRequestsFragment.ACCOUNT_ID) == null) { + return + } + //mAccountWithPendingrequests = extra.getString(ContactRequestsFragment.ACCOUNT_ID); + presentTrustRequestFragment(extra.getString(ContactRequestsFragment.ACCOUNT_ID)) + } else if (Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action) { + val path = ConversationPath.fromBundle(extra) + if (path != null) { + startConversation(path) + } else { + intent.setClass(applicationContext, ShareActivity::class.java) + startActivity(intent) + } + } else if (DRingService.ACTION_CONV_ACCEPT == action || Intent.ACTION_VIEW == action) { + startConversation(ConversationPath.fromIntent(intent)!!) + } //else { + val fragmentManager = supportFragmentManager + fContent = fragmentManager.findFragmentById(R.id.main_frame) + if (fContent == null || Intent.ACTION_SEARCH == action) { + if (fContent is SmartListFragment) { + (fContent as SmartListFragment).handleIntent(intent) + } else { + val content = SmartListFragment() + fContent = content + fragmentManager.beginTransaction() + .replace(R.id.main_frame, content, HOME_TAG) + .commitNow() + } + } + /*if (mAccountWithPendingrequests != null) { + presentTrustRequestFragment(mAccountWithPendingrequests); + mAccountWithPendingrequests = null; + }*/ + //} + } + + private fun showMigrationDialog() { + if (mMigrationDialog != null) { + return + } + mMigrationDialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.account_migration_title_dialog) + .setMessage(R.string.account_migration_message_dialog) + .setIcon(R.drawable.baseline_warning_24) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + selectNavigationItem(R.id.navigation_settings) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun setToolbarState(@StringRes titleRes: Int) { + setToolbarState(getString(titleRes), null) + } + + private fun setToolbarState(title: String?, subtitle: String?) { + mBinding?.mainToolbar?.let { toolbar -> + toolbar.logo = null + toolbar.title = title + toolbar.subtitle = subtitle + } + } + + private fun showProfileInfo() { + mBinding?.apply { + spinnerToolbar.visibility = View.VISIBLE + mainToolbar.title = null + mainToolbar.subtitle = null + } + } + + override fun onStart() { + super.onStart() + mDisposable.add( + mAccountService.observableAccountList + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { accounts: List<Account> -> + if (accounts.isEmpty()) { + startActivity(Intent(this, AccountWizardActivity::class.java)) + } + for (account in accounts) { + if (account.needsMigration()) { + showMigrationDialog() + break + } + } + }) + mDisposable.add( + mAccountService.observableAccountList + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ accounts -> + if (mAccountAdapter == null) { + mAccountAdapter = AccountSpinnerAdapter(this@HomeActivity, ArrayList(accounts)).apply { + setNotifyOnChange(false) + mBinding?.spinnerToolbar?.adapter = this + } + } else { + mAccountAdapter!!.clear() + mAccountAdapter!!.addAll(accounts) + mAccountAdapter!!.notifyDataSetChanged() + if (accounts.isNotEmpty()) { + mBinding!!.spinnerToolbar.setSelection(0) + } + } + if (fContent is SmartListFragment) { + showProfileInfo() + } + }) { e -> Log.e(TAG, "Error loading account list !", e) }) + mDisposable.add(mAccountService + .currentAccountSubject + .switchMap { obj -> obj.unreadPending } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { count -> setBadge(R.id.navigation_requests, count) }) + mDisposable.add(mAccountService + .currentAccountSubject + .switchMap { obj -> obj.unreadConversations } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { count -> setBadge(R.id.navigation_home, count) }) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mDisposable.add(mAccountService + .currentAccountSubject + .switchMap { obj -> obj.getConversationsSubject() } + .debounce(10, TimeUnit.SECONDS) + .observeOn(Schedulers.io()) + .subscribe({ conversations -> setShareShortcuts(conversations) }) + { e -> Log.e(TAG, "Error generating conversation shortcuts", e) }) + } + if (fConversation == null) + fConversation = supportFragmentManager.findFragmentByTag(ConversationFragment::class.java.simpleName) as ConversationFragment? + val newOrientation = resources.configuration.orientation + if (mOrientation != newOrientation) { + mOrientation = newOrientation + hideTabletToolbar() + if (DeviceUtils.isTablet(this)) { + selectNavigationItem(R.id.navigation_home) + showTabletToolbar() + } else { + // Remove ConversationFragment that might have been restored after an orientation change + if (fConversation != null) { + supportFragmentManager + .beginTransaction() + .remove(fConversation!!) + .commitNow() + fConversation = null + } + } + } + + // Select first conversation in tablet mode + if (DeviceUtils.isTablet(this)) { + val intent = intent + val uri = intent?.data + if ((intent == null || uri == null) && fConversation == null) { + var smartlist: Observable<List<Observable<SmartListViewModel>>>? = null + if (fContent is SmartListFragment) smartlist = + mConversationFacade.getSmartList(false) else if (fContent is ContactRequestsFragment) smartlist = + mConversationFacade.pendingList + if (smartlist != null) { + mDisposable.add(smartlist + .filter { list -> list.isNotEmpty() } + .map { list -> list[0].firstOrError() } + .firstElement() + .flatMapSingle { e -> e } + .subscribe { element -> startConversation(element.accountId, element.uri) }) + } + } + } + } + + override fun onStop() { + super.onStop() + mDisposable.clear() + } + + fun startConversation(conversationId: String) { + mDisposable.add(mAccountService.currentAccountSubject + .firstElement() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { account -> startConversation(account.accountID, Uri.fromString(conversationId)) }) + } + + fun startConversation(accountId: String, conversationId: Uri) { + startConversation(ConversationPath(accountId, conversationId)) + } + + private fun startConversation(path: ConversationPath) { + Log.w(TAG, "startConversation $path") + if (!DeviceUtils.isTablet(this)) { + startActivity(Intent(Intent.ACTION_VIEW, path.toUri(), this, ConversationActivity::class.java)) + } else { + startConversationTablet(path.toBundle()) + } + } + + private fun startConversationTablet(bundle: Bundle?) { + fConversation = ConversationFragment() + fConversation!!.arguments = bundle + if (fContent !is ContactRequestsFragment) { + selectNavigationItem(R.id.navigation_home) + } + showTabletToolbar() + supportFragmentManager.beginTransaction() + .replace(R.id.conversation_container, fConversation!!, ConversationFragment::class.java.simpleName) + .commit() + } + + private fun presentTrustRequestFragment(accountID: String?) { + mNotificationService.cancelTrustRequestNotification(accountID) + if (fContent is ContactRequestsFragment) { + (fContent as ContactRequestsFragment).presentForAccount(accountID) + return + } + val content = ContactRequestsFragment().apply { + arguments = Bundle().apply { putString(ContactRequestsFragment.ACCOUNT_ID, accountID) } + } + fContent = content + mBinding!!.navigationView.menu.getItem(NAVIGATION_CONTACT_REQUESTS).isChecked = true + supportFragmentManager.beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(R.id.main_frame, content, CONTACT_REQUESTS_TAG) + .addToBackStack(CONTACT_REQUESTS_TAG).commit() + } + + override fun onBackPressed() { + if (mAccountFragmentBackHandlerInterface != null && mAccountFragmentBackHandlerInterface!!.onBackPressed()) { + return + } + super.onBackPressed() + fContent = supportFragmentManager.findFragmentById(R.id.main_frame) + if (fContent is SmartListFragment) { + mBinding!!.navigationView.menu.getItem(NAVIGATION_CONVERSATIONS).isChecked = + true + //showProfileInfo(); + showToolbarSpinner() + hideTabletToolbar() + } + } + + private fun popCustomBackStack() { + val fragmentManager = supportFragmentManager + val entryCount = fragmentManager.backStackEntryCount + for (i in 0 until entryCount) { + fragmentManager.popBackStackImmediate() + } + //fContent = fragmentManager.findFragmentById(R.id.main_frame); + hideTabletToolbar() + setToolbarElevation(false) + } + + fun goToSettings() { + if (fContent is SettingsFragment) { + return + } + popCustomBackStack() + hideToolbarSpinner() + val content = SettingsFragment() + fContent = content + supportFragmentManager + .beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(fragmentContainerId, content, SETTINGS_TAG) + .addToBackStack(SETTINGS_TAG).commit() + } + + fun goToAbout() { + if (fContent is AboutFragment) { + return + } + popCustomBackStack() + hideToolbarSpinner() + val content = AboutFragment() + fContent = content + supportFragmentManager + .beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(fragmentContainerId, content, ABOUT_TAG) + .addToBackStack(ABOUT_TAG).commit() + } + + fun goToVideoSettings() { + if (fContent is VideoSettingsFragment) { + return + } + val content = VideoSettingsFragment() + fContent = content + supportFragmentManager + .beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(fragmentContainerId, content, VIDEO_SETTINGS_TAG) + .addToBackStack(VIDEO_SETTINGS_TAG).commit() + } + + override fun onNavigationItemSelected(item: MenuItem): Boolean { + val account = mAccountService.currentAccount ?: return false + val bundle = Bundle() + val itemId = item.itemId + if (itemId == R.id.navigation_requests) { + if (fContent is ContactRequestsFragment) { + (fContent as ContactRequestsFragment).presentForAccount(null) + return true + } + popCustomBackStack() + val content = ContactRequestsFragment() + fContent = content + supportFragmentManager.beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(R.id.main_frame, content, CONTACT_REQUESTS_TAG) + .setReorderingAllowed(true) + .addToBackStack(CONTACT_REQUESTS_TAG) + .commit() + //showProfileInfo(); + showToolbarSpinner() + } else if (itemId == R.id.navigation_home) { + if (fContent is SmartListFragment) { + return true + } + popCustomBackStack() + val fcontent = supportFragmentManager.findFragmentById(R.id.main_frame) + if (fcontent is SmartListFragment) { + fContent = fcontent + return true + } + val content = SmartListFragment() + fContent = content + supportFragmentManager.beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(R.id.main_frame, content, HOME_TAG) + .setReorderingAllowed(true) + .commit() + //showProfileInfo(); + showToolbarSpinner() + } else if (itemId == R.id.navigation_settings) { + if (account.needsMigration()) { + Log.d(TAG, "launchAccountMigrationActivity: Launch account migration activity") + val intent = Intent() + .setClass(this, AccountWizardActivity::class.java) + .setData(android.net.Uri.withAppendedPath(ContentUriHandler.ACCOUNTS_CONTENT_URI, account.accountID)) + startActivityForResult(intent, 1) + } else { + Log.d(TAG, "launchAccountEditFragment: Launch account edit fragment") + bundle.putString(AccountEditionFragment.ACCOUNT_ID_KEY, account.accountID) + if (fContent is AccountEditionFragment) { + return true + } + popCustomBackStack() + val content = AccountEditionFragment() + content.arguments = bundle + fContent = content + supportFragmentManager.beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(fragmentContainerId, content, ACCOUNTS_TAG) + .addToBackStack(ACCOUNTS_TAG) + .commit() + //showProfileInfo(); + showToolbarSpinner() + } + } + return true + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val adapter = mAccountAdapter ?: return + val type = adapter.getItemViewType(position) + if (type == AccountSpinnerAdapter.TYPE_ACCOUNT) { + adapter.getItem(position)?.let { account -> + mAccountService.currentAccount = account + showAccountStatus(fContent is AccountEditionFragment && !account.isSip) + } + } else { + val intent = Intent(this@HomeActivity, AccountWizardActivity::class.java) + if (type == AccountSpinnerAdapter.TYPE_CREATE_SIP) { + intent.action = AccountConfig.ACCOUNT_TYPE_SIP + } + startActivity(intent) + mBinding!!.spinnerToolbar.setSelection(mAccountService.currentAccountIndex) + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + + private fun setBadge(menuId: Int, number: Int) { + if (number == 0) mBinding!!.navigationView.removeBadge(menuId) else mBinding!!.navigationView.getOrCreateBadge( + menuId + ).number = number + } + + private fun hideTabletToolbar() { + mBinding?.let { binding -> binding.tabletToolbar?.let { toolbar -> + binding.contactTitle?.text = null + binding.contactSubtitle?.text = null + binding.contactImage?.setImageDrawable(null) + toolbar.visibility = View.GONE + }} + } + + private fun showTabletToolbar() { + if (DeviceUtils.isTablet(this)) + mBinding?.let { binding -> binding.tabletToolbar?.let { toolbar -> + toolbar.visibility = View.VISIBLE + }} + } + + fun setTabletTitle(@StringRes titleRes: Int) { + mBinding?.let { binding -> binding.tabletToolbar?.let { toolbar -> + binding.contactTitle?.setText(titleRes) + binding.contactTitle?.textSize = 19f + binding.contactTitle?.setTypeface(null, Typeface.BOLD) + binding.contactImage?.visibility = View.GONE + toolbar.visibility = View.VISIBLE + }} + /*RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.contactTitle.getLayoutParams(); + params.removeRule(RelativeLayout.ALIGN_TOP); + params.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE); + binding.contactTitle.setLayoutParams(params);*/ + } + + fun setToolbarTitle(@StringRes titleRes: Int) { + if (DeviceUtils.isTablet(this)) { + setTabletTitle(titleRes) + } else { + setToolbarState(titleRes) + } + } + + fun showAccountStatus(show: Boolean) { + mBinding!!.accountSwitch.visibility = if (show) View.VISIBLE else View.GONE + } + + private fun showToolbarSpinner() { + mBinding!!.spinnerToolbar.visibility = View.VISIBLE + } + + private fun hideToolbarSpinner() { + if (mBinding != null && !DeviceUtils.isTablet(this)) { + mBinding!!.spinnerToolbar.visibility = View.GONE + } + } + + private val fragmentContainerId: Int + get() = if (DeviceUtils.isTablet(this@HomeActivity)) + R.id.conversation_container else R.id.main_frame + + fun setAccountFragmentOnBackPressedListener(backPressedListener: BackHandlerInterface?) { + mAccountFragmentBackHandlerInterface = backPressedListener + } + + /** + * Changes the current main fragment to a plugins list settings fragment + */ + fun goToPluginsListSettings() { + if (fContent is PluginsListSettingsFragment) { + return + } + val content = PluginsListSettingsFragment() + fContent = content + supportFragmentManager + .beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(fragmentContainerId, content, PLUGINS_LIST_SETTINGS_TAG) + .addToBackStack(PLUGINS_LIST_SETTINGS_TAG).commit() + } + + /** + * Changes the current main fragment to a plugin settings fragment + * @param pluginDetails + */ + fun gotToPluginSettings(pluginDetails: PluginDetails?) { + if (fContent is PluginSettingsFragment) { + return + } + val content = PluginSettingsFragment.newInstance(pluginDetails) + fContent = content + supportFragmentManager + .beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(fragmentContainerId, content, PLUGIN_SETTINGS_TAG) + .addToBackStack(PLUGIN_SETTINGS_TAG).commit() + } + + /** + * Changes the current main fragment to a plugin PATH preference fragment + */ + fun gotToPluginPathPreference(pluginDetails: PluginDetails?, preferenceKey: String?) { + if (fContent is PluginPathPreferenceFragment) { + return + } + val content = PluginPathPreferenceFragment.newInstance(pluginDetails, preferenceKey) + fContent = content + supportFragmentManager + .beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(fragmentContainerId, content, PLUGIN_PATH_PREFERENCE_TAG) + .addToBackStack(PLUGIN_PATH_PREFERENCE_TAG).commit() + } + + override fun setColor(color: Int) { + //mToolbar.setBackground(new ColorDrawable(color)); + } + + fun setToolbarElevation(enable: Boolean) { + if (mBinding != null) mBinding!!.appBar.elevation = if (enable) resources.getDimension(R.dimen.toolbar_elevation) else 0f + } + + fun setToolbarOutlineState(enabled: Boolean) { + if (mBinding != null) { + if (!enabled) { + mBinding!!.appBar.outlineProvider = null + } else { + mBinding!!.appBar.outlineProvider = mOutlineProvider + } + } + } + + fun popFragmentImmediate() { + val fm = supportFragmentManager + fm.popBackStackImmediate() + val entry = fm.getBackStackEntryAt(fm.backStackEntryCount - 1) + fContent = fm.findFragmentById(entry.id) + } + + private fun selectNavigationItem(id: Int) { + if (mBinding != null) mBinding!!.navigationView.selectedItemId = id + } + + private fun enableAccount(newValue: Boolean) { + val account = mAccountService.currentAccount + if (account == null) { + Log.w(TAG, "account not found!") + return + } + account.isEnabled = newValue + mAccountService.setAccountEnabled(account.accountID, newValue) + } + + val switchButton: SwitchButton + get() = mBinding!!.accountSwitch + + private fun setShareShortcuts(conversations: Collection<Conversation>) { + val targetSize = (AvatarFactory.SIZE_NOTIF * resources.displayMetrics.density).toInt() + var i = 0 + var maxCount = ShortcutManagerCompat.getMaxShortcutCountPerActivity(this) + if (maxCount == 0) maxCount = 4 + val futureIcons: MutableList<Future<Bitmap>> = + ArrayList(conversations.size.coerceAtMost(maxCount)) + for (conversation in conversations) { + val mode = conversation.mode.blockingFirst() + if (mode == Conversation.Mode.Syncing) + continue + futureIcons.add( + (mContactService as ContactServiceImpl?)!!.loadConversationAvatar(this,conversation) + .map { d -> BitmapUtils.drawableToBitmap(d, targetSize) } + .subscribeOn(Schedulers.computation()) + .toFuture()) + if (++i == maxCount) break + } + val shortcutInfoList: MutableList<ShortcutInfoCompat> = ArrayList(futureIcons.size) + i = 0 + for (conversation in conversations) { + val mode = conversation.mode.blockingFirst() + if (mode == Conversation.Mode.Syncing) + continue + var icon: IconCompat? = null + try { + icon = IconCompat.createWithBitmap(futureIcons[i].get()) + } catch (e: Exception) { + Log.w(TAG, "Failed to load icon", e) + } + val title = conversation.title ?: continue + if (title.isEmpty()) continue + val path = ConversationPath(conversation) + val key = path.toKey() + val person = Person.Builder() + .setName(conversation.title) + .setKey(key) + .build() + val shortcutInfo = ShortcutInfoCompat.Builder(this, key) + .setShortLabel(conversation.title ?: "") + .setPerson(person) + .setLongLived(true) + .setIcon(icon) + .setCategories(setOf(CONVERSATIONS_CATEGORY)) + .setIntent(Intent(Intent.ACTION_SEND, android.net.Uri.EMPTY, this, HomeActivity::class.java) + .putExtras(path.toBundle())) + .build() + shortcutInfoList.add(shortcutInfo) + if (++i == maxCount) break + } + try { + Log.d(TAG, "Adding shortcuts: " + shortcutInfoList.size) + ShortcutManagerCompat.removeAllDynamicShortcuts(this) + ShortcutManagerCompat.addDynamicShortcuts(this, shortcutInfoList) + } catch (e: Exception) { + Log.w(TAG, "Error adding shortcuts", e) + } + } + + companion object { + val TAG: String = HomeActivity::class.simpleName!! + const val REQUEST_CODE_CALL = 3 + const val REQUEST_CODE_CONVERSATION = 4 + const val REQUEST_CODE_PHOTO = 5 + const val REQUEST_CODE_GALLERY = 6 + const val REQUEST_CODE_QR_CONVERSATION = 7 + const val REQUEST_PERMISSION_CAMERA = 113 + const val REQUEST_PERMISSION_READ_STORAGE = 114 + private const val NAVIGATION_CONTACT_REQUESTS = 0 + private const val NAVIGATION_CONVERSATIONS = 1 + private const val NAVIGATION_ACCOUNT = 2 + const val HOME_TAG = "Home" + const val CONTACT_REQUESTS_TAG = "Trust request" + const val ACCOUNTS_TAG = "Accounts" + const val ABOUT_TAG = "About" + const val SETTINGS_TAG = "Prefs" + const val VIDEO_SETTINGS_TAG = "VideoPrefs" + const val ACTION_PRESENT_TRUST_REQUEST_FRAGMENT = BuildConfig.APPLICATION_ID + "presentTrustRequestFragment" + const val PLUGINS_LIST_SETTINGS_TAG = "PluginsListSettings" + const val PLUGIN_SETTINGS_TAG = "PluginSettings" + const val PLUGIN_PATH_PREFERENCE_TAG = "PluginPathPreference" + private const val CONVERSATIONS_CATEGORY = "conversations" + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/LogsActivity.java b/ring-android/app/src/main/java/cx/ring/client/LogsActivity.java deleted file mode 100644 index 4cc27d633..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/LogsActivity.java +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.client; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; - -import com.google.android.material.snackbar.Snackbar; - -import net.jami.services.HardwareService; -import net.jami.utils.StringUtils; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.OutputStream; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.databinding.ActivityLogsBinding; -import cx.ring.utils.AndroidFileUtils; -import cx.ring.utils.ContentUriHandler; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class LogsActivity extends AppCompatActivity { - private static final String TAG = LogsActivity.class.getSimpleName(); - - private ActivityLogsBinding binding; - - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - private Disposable disposable; - - private ActivityResultLauncher<String> fileSaver; - private File mCurrentFile = null; - - @Inject - @Singleton - HardwareService mHardwareService; - - public LogsActivity() { - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - JamiApplication.getInstance().startDaemon(); - JamiApplication.getInstance().getInjectionComponent().inject(this); - binding = ActivityLogsBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - setSupportActionBar(binding.toolbar); - ActionBar ab = getSupportActionBar(); - if (ab != null) - ab.setDisplayHomeAsUpEnabled(true); - - fileSaver = registerForActivityResult(new ActivityResultContracts.CreateDocument(), result -> compositeDisposable.add( - AndroidFileUtils.copyFileToUri(getContentResolver(), mCurrentFile, result). - observeOn(AndroidSchedulers.mainThread()). - subscribe(() -> { - if (!mCurrentFile.delete()) - Log.w(TAG, "Can't delete temp file"); - mCurrentFile = null; - Snackbar.make(binding.getRoot(), R.string.file_saved_successfully, Snackbar.LENGTH_SHORT).show(); - }, error -> Snackbar.make(binding.getRoot(), R.string.generic_error, Snackbar.LENGTH_SHORT).show()))); - - binding.fab.setOnClickListener(view -> { - if (disposable == null) - startLogging(); - else - stopLogging(); - }); - if (mHardwareService.isLogging()) - startLogging(); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.logs_menu, menu); - return super.onCreateOptionsMenu(menu); - } - - private Maybe<String> getLog() { - if (mHardwareService.isLogging()) - return mHardwareService.startLogs() - .firstElement(); - CharSequence log = binding.logView.getText(); - if (StringUtils.isEmpty(log)) - return Maybe.empty(); - return Maybe.just(log.toString()); - } - - private Maybe<File> getLogFile() { - return getLog() - .observeOn(Schedulers.io()) - .map(log -> { - File file = AndroidFileUtils.createLogFile(this); - OutputStream os = new FileOutputStream(file); - os.write(log.getBytes()); - return file; - }); - } - - private Maybe<Uri> getLogUri() { - return getLogFile().map(file -> ContentUriHandler.getUriForFile(this, ContentUriHandler.AUTHORITY_FILES, file)); - } - - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - int id = item.getItemId(); - if (id == android.R.id.home) { - finish(); - return true; - } else if (id == R.id.menu_log_share) { - compositeDisposable.add(getLogUri() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(uri -> { - Log.w(TAG, "saved logs to " + uri); - Intent sendIntent = new Intent(Intent.ACTION_SEND); - sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - String type = getContentResolver().getType(uri); - sendIntent.setDataAndType(uri, type); - sendIntent.putExtra(Intent.EXTRA_STREAM, uri); - startActivity(Intent.createChooser(sendIntent, null)); - }, e -> Snackbar.make(binding.getRoot(), "Error sharing logs: " + e.getLocalizedMessage(), Snackbar.LENGTH_SHORT).show())); - return true; - } else if (id == R.id.menu_log_save) { - compositeDisposable.add(getLogFile() - .subscribe(file -> { - mCurrentFile = file; - fileSaver.launch(file.getName()); - })); - return true; - } - return super.onOptionsItemSelected(item); - } - - void startLogging() { - binding.logView.setText(""); - disposable = mHardwareService.startLogs() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(message -> { - binding.logView.setText(message); - binding.scroll.post(() -> binding.scroll.fullScroll(View.FOCUS_DOWN)); - }, e -> Log.w(TAG, "Error in logger", e)); - compositeDisposable.add(disposable); - setButtonState(true); - } - - void stopLogging() { - disposable.dispose(); - disposable = null; - mHardwareService.stopLogs(); - setButtonState(false); - } - - void setButtonState(boolean logging) { - binding.fab.setText(logging ? R.string.pref_logs_stop : R.string.pref_logs_start); - binding.fab.setBackgroundColor(ContextCompat.getColor(this, logging ? R.color.red_400 : R.color.colorSecondary)); - } - - @Override - protected void onDestroy() { - if (disposable != null) { - disposable.dispose(); - disposable = null; - } - compositeDisposable.clear(); - super.onDestroy(); - } -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/LogsActivity.kt b/ring-android/app/src/main/java/cx/ring/client/LogsActivity.kt new file mode 100644 index 000000000..ab7c29c55 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/LogsActivity.kt @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.client + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.google.android.material.snackbar.Snackbar +import cx.ring.R +import cx.ring.application.JamiApplication +import cx.ring.databinding.ActivityLogsBinding +import cx.ring.utils.AndroidFileUtils +import cx.ring.utils.ContentUriHandler +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.services.HardwareService +import net.jami.utils.StringUtils +import java.io.File +import java.io.FileOutputStream +import javax.inject.Inject +import javax.inject.Singleton + +@AndroidEntryPoint +class LogsActivity : AppCompatActivity() { + private lateinit var binding: ActivityLogsBinding + private val compositeDisposable = CompositeDisposable() + private var disposable: Disposable? = null + private lateinit var fileSaver: ActivityResultLauncher<String> + private var mCurrentFile: File? = null + + @Inject + @Singleton + lateinit var mHardwareService: HardwareService + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + JamiApplication.instance?.startDaemon() + binding = ActivityLogsBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + fileSaver = registerForActivityResult(ActivityResultContracts.CreateDocument()) { result: Uri? -> + if (result != null) + compositeDisposable.add(AndroidFileUtils.copyFileToUri(contentResolver, mCurrentFile, result) + .observeOn(AndroidSchedulers.mainThread()).subscribe({ + if (!mCurrentFile!!.delete()) + Log.w(TAG, "Can't delete temp file") + mCurrentFile = null + Snackbar.make(binding.root, R.string.file_saved_successfully, Snackbar.LENGTH_SHORT).show() + }) { Snackbar.make(binding.root, R.string.generic_error, Snackbar.LENGTH_SHORT).show() + }) + } + binding.fab.setOnClickListener { if (disposable == null) startLogging() else stopLogging() } + if (mHardwareService.isLogging) startLogging() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.logs_menu, menu) + return super.onCreateOptionsMenu(menu) + } + + private val log: Maybe<String> + get() { + if (mHardwareService.isLogging) + return mHardwareService.startLogs().firstElement() + val log = binding.logView.text + return if (StringUtils.isEmpty(log)) Maybe.empty() + else Maybe.just(log.toString()) + } + private val logFile: Maybe<File> + get() = log + .observeOn(Schedulers.io()) + .map { log: String -> + val file = AndroidFileUtils.createLogFile(this) + FileOutputStream(file).use { os -> os.write(log.toByteArray()) } + file + } + private val logUri: Maybe<Uri> + get() = logFile.map { file: File -> + ContentUriHandler.getUriForFile(this, ContentUriHandler.AUTHORITY_FILES, file) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + finish() + return true + } + R.id.menu_log_share -> { + compositeDisposable.add(logUri.observeOn(AndroidSchedulers.mainThread()) + .subscribe({ uri: Uri -> + Log.w(TAG, "saved logs to $uri") + val sendIntent = Intent(Intent.ACTION_SEND).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + setDataAndType(uri, contentResolver.getType(uri)) + putExtra(Intent.EXTRA_STREAM, uri) + } + startActivity(Intent.createChooser(sendIntent, null)) + }) { e: Throwable -> + Snackbar.make(binding.root, "Error sharing logs: " + e.localizedMessage, Snackbar.LENGTH_SHORT).show() + }) + return true + } + R.id.menu_log_save -> { + compositeDisposable.add(logFile.subscribe { file: File -> + mCurrentFile = file + fileSaver.launch(file.name) + }) + return true + } + else -> return super.onOptionsItemSelected(item) + } + } + + private fun startLogging() { + binding.logView.text = "" + disposable = mHardwareService.startLogs() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ message: String -> + binding.logView.text = message + binding.scroll.post { binding.scroll.fullScroll(View.FOCUS_DOWN) } + }) { e -> Log.w(TAG, "Error in logger", e) } + compositeDisposable.add(disposable) + setButtonState(true) + } + + private fun stopLogging() { + disposable?.let { + it.dispose() + disposable = null + } + mHardwareService.stopLogs() + setButtonState(false) + } + + private fun setButtonState(logging: Boolean) { + binding.fab.setText(if (logging) R.string.pref_logs_stop else R.string.pref_logs_start) + binding.fab.setBackgroundColor(ContextCompat.getColor(this, if (logging) R.color.red_400 else R.color.colorSecondary)) + } + + override fun onDestroy() { + disposable?.let { + it.dispose() + disposable = null + } + compositeDisposable.clear() + super.onDestroy() + } + + companion object { + private val TAG = LogsActivity::class.simpleName!! + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/MediaViewerActivity.java b/ring-android/app/src/main/java/cx/ring/client/MediaViewerActivity.kt similarity index 68% rename from ring-android/app/src/main/java/cx/ring/client/MediaViewerActivity.java rename to ring-android/app/src/main/java/cx/ring/client/MediaViewerActivity.kt index be06c637e..164322f92 100644 --- a/ring-android/app/src/main/java/cx/ring/client/MediaViewerActivity.java +++ b/ring-android/app/src/main/java/cx/ring/client/MediaViewerActivity.kt @@ -17,20 +17,15 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ -package cx.ring.client; +package cx.ring.client -import android.os.Bundle; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.Toolbar; +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import cx.ring.R -import cx.ring.R; - -public class MediaViewerActivity extends AppCompatActivity { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_media_viewer); +class MediaViewerActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_media_viewer) } - -} +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/MediaViewerFragment.java b/ring-android/app/src/main/java/cx/ring/client/MediaViewerFragment.java deleted file mode 100644 index d66a11cf2..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/MediaViewerFragment.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.client; - -import android.app.Activity; -import android.app.Fragment; -import android.net.Uri; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; - -import com.bumptech.glide.load.resource.bitmap.CenterInside; - -import cx.ring.R; -import cx.ring.utils.GlideApp; -import cx.ring.utils.GlideOptions; - -/** - * A placeholder fragment containing a simple view. - */ -public class MediaViewerFragment extends Fragment { - private final static String TAG = MediaViewerFragment.class.getSimpleName(); - - private Uri mUri = null; - - protected ImageView mImage; - - private final GlideOptions PICTURE_OPTIONS = new GlideOptions().transform(new CenterInside()); - - public MediaViewerFragment() { - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - ViewGroup view = (ViewGroup) inflater.inflate(R.layout.fragment_media_viewer, container, false); - mImage = view.findViewById(R.id.image); - showImage(); - return view; - } - - @Override - public void onStart() { - super.onStart(); - Activity activity = getActivity(); - if (activity == null) - return; - mUri = activity.getIntent().getData(); - showImage(); - } - - private void showImage() { - if (mUri == null) { - Log.w(TAG, "showImage(): null URI"); - return; - } - Activity a = getActivity(); - if (a == null) { - Log.w(TAG, "showImage(): null Activity"); - return; - } - if (mImage == null) { - Log.w(TAG, "showImage(): null image view"); - return; - } - GlideApp.with(a) - .load(mUri) - .apply(PICTURE_OPTIONS) - .into(mImage); - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/client/MediaViewerFragment.kt b/ring-android/app/src/main/java/cx/ring/client/MediaViewerFragment.kt new file mode 100644 index 000000000..77c2d8271 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/MediaViewerFragment.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.client + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.fragment.app.Fragment +import com.bumptech.glide.load.resource.bitmap.CenterInside +import cx.ring.R +import cx.ring.utils.GlideApp +import cx.ring.utils.GlideOptions + +/** + * A placeholder fragment containing a simple view. + */ +class MediaViewerFragment : Fragment() { + private var mUri: Uri? = null + private var mImage: ImageView? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.fragment_media_viewer, container, false) as ViewGroup + mImage = view.findViewById(R.id.image) + showImage() + return view + } + + override fun onDestroyView() { + super.onDestroyView() + mImage = null + } + + override fun onStart() { + super.onStart() + val activity = activity ?: return + mUri = activity.intent.data + showImage() + } + + private fun showImage() { + mUri?.let {uri -> + activity?.let {a -> + mImage?.let {image -> + GlideApp.with(a) + .load(uri) + .apply(PICTURE_OPTIONS) + .into(image) + } + } + } + } + + companion object { + private val PICTURE_OPTIONS = GlideOptions().transform(CenterInside()) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/RingtoneActivity.java b/ring-android/app/src/main/java/cx/ring/client/RingtoneActivity.java deleted file mode 100644 index 069fbdaaf..000000000 --- a/ring-android/app/src/main/java/cx/ring/client/RingtoneActivity.java +++ /dev/null @@ -1,376 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.client; - -import android.app.Activity; -import android.content.ContentResolver; -import android.content.Intent; -import android.graphics.drawable.Drawable; -import android.media.MediaPlayer; -import android.net.Uri; -import android.os.Bundle; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.recyclerview.widget.DefaultItemAnimator; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.target.DrawableImageViewTarget; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import cx.ring.R; -import cx.ring.account.AccountEditionFragment; -import cx.ring.adapters.RingtoneAdapter; -import cx.ring.application.JamiApplication; -import net.jami.model.Account; -import net.jami.model.ConfigKey; -import net.jami.model.Ringtone; -import net.jami.services.AccountService; -import cx.ring.utils.AndroidFileUtils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.Disposable; - -import net.jami.utils.Log; - -public class RingtoneActivity extends AppCompatActivity { - - private final String TAG = RingtoneActivity.class.getSimpleName(); - - private RingtoneAdapter adapter; - private Account mAccount; - private TextView customRingtone; - private ImageView customPlaying, customSelected; - private final MediaPlayer mediaPlayer = new MediaPlayer(); - private Disposable disposable; - - @Inject - @Singleton - AccountService mAccountService; - - public static final int MAX_SIZE_RINGTONE = 64 * 1024; - private static final int SELECT_RINGTONE_PATH = 40; - - @Override - protected void onCreate(Bundle savedInstanceState) { - JamiApplication.getInstance().getInjectionComponent().inject(this); - setContentView(R.layout.activity_ringtone); - super.onCreate(savedInstanceState); - mAccount = mAccountService.getAccount(getIntent().getExtras().getString(AccountEditionFragment.ACCOUNT_ID_KEY)); - if (mAccount == null) { - finish(); - return; - } - - /*Toolbar toolbar = findViewById(R.id.ringtoneToolbar); - toolbar.setNavigationOnClickListener(view -> finish());*/ - - RecyclerView recycler = findViewById(R.id.ringToneRecycler); - ConstraintLayout customRingtoneLayout = findViewById(R.id.customRingtoneLayout); - customRingtone = findViewById(R.id.customRingtoneName); - customPlaying = findViewById(R.id.custom_ringtone_playing); - customSelected = findViewById(R.id.custom_ringtone_selected); - adapter = new RingtoneAdapter(prepareRingtones()); - - RecyclerView.LayoutManager upcomingLayoutManager = new LinearLayoutManager(this); - recycler.setLayoutManager(upcomingLayoutManager); - recycler.setItemAnimator(new DefaultItemAnimator()); - recycler.setAdapter(adapter); - - // loads the user's settings - setPreference(); - - customRingtoneLayout.setOnClickListener(v -> - displayFileSearchDialog()); - - customRingtoneLayout.setOnLongClickListener(view -> { - displayRemoveDialog(); - return true; - }); - - disposable = adapter.getRingtoneSubject().subscribe(ringtone -> { - setJamiRingtone(ringtone); - removeCustomRingtone(); - }, e -> Log.e(TAG, "Error updating ringtone status")); - } - - @Override - public void onDestroy() { - super.onDestroy(); - disposable.dispose(); - } - - @Override - protected void onStop() { - super.onStop(); - stopCustomPreview(); - } - - @Override - public void finish() { - super.finish(); - adapter.releaseMediaPlayer(); - mediaPlayer.release(); - } - - /** - * Gets the name of a file without its extension - * - * @param fileName the name of the file - * @return the base name - */ - public static String stripFileNameExtension(String fileName) { - int index = fileName.lastIndexOf('.'); - if (index == -1) { - return fileName; - } else { - return fileName.substring(0, index); - } - } - - private List<Ringtone> prepareRingtones() { - List<Ringtone> ringtoneList = new ArrayList<>(); - File ringtoneFolder = new File(getFilesDir(), "ringtones"); - File[] ringtones = ringtoneFolder.listFiles(); - Drawable ringtoneIcon = getDrawable(R.drawable.baseline_notifications_active_24); - - if(ringtones == null) - return ringtoneList; - Arrays.sort(ringtones, (a, b) -> a.getName().compareTo(b.getName())); - - ringtoneList.add(new Ringtone("Silent", null, getDrawable(R.drawable.baseline_notifications_off_24))); - - for(File file : ringtones) { - String name = stripFileNameExtension(file.getName()); - ringtoneList.add(new Ringtone(name, file.getAbsolutePath(), ringtoneIcon)); - } - - return ringtoneList; - } - - /** - * Sets the selected ringtone (Jami or custom) on activity startup - */ - private void setPreference() { - File path = new File(mAccount.getConfig().get(ConfigKey.RINGTONE_PATH)); - boolean customEnabled = mAccount.getConfig().getBool(ConfigKey.RINGTONE_CUSTOM); - if (customEnabled && path.exists()) { - customRingtone.setText(path.getName()); - customSelected.setVisibility(View.VISIBLE); - } else if(path.exists()) { - adapter.selectDefaultItem(path.getAbsolutePath(), mAccount.getConfig().getBool(ConfigKey.RINGTONE_ENABLED)); - } - else { - setDefaultRingtone(); - } - } - - private void setDefaultRingtone() { - File ringtonesDir = new File(getFilesDir(), "ringtones"); - String ringtonePath = new File(ringtonesDir, getString(R.string.ringtone_default_name)).getAbsolutePath(); - adapter.selectDefaultItem(ringtonePath, mAccount.getConfig().getBool(ConfigKey.RINGTONE_ENABLED)); - mAccount.setDetail(ConfigKey.RINGTONE_PATH, ringtonePath); - mAccount.setDetail(ConfigKey.RINGTONE_CUSTOM, false); - updateAccount(); - } - - /** - * Sets a Jami ringtone as the default - * - * @param ringtone the ringtone object - */ - private void setJamiRingtone(Ringtone ringtone) { - String path = ringtone.getRingtonePath(); - if (path == null) { - mAccount.setDetail(ConfigKey.RINGTONE_ENABLED, false); - mAccount.setDetail(ConfigKey.RINGTONE_PATH, ""); - } else { - mAccount.setDetail(ConfigKey.RINGTONE_ENABLED, true); - mAccount.setDetail(ConfigKey.RINGTONE_PATH, ringtone.getRingtonePath()); - mAccount.setDetail(ConfigKey.RINGTONE_CUSTOM, false); - } - updateAccount(); - } - - /** - * Sets a custom ringtone selected by the user - * - * @param path the ringtoen path - * @see #onFileFound(File) onFileFound - * @see #displayFileSearchDialog() displayFileSearchDialog - */ - private void setCustomRingtone(String path) { - mAccount.setDetail(ConfigKey.RINGTONE_ENABLED, true); - mAccount.setDetail(ConfigKey.RINGTONE_PATH, path); - mAccount.setDetail(ConfigKey.RINGTONE_CUSTOM, true); - updateAccount(); - } - - /** - * Updates an account with new details - */ - private void updateAccount() { - mAccountService.setCredentials(mAccount.getAccountID(), mAccount.getCredentialsHashMapList()); - mAccountService.setAccountDetails(mAccount.getAccountID(), mAccount.getDetails()); - } - - /** - * Previews a custom ringtone - * - * @param ringtone the ringtone file - */ - private void previewRingtone(File ringtone) { - try { - mediaPlayer.setDataSource(ringtone.getAbsolutePath()); - mediaPlayer.prepare(); - mediaPlayer.start(); - } catch (IOException | NullPointerException e) { - stopCustomPreview(); - Log.e(TAG, "Error previewing ringtone", e); - } - mediaPlayer.setOnCompletionListener(mp -> stopCustomPreview()); - } - - /** - * Removes a custom ringtone and updates the view - */ - private void removeCustomRingtone() { - customSelected.setVisibility(View.INVISIBLE); - customPlaying.setVisibility(View.INVISIBLE); - customRingtone.setText(R.string.ringtone_custom_prompt); - stopCustomPreview(); - } - - /** - * Stops audio previews from all possible sources - */ - private void stopCustomPreview() { - try { - if (mediaPlayer != null) { - if (mediaPlayer.isPlaying()) - mediaPlayer.stop(); - mediaPlayer.reset(); - } - } catch (Exception e) { - } - } - - /** - * Handles playing and setting a custom ringtone or displaying an error if it is too large - * - * @param ringtone the ringtone path - */ - private void onFileFound(File ringtone) { - if (ringtone.length() / 1024 > MAX_SIZE_RINGTONE) { - displayFileTooBigDialog(); - } else { - // resetState will stop the preview - adapter.resetState(); - customRingtone.setText(ringtone.getName()); - customSelected.setVisibility(View.VISIBLE); - customPlaying.setVisibility(View.VISIBLE); - Glide.with(this) - .load(R.raw.baseline_graphic_eq_black_24dp) - .placeholder(R.drawable.baseline_graphic_eq_24) - .into(new DrawableImageViewTarget(customPlaying)); - previewRingtone(ringtone); - setCustomRingtone(ringtone.getAbsolutePath()); - } - } - - /** - * Displays the native file browser to select a ringtone - */ - private void displayFileSearchDialog() { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType("audio/*"); - startActivityForResult(intent, SELECT_RINGTONE_PATH); - } - - /** - * Displays a dialog if the selected ringtone is too large - */ - private void displayFileTooBigDialog() { - new AlertDialog.Builder(this) - .setTitle(R.string.ringtone_error_title) - .setMessage(getString(R.string.ringtone_error_size_too_big, MAX_SIZE_RINGTONE)) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - - /** - * Displays a dialog that prompts the user to remove a custom ringtone - */ - private void displayRemoveDialog() { - if (!mAccount.getConfig().getBool(ConfigKey.RINGTONE_CUSTOM)) - return; - String[] item = {"Remove"}; - // subject callback from adapter will update the view - new AlertDialog.Builder(this) - .setItems(item, (dialog, which) -> - setDefaultRingtone()).show(); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (data == null) - return; - Uri uri = data.getData(); - if (resultCode == Activity.RESULT_CANCELED || uri == null) { - return; - } - - ContentResolver cr = getContentResolver(); - if (requestCode == SELECT_RINGTONE_PATH) { - try { - String path = AndroidFileUtils.getRealPathFromURI(this, uri); - if (path == null) - throw new IllegalArgumentException(); - onFileFound(new File(path)); - } catch (Exception e) { - final int takeFlags = data.getFlags() - & (Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - cr.takePersistableUriPermission(uri, takeFlags); - AndroidFileUtils.getCacheFile(this, uri) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onFileFound, - err -> Toast.makeText(this, "Can't load ringtone !", Toast.LENGTH_SHORT).show()); - } - } - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/client/RingtoneActivity.kt b/ring-android/app/src/main/java/cx/ring/client/RingtoneActivity.kt new file mode 100644 index 000000000..e604f5a7c --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/client/RingtoneActivity.kt @@ -0,0 +1,354 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.client + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.content.Intent +import android.media.MediaPlayer +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.LayoutManager +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.DrawableImageViewTarget +import cx.ring.R +import cx.ring.account.AccountEditionFragment +import cx.ring.adapters.RingtoneAdapter +import cx.ring.utils.AndroidFileUtils +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.Disposable +import net.jami.model.Account +import net.jami.model.ConfigKey +import net.jami.model.Ringtone +import net.jami.services.AccountService +import net.jami.utils.Log +import java.io.File +import java.io.IOException +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +@AndroidEntryPoint +class RingtoneActivity : AppCompatActivity() { + private var adapter: RingtoneAdapter? = null + private lateinit var mAccount: Account + private var customRingtone: TextView? = null + private var customPlaying: ImageView? = null + private var customSelected: ImageView? = null + private val mediaPlayer: MediaPlayer = MediaPlayer() + private var disposable: Disposable? = null + + @Inject + @Singleton + lateinit var mAccountService: AccountService + + override fun onCreate(savedInstanceState: Bundle?) { + setContentView(R.layout.activity_ringtone) + super.onCreate(savedInstanceState) + val account = mAccountService.getAccount(intent.extras!!.getString(AccountEditionFragment.ACCOUNT_ID_KEY)!!) + if (account == null) { + finish() + return + } + mAccount = account + + /*Toolbar toolbar = findViewById(R.id.ringtoneToolbar); + toolbar.setNavigationOnClickListener(view -> finish());*/ + val recycler = findViewById<RecyclerView>(R.id.ringToneRecycler) + val customRingtoneLayout = findViewById<ConstraintLayout>(R.id.customRingtoneLayout) + customRingtone = findViewById(R.id.customRingtoneName) + customPlaying = findViewById(R.id.custom_ringtone_playing) + customSelected = findViewById(R.id.custom_ringtone_selected) + adapter = RingtoneAdapter(prepareRingtones()) + val upcomingLayoutManager: LayoutManager = LinearLayoutManager(this) + recycler.layoutManager = upcomingLayoutManager + recycler.itemAnimator = DefaultItemAnimator() + recycler.adapter = adapter + + // loads the user's settings + setPreference() + customRingtoneLayout.setOnClickListener { displayFileSearchDialog() } + customRingtoneLayout.setOnLongClickListener { + displayRemoveDialog() + true + } + disposable = adapter!!.ringtoneSubject.subscribe({ ringtone: Ringtone -> + setJamiRingtone(ringtone) + removeCustomRingtone() + }) { Log.e(TAG, "Error updating ringtone status") } + } + + public override fun onDestroy() { + super.onDestroy() + disposable!!.dispose() + } + + override fun onStop() { + super.onStop() + stopCustomPreview() + } + + override fun finish() { + super.finish() + adapter!!.releaseMediaPlayer() + mediaPlayer.release() + } + + private fun prepareRingtones(): List<Ringtone> { + val ringtoneList: MutableList<Ringtone> = ArrayList() + val ringtoneFolder = File(filesDir, "ringtones") + val ringtones = ringtoneFolder.listFiles() + val ringtoneIcon = getDrawable(R.drawable.baseline_notifications_active_24) + if (ringtones == null) return ringtoneList + Arrays.sort(ringtones) { a: File, b: File -> a.name.compareTo(b.name) } + ringtoneList.add(Ringtone("Silent", null, getDrawable(R.drawable.baseline_notifications_off_24))) + for (file in ringtones) { + val name = stripFileNameExtension(file.name) + ringtoneList.add(Ringtone(name, file.absolutePath, ringtoneIcon)) + } + return ringtoneList + } + + /** + * Sets the selected ringtone (Jami or custom) on activity startup + */ + private fun setPreference() { + val path = File(mAccount.config[ConfigKey.RINGTONE_PATH]) + val customEnabled = mAccount.config.getBool(ConfigKey.RINGTONE_CUSTOM) + if (customEnabled && path.exists()) { + customRingtone!!.text = path.name + customSelected!!.visibility = View.VISIBLE + } else if (path.exists()) { + adapter!!.selectDefaultItem(path.absolutePath, mAccount.config.getBool(ConfigKey.RINGTONE_ENABLED)) + } else { + setDefaultRingtone() + } + } + + private fun setDefaultRingtone() { + val ringtonesDir = File(filesDir, "ringtones") + val ringtonePath = File(ringtonesDir, getString(R.string.ringtone_default_name)).absolutePath + adapter!!.selectDefaultItem(ringtonePath, mAccount.config.getBool(ConfigKey.RINGTONE_ENABLED)) + mAccount.setDetail(ConfigKey.RINGTONE_PATH, ringtonePath) + mAccount.setDetail(ConfigKey.RINGTONE_CUSTOM, false) + updateAccount() + } + + /** + * Sets a Jami ringtone as the default + * + * @param ringtone the ringtone object + */ + private fun setJamiRingtone(ringtone: Ringtone) { + val path = ringtone.ringtonePath + if (path == null) { + mAccount.setDetail(ConfigKey.RINGTONE_ENABLED, false) + mAccount.setDetail(ConfigKey.RINGTONE_PATH, "") + } else { + mAccount.setDetail(ConfigKey.RINGTONE_ENABLED, true) + mAccount.setDetail(ConfigKey.RINGTONE_PATH, ringtone.ringtonePath) + mAccount.setDetail(ConfigKey.RINGTONE_CUSTOM, false) + } + updateAccount() + } + + /** + * Sets a custom ringtone selected by the user + * + * @param path the ringtoen path + * @see .onFileFound + * @see .displayFileSearchDialog + */ + private fun setCustomRingtone(path: String) { + mAccount.setDetail(ConfigKey.RINGTONE_ENABLED, true) + mAccount.setDetail(ConfigKey.RINGTONE_PATH, path) + mAccount.setDetail(ConfigKey.RINGTONE_CUSTOM, true) + updateAccount() + } + + /** + * Updates an account with new details + */ + private fun updateAccount() { + mAccountService.setCredentials(mAccount.accountID, mAccount.credentialsHashMapList) + mAccountService.setAccountDetails(mAccount.accountID, mAccount.details) + } + + /** + * Previews a custom ringtone + * + * @param ringtone the ringtone file + */ + private fun previewRingtone(ringtone: File) { + try { + mediaPlayer.setDataSource(ringtone.absolutePath) + mediaPlayer.prepare() + mediaPlayer.start() + } catch (e: IOException) { + stopCustomPreview() + Log.e(TAG, "Error previewing ringtone", e) + } catch (e: NullPointerException) { + stopCustomPreview() + Log.e(TAG, "Error previewing ringtone", e) + } + mediaPlayer.setOnCompletionListener { stopCustomPreview() } + } + + /** + * Removes a custom ringtone and updates the view + */ + private fun removeCustomRingtone() { + customSelected!!.visibility = View.INVISIBLE + customPlaying!!.visibility = View.INVISIBLE + customRingtone!!.setText(R.string.ringtone_custom_prompt) + stopCustomPreview() + } + + /** + * Stops audio previews from all possible sources + */ + private fun stopCustomPreview() { + try { + if (mediaPlayer.isPlaying) mediaPlayer.stop() + mediaPlayer.reset() + } catch (e: Exception) { + } + } + + /** + * Handles playing and setting a custom ringtone or displaying an error if it is too large + * + * @param ringtone the ringtone path + */ + private fun onFileFound(ringtone: File) { + if (ringtone.length() / 1024 > MAX_SIZE_RINGTONE) { + displayFileTooBigDialog() + } else { + // resetState will stop the preview + adapter!!.resetState() + customRingtone!!.text = ringtone.name + customSelected!!.visibility = View.VISIBLE + customPlaying!!.visibility = View.VISIBLE + Glide.with(this) + .load(R.raw.baseline_graphic_eq_black_24dp) + .placeholder(R.drawable.baseline_graphic_eq_24) + .into(DrawableImageViewTarget(customPlaying)) + previewRingtone(ringtone) + setCustomRingtone(ringtone.absolutePath) + } + } + + /** + * Displays the native file browser to select a ringtone + */ + private fun displayFileSearchDialog() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "audio/*" + startActivityForResult(intent, SELECT_RINGTONE_PATH) + } + + /** + * Displays a dialog if the selected ringtone is too large + */ + private fun displayFileTooBigDialog() { + AlertDialog.Builder(this) + .setTitle(R.string.ringtone_error_title) + .setMessage(getString(R.string.ringtone_error_size_too_big, MAX_SIZE_RINGTONE)) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + /** + * Displays a dialog that prompts the user to remove a custom ringtone + */ + private fun displayRemoveDialog() { + if (!mAccount.config.getBool(ConfigKey.RINGTONE_CUSTOM)) return + val item = arrayOf("Remove") + // subject callback from adapter will update the view + AlertDialog.Builder(this) + .setItems(item) { _: DialogInterface?, _: Int -> setDefaultRingtone() }.show() + } + + @SuppressLint("WrongConstant") + public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (data == null) return + val uri = data.data + if (resultCode == RESULT_CANCELED || uri == null) { + return + } + val cr = contentResolver + if (requestCode == SELECT_RINGTONE_PATH) { + try { + val path = AndroidFileUtils.getRealPathFromURI(this, uri) ?: throw IllegalArgumentException() + onFileFound(File(path)) + } catch (e: Exception) { + val takeFlags = (data.flags + and (Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) + cr.takePersistableUriPermission(uri, takeFlags) + AndroidFileUtils.getCacheFile(this, uri) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { ringtone: File -> onFileFound(ringtone) } + ) { + Toast.makeText( + this, + "Can't load ringtone !", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + companion object { + const val MAX_SIZE_RINGTONE = 64 * 1024 + private const val SELECT_RINGTONE_PATH = 40 + + /** + * Gets the name of a file without its extension + * + * @param fileName the name of the file + * @return the base name + */ + fun stripFileNameExtension(fileName: String): String { + val index = fileName.lastIndexOf('.') + return if (index == -1) { + fileName + } else { + fileName.substring(0, index) + } + } + + private val TAG = RingtoneActivity::class.java.simpleName + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/client/ShareActivity.java b/ring-android/app/src/main/java/cx/ring/client/ShareActivity.kt similarity index 57% rename from ring-android/app/src/main/java/cx/ring/client/ShareActivity.java rename to ring-android/app/src/main/java/cx/ring/client/ShareActivity.kt index c8f01c11a..f996fbf03 100644 --- a/ring-android/app/src/main/java/cx/ring/client/ShareActivity.java +++ b/ring-android/app/src/main/java/cx/ring/client/ShareActivity.kt @@ -16,28 +16,26 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ -package cx.ring.client; +package cx.ring.client -import android.content.Intent; -import android.os.Bundle; -import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import cx.ring.utils.ConversationPath +import cx.ring.R +import dagger.hilt.android.AndroidEntryPoint -import cx.ring.R; -import cx.ring.utils.ConversationPath; - -public class ShareActivity extends AppCompatActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Intent intent = getIntent(); - Bundle extra = intent.getExtras(); +@AndroidEntryPoint +class ShareActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val intent = intent + val extra = intent.extras if (ConversationPath.fromBundle(extra) != null) { - intent.setClass(this, ConversationActivity.class); - startActivity(intent); - finish(); - return; + intent.setClass(this, ConversationActivity::class.java) + startActivity(intent) + finish() + return } - setContentView(R.layout.activity_share); + setContentView(R.layout.activity_share) } - -} +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListAdapter.java b/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListAdapter.java deleted file mode 100644 index ec070b45f..000000000 --- a/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListAdapter.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ -package cx.ring.contactrequests; - -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import java.util.ArrayList; -import java.util.Collection; - -import cx.ring.R; -import net.jami.model.Contact; - -public class BlockListAdapter extends RecyclerView.Adapter<BlockListViewHolder> { - - private final BlockListViewHolder.BlockListListeners mListener; - private final ArrayList<Contact> mBlacklisted; - - public BlockListAdapter(Collection<Contact> viewModels, BlockListViewHolder.BlockListListeners listener) { - mBlacklisted = new ArrayList<>(viewModels); - mListener = listener; - } - - public void replaceAll(Collection<Contact> viewModels) { - mBlacklisted.clear(); - mBlacklisted.addAll(viewModels); - notifyDataSetChanged(); - } - - @Override - public BlockListViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View holderView = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_contact_blacklist, parent, false); - - return new BlockListViewHolder(holderView); - } - - @Override - public void onBindViewHolder(BlockListViewHolder holder, int position) { - final Contact contact = mBlacklisted.get(position); - holder.bind(mListener, contact); - } - - @Override - public int getItemCount() { - return mBlacklisted.size(); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListAdapter.kt b/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListAdapter.kt new file mode 100644 index 000000000..c0e729e7d --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListAdapter.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ +package cx.ring.contactrequests + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import cx.ring.R +import cx.ring.contactrequests.BlockListViewHolder.BlockListListeners +import net.jami.model.Contact + +class BlockListAdapter(viewModels: Collection<Contact>, listener: BlockListListeners) : + RecyclerView.Adapter<BlockListViewHolder>() { + private val mListener: BlockListListeners = listener + private val mBlacklisted: ArrayList<Contact> = ArrayList(viewModels) + fun replaceAll(viewModels: Collection<Contact>) { + mBlacklisted.clear() + mBlacklisted.addAll(viewModels) + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BlockListViewHolder { + val holderView = LayoutInflater.from(parent.context) + .inflate(R.layout.item_contact_blacklist, parent, false) + return BlockListViewHolder(holderView) + } + + override fun onBindViewHolder(holder: BlockListViewHolder, position: Int) { + val contact = mBlacklisted[position] + holder.bind(mListener, contact) + } + + override fun getItemCount(): Int { + return mBlacklisted.size + } + +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListFragment.java b/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListFragment.java deleted file mode 100644 index c0bcece5b..000000000 --- a/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListFragment.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.contactrequests; - -import android.content.Context; -import android.os.Bundle; - -import androidx.activity.OnBackPressedCallback; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.LinearLayoutManager; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import java.util.Collection; - -import cx.ring.account.AccountEditionFragment; -import cx.ring.account.JamiAccountSummaryFragment; -import cx.ring.application.JamiApplication; -import cx.ring.databinding.FragBlocklistBinding; - -import net.jami.contactrequests.BlockListPresenter; -import net.jami.contactrequests.BlockListView; -import net.jami.model.Contact; -import cx.ring.mvp.BaseSupportFragment; - -public class BlockListFragment extends BaseSupportFragment<BlockListPresenter> implements BlockListView, - BlockListViewHolder.BlockListListeners { - - public static final String TAG = BlockListFragment.class.getSimpleName(); - - private final OnBackPressedCallback mOnBackPressedCallback = new OnBackPressedCallback(false) { - @Override - public void handleOnBackPressed() { - mOnBackPressedCallback.setEnabled(false); - JamiAccountSummaryFragment fragment = (JamiAccountSummaryFragment) getParentFragment(); - if (fragment != null) { - fragment.popBackStack(); - } - } - }; - - private BlockListAdapter mAdapter; - private FragBlocklistBinding binding; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragBlocklistBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - setHasOptionsMenu(true); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onResume() { - super.onResume(); - if (getArguments() == null || getArguments().getString(AccountEditionFragment.ACCOUNT_ID_KEY) == null) { - return; - } - String mAccountId = getArguments().getString(AccountEditionFragment.ACCOUNT_ID_KEY); - mOnBackPressedCallback.setEnabled(true); - presenter.setAccountId(mAccountId); - } - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - requireActivity().getOnBackPressedDispatcher().addCallback(this, mOnBackPressedCallback); - } - - @Override - public void onUnblockClicked(Contact viewModel) { - presenter.unblockClicked(viewModel); - } - - @Override - public void updateView(final Collection<Contact> list) { - binding.blocklist.setVisibility(View.VISIBLE); - if (binding.blocklist.getAdapter() != null) { - mAdapter.replaceAll(list); - } else { - mAdapter = new BlockListAdapter(list, BlockListFragment.this); - LinearLayoutManager layoutManager = new LinearLayoutManager(getActivity()); - binding.blocklist.setLayoutManager(layoutManager); - binding.blocklist.setAdapter(mAdapter); - } - } - - @Override - public void hideListView() { - binding.blocklist.setVisibility(View.GONE); - } - - @Override - public void displayEmptyListMessage(final boolean display) { - binding.placeholder.setVisibility(display ? View.VISIBLE : View.GONE); - } - - public void setAccount(String accountId) { - presenter.setAccountId(accountId); - } -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListFragment.kt b/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListFragment.kt new file mode 100644 index 000000000..8512752e9 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListFragment.kt @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.contactrequests + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.recyclerview.widget.LinearLayoutManager +import cx.ring.account.AccountEditionFragment +import cx.ring.account.JamiAccountSummaryFragment +import cx.ring.contactrequests.BlockListViewHolder.BlockListListeners +import cx.ring.databinding.FragBlocklistBinding +import cx.ring.mvp.BaseSupportFragment +import dagger.hilt.android.AndroidEntryPoint +import net.jami.contactrequests.BlockListPresenter +import net.jami.contactrequests.BlockListView +import net.jami.model.Contact + +@AndroidEntryPoint +class BlockListFragment : BaseSupportFragment<BlockListPresenter, BlockListView>(), BlockListView, + BlockListListeners { + private val mOnBackPressedCallback: OnBackPressedCallback = + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + this.isEnabled = false + val fragment = parentFragment as JamiAccountSummaryFragment? + fragment?.popBackStack() + } + } + private var mAdapter: BlockListAdapter? = null + private var binding: FragBlocklistBinding? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragBlocklistBinding.inflate(inflater, container, false) + setHasOptionsMenu(true) + return binding!!.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + override fun onResume() { + super.onResume() + if (arguments == null || requireArguments().getString(AccountEditionFragment.ACCOUNT_ID_KEY) == null) { + return + } + val mAccountId = requireArguments().getString(AccountEditionFragment.ACCOUNT_ID_KEY) + mOnBackPressedCallback.isEnabled = true + presenter.setAccountId(mAccountId) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + requireActivity().onBackPressedDispatcher.addCallback(this, mOnBackPressedCallback) + } + + override fun onUnblockClicked(contact: Contact) { + presenter.unblockClicked(contact) + } + + override fun updateView(list: Collection<Contact>) { + binding!!.blocklist.visibility = View.VISIBLE + if (binding!!.blocklist.adapter != null) { + mAdapter!!.replaceAll(list) + } else { + mAdapter = BlockListAdapter(list, this@BlockListFragment) + val layoutManager = LinearLayoutManager(activity) + binding!!.blocklist.layoutManager = layoutManager + binding!!.blocklist.adapter = mAdapter + } + } + + override fun hideListView() { + binding!!.blocklist.visibility = View.GONE + } + + override fun displayEmptyListMessage(display: Boolean) { + binding!!.placeholder.visibility = if (display) View.VISIBLE else View.GONE + } + + fun setAccount(accountId: String?) { + presenter.setAccountId(accountId) + } + + companion object { + @JvmStatic + val TAG: String = BlockListFragment::class.simpleName!! + } + +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListViewHolder.java b/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListViewHolder.java deleted file mode 100644 index 79b146868..000000000 --- a/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListViewHolder.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.contactrequests; - -import androidx.recyclerview.widget.RecyclerView; -import android.view.View; -import cx.ring.views.AvatarFactory; -import cx.ring.databinding.ItemContactBlacklistBinding; -import net.jami.model.Contact; - -public class BlockListViewHolder extends RecyclerView.ViewHolder { - private final ItemContactBlacklistBinding binding; - - BlockListViewHolder(View view) { - super(view); - binding = ItemContactBlacklistBinding.bind(view); - } - - void bind(final BlockListListeners clickListener, final Contact contact) { - AvatarFactory.loadGlideAvatar(binding.photo, contact); - binding.displayName.setText(contact.getRingUsername()); - binding.unblock.setOnClickListener(view -> clickListener.onUnblockClicked(contact)); - } - - public interface BlockListListeners { - void onUnblockClicked(Contact contact); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListViewHolder.kt b/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListViewHolder.kt new file mode 100644 index 000000000..9c65ce3b6 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/contactrequests/BlockListViewHolder.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.contactrequests + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import cx.ring.databinding.ItemContactBlacklistBinding +import net.jami.model.Contact +import cx.ring.views.AvatarFactory + +class BlockListViewHolder internal constructor(view: View) : RecyclerView.ViewHolder(view) { + private val binding: ItemContactBlacklistBinding = ItemContactBlacklistBinding.bind(view) + fun bind(clickListener: BlockListListeners, contact: Contact) { + AvatarFactory.loadGlideAvatar(binding.photo, contact) + binding.displayName.text = contact.ringUsername + binding.unblock.setOnClickListener { clickListener.onUnblockClicked(contact) } + } + + interface BlockListListeners { + fun onUnblockClicked(contact: Contact) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/contactrequests/ContactRequestsFragment.java b/ring-android/app/src/main/java/cx/ring/contactrequests/ContactRequestsFragment.java deleted file mode 100644 index e43507f9d..000000000 --- a/ring-android/app/src/main/java/cx/ring/contactrequests/ContactRequestsFragment.java +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.contactrequests; - -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; - -import java.util.List; - -import cx.ring.R; -import cx.ring.adapters.SmartListAdapter; -import cx.ring.application.JamiApplication; -import cx.ring.client.HomeActivity; -import cx.ring.databinding.FragPendingContactRequestsBinding; - -import net.jami.contactrequests.ContactRequestsPresenter; -import net.jami.contactrequests.ContactRequestsView; -import net.jami.model.Uri; -import cx.ring.mvp.BaseSupportFragment; -import net.jami.smartlist.SmartListViewModel; - -import cx.ring.viewholders.SmartListViewHolder; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class ContactRequestsFragment extends BaseSupportFragment<ContactRequestsPresenter> implements ContactRequestsView, - SmartListViewHolder.SmartListListeners { - - private static final String TAG = ContactRequestsFragment.class.getSimpleName(); - public static final String ACCOUNT_ID = TAG + "accountID"; - - private static final int SCROLL_DIRECTION_UP = -1; - - private SmartListAdapter mAdapter; - private FragPendingContactRequestsBinding binding; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragPendingContactRequestsBinding.inflate(inflater, container, false); - ((JamiApplication) requireActivity().getApplication()).getInjectionComponent().inject(this); - setHasOptionsMenu(true); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - mAdapter = null; - binding = null; - } - - public void presentForAccount(@Nullable String accountId) { - Bundle arguments = getArguments(); - if (arguments != null) - arguments.putString(ACCOUNT_ID, accountId); - if (presenter != null) - presenter.updateAccount(accountId); - } - - @Override - public void onStart() { - super.onStart(); - Bundle arguments = getArguments(); - String accountId = arguments != null ? arguments.getString(ACCOUNT_ID) : null; - presenter.updateAccount(accountId); - } - - @Override - public void onCreateOptionsMenu(Menu menu, @NonNull MenuInflater inflater) { - menu.clear(); - } - - @Override - public void onPrepareOptionsMenu(Menu menu) { - menu.clear(); - } - - @Override - public void updateView(final List<SmartListViewModel> list, CompositeDisposable disposable) { - if (binding == null) { - return; - } - - if (!list.isEmpty()) { - binding.paneRingID.setVisibility(/*viewModel.hasPane() ? View.VISIBLE :*/ View.GONE); - } - - binding.placeholder.setVisibility(list.isEmpty() ? View.VISIBLE : View.GONE); - - if (binding.requestsList.getAdapter() != null) { - mAdapter.update(list); - } else { - mAdapter = new SmartListAdapter(list, ContactRequestsFragment.this, disposable); - binding.requestsList.setAdapter(mAdapter); - LinearLayoutManager mLayoutManager = new LinearLayoutManager(getActivity()); - binding.requestsList.setLayoutManager(mLayoutManager); - } - - binding.requestsList.addOnScrollListener(new RecyclerView.OnScrollListener() { - @Override - public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { - super.onScrollStateChanged(recyclerView, newState); - } - - @Override - public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { - ((HomeActivity) requireActivity()).setToolbarElevation(recyclerView.canScrollVertically(SCROLL_DIRECTION_UP)); - } - }); - - updateBadge(); - } - - @Override - public void updateItem(SmartListViewModel item) { - if (mAdapter != null) { - mAdapter.update(item); - } - } - - @Override - public void goToConversation(String accountId, Uri contactId) { - ((HomeActivity) requireActivity()).startConversation(accountId, contactId); - } - - @Override - public void onItemClick(SmartListViewModel viewModel) { - presenter.contactRequestClicked(viewModel.getAccountId(), viewModel.getUri()); - } - - @Override - public void onItemLongClick(SmartListViewModel smartListViewModel) { - - } - - private void updateBadge() { - ((HomeActivity) requireActivity()).setBadge(R.id.navigation_requests, mAdapter.getItemCount()); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/contactrequests/ContactRequestsFragment.kt b/ring-android/app/src/main/java/cx/ring/contactrequests/ContactRequestsFragment.kt new file mode 100644 index 000000000..0d5373103 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/contactrequests/ContactRequestsFragment.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.contactrequests + +import android.os.Bundle +import android.view.* +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import cx.ring.adapters.SmartListAdapter +import cx.ring.client.HomeActivity +import cx.ring.databinding.FragPendingContactRequestsBinding +import cx.ring.mvp.BaseSupportFragment +import cx.ring.viewholders.SmartListViewHolder.SmartListListeners +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.contactrequests.ContactRequestsPresenter +import net.jami.contactrequests.ContactRequestsView +import net.jami.model.Uri +import net.jami.smartlist.SmartListViewModel + +@AndroidEntryPoint +class ContactRequestsFragment : + BaseSupportFragment<ContactRequestsPresenter, ContactRequestsView>(), ContactRequestsView, + SmartListListeners { + private var mAdapter: SmartListAdapter? = null + private var binding: FragPendingContactRequestsBinding? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragPendingContactRequestsBinding.inflate(inflater, container, false) + setHasOptionsMenu(true) + return binding!!.root + } + + override fun onDestroyView() { + super.onDestroyView() + mAdapter = null + binding = null + } + + fun presentForAccount(accountId: String?) { + arguments?.putString(ACCOUNT_ID, accountId) + presenter.updateAccount(accountId) + } + + override fun onStart() { + super.onStart() + presenter.updateAccount(arguments?.getString(ACCOUNT_ID)) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + menu.clear() + } + + override fun onPrepareOptionsMenu(menu: Menu) { + menu.clear() + } + + override fun updateView(list: MutableList<SmartListViewModel>, disposable: CompositeDisposable) { + if (binding == null) { + return + } + if (list.isNotEmpty()) { + binding!!.paneRingID.visibility = View.GONE + } + binding!!.placeholder.visibility = if (list.isEmpty()) View.VISIBLE else View.GONE + if (binding!!.requestsList.adapter != null) { + mAdapter!!.update(list) + } else { + mAdapter = SmartListAdapter(list, this@ContactRequestsFragment, disposable) + binding!!.requestsList.adapter = mAdapter + val mLayoutManager = LinearLayoutManager(activity) + binding!!.requestsList.layoutManager = mLayoutManager + } + binding!!.requestsList.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + (requireActivity() as HomeActivity).setToolbarElevation( + recyclerView.canScrollVertically( + SCROLL_DIRECTION_UP + ) + ) + } + }) + } + + override fun updateItem(item: SmartListViewModel) { + mAdapter?.update(item) + } + + override fun goToConversation(accountId: String, contactId: Uri) { + (requireActivity() as HomeActivity).startConversation(accountId, contactId) + } + + override fun onItemClick(smartListViewModel: SmartListViewModel) { + presenter.contactRequestClicked(smartListViewModel.accountId, smartListViewModel.uri) + } + + override fun onItemLongClick(smartListViewModel: SmartListViewModel) {} + + companion object { + private val TAG = ContactRequestsFragment::class.java.simpleName + val ACCOUNT_ID = TAG + "accountID" + private const val SCROLL_DIRECTION_UP = -1 + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionComponent.java b/ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionComponent.java deleted file mode 100755 index bc49eb543..000000000 --- a/ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionComponent.java +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.dependencyinjection; - -import net.jami.facades.ConversationFacade; -import net.jami.services.AccountService; -import net.jami.services.CallService; -import net.jami.services.DaemonService; -import net.jami.services.HardwareService; - -import javax.inject.Singleton; - -import cx.ring.account.AccountEditionFragment; -import cx.ring.account.AccountWizardActivity; -import cx.ring.account.HomeAccountCreationFragment; -import cx.ring.account.JamiAccountConnectFragment; -import cx.ring.account.JamiAccountPasswordFragment; -import cx.ring.account.JamiAccountSummaryFragment; -import cx.ring.account.JamiAccountUsernameFragment; -import cx.ring.account.JamiLinkAccountPasswordFragment; -import cx.ring.account.ProfileCreationFragment; -import cx.ring.account.RegisterNameDialog; -import cx.ring.application.JamiApplication; -import cx.ring.client.ContactDetailsActivity; -import cx.ring.client.ConversationSelectionActivity; -import cx.ring.client.HomeActivity; -import cx.ring.client.LogsActivity; -import cx.ring.client.RingtoneActivity; -import cx.ring.contactrequests.BlockListFragment; -import cx.ring.contactrequests.ContactRequestsFragment; -import cx.ring.fragments.AccountMigrationFragment; -import cx.ring.fragments.AdvancedAccountFragment; -import cx.ring.fragments.CallFragment; -import cx.ring.fragments.ContactPickerFragment; -import cx.ring.fragments.ConversationFragment; -import cx.ring.fragments.GeneralAccountFragment; -import cx.ring.fragments.LinkDeviceFragment; -import cx.ring.fragments.LocationSharingFragment; -import cx.ring.fragments.MediaPreferenceFragment; -import cx.ring.fragments.SIPAccountCreationFragment; -import cx.ring.fragments.SecurityAccountFragment; -import cx.ring.fragments.ShareWithFragment; -import cx.ring.fragments.SmartListFragment; -import cx.ring.history.DatabaseHelper; -import cx.ring.service.BootReceiver; -import cx.ring.service.CallNotificationService; -import cx.ring.service.DRingService; -import cx.ring.service.JamiJobService; -import cx.ring.services.ContactServiceImpl; -import cx.ring.services.DataTransferService; -import cx.ring.services.DeviceRuntimeServiceImpl; -import cx.ring.services.HistoryServiceImpl; -import cx.ring.service.LocationSharingService; -import cx.ring.services.NotificationServiceImpl; -import cx.ring.services.SharedPreferencesServiceImpl; -import cx.ring.service.SyncService; -import cx.ring.settings.AccountFragment; -import cx.ring.settings.SettingsFragment; -import cx.ring.share.ShareFragment; -import cx.ring.tv.account.TVAccountExport; -import cx.ring.tv.account.TVAccountWizard; -import cx.ring.tv.account.TVHomeAccountCreationFragment; -import cx.ring.tv.account.TVJamiAccountCreationFragment; -import cx.ring.tv.account.TVJamiLinkAccountFragment; -import cx.ring.tv.account.TVProfileCreationFragment; -import cx.ring.tv.account.TVProfileEditingFragment; -import cx.ring.tv.account.TVSettingsFragment; -import cx.ring.tv.account.TVShareFragment; -import cx.ring.tv.call.TVCallActivity; -import cx.ring.tv.call.TVCallFragment; -import cx.ring.tv.cards.iconcards.IconCardPresenter; -import cx.ring.tv.contact.TVContactFragment; -import cx.ring.tv.conversation.TvConversationFragment; -import cx.ring.tv.main.MainFragment; -import cx.ring.tv.search.ContactSearchFragment; -import dagger.Component; - -@Singleton -@Component(modules = {JamiInjectionModule.class, ServiceInjectionModule.class}) -public interface JamiInjectionComponent { - void inject(JamiApplication app); - - void inject(HomeActivity activity); - - void inject(DatabaseHelper helper); - - void inject(AccountWizardActivity activity); - - void inject(AccountEditionFragment activity); - - void inject(RingtoneActivity activity); - - void inject(AccountMigrationFragment fragment); - - void inject(SIPAccountCreationFragment fragment); - - void inject(JamiAccountSummaryFragment fragment); - - void inject(CallFragment fragment); - - void inject(SmartListFragment fragment); - - void inject(ConversationSelectionActivity fragment); - - void inject(JamiAccountUsernameFragment fragment); - - void inject(JamiAccountPasswordFragment fragment); - - void inject(MediaPreferenceFragment fragment); - - void inject(SecurityAccountFragment fragment); - - void inject(ShareFragment fragment); - - void inject(SettingsFragment fragment); - - void inject(AccountFragment fragment); - - void inject(ProfileCreationFragment fragment); - - void inject(RegisterNameDialog dialog); - - void inject(ConversationFragment fragment); - - void inject(ContactRequestsFragment fragment); - - void inject(BlockListFragment fragment); - - void inject(DRingService service); - - void inject(DeviceRuntimeServiceImpl service); - - void inject(DaemonService service); - - void inject(CallService service); - - void inject(AccountService service); - - void inject(HardwareService service); - - void inject(SharedPreferencesServiceImpl service); - - void inject(HistoryServiceImpl service); - - void inject(ContactServiceImpl service); - - void inject(NotificationServiceImpl service); - - void inject(ConversationFacade service); - - void inject(CallNotificationService service); - - void inject(DataTransferService service); - - void inject(BootReceiver receiver); - - void inject(AdvancedAccountFragment fragment); - - void inject(GeneralAccountFragment fragment); - - void inject(HomeAccountCreationFragment fragment); - - void inject(JamiLinkAccountPasswordFragment fragment); - - void inject(JamiAccountConnectFragment fragment); - - // AndroidTV section - void inject(TVCallFragment fragment); - - void inject(MainFragment fragment); - - void inject(ContactSearchFragment fragment); - - void inject(cx.ring.tv.main.HomeActivity activity); - - void inject(TVCallActivity activity); - - void inject(TVAccountWizard activity); - - void inject(TVHomeAccountCreationFragment fragment); - - void inject(TVProfileCreationFragment fragment); - - void inject(TVJamiAccountCreationFragment fragment); - - void inject(TVJamiLinkAccountFragment fragment); - - void inject(TVAccountExport fragment); - - void inject(TVProfileEditingFragment activity); - - void inject(TVShareFragment activity); - - void inject(TVContactFragment fragment); - - void inject(TvConversationFragment fragment); - - void inject(TVSettingsFragment tvSettingsFragment); - - void inject(TVSettingsFragment.PrefsFragment prefsFragment); - - void inject(LocationSharingFragment service); - - void inject(JamiJobService service); - - void inject(ShareWithFragment fragment); - - void inject(ContactDetailsActivity fragment); - - void inject(IconCardPresenter presenter); - - void inject(LocationSharingService service); - - void inject(SyncService syncService); - - void inject(LinkDeviceFragment linkDeviceFragment); - - void inject(ContactPickerFragment contactPickerFragment); - - void inject(LogsActivity logsActivity); - -} diff --git a/ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionModule.java b/ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionModule.java deleted file mode 100755 index 85878a2cf..000000000 --- a/ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionModule.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.dependencyinjection; - -import android.content.Context; - -import cx.ring.application.JamiApplication; -import dagger.Module; -import dagger.Provides; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Scheduler; - -@Module -public class JamiInjectionModule { - - private final JamiApplication mJamiApplication; - - public JamiInjectionModule(JamiApplication app) { - mJamiApplication = app; - } - - @Provides - JamiApplication provideJamiApplication() { - return mJamiApplication; - } - - @Provides - Context provideContext() { - return mJamiApplication; - } - - @Provides - Scheduler provideMainSchedulers() { - return AndroidSchedulers.mainThread(); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.java b/ring-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.java deleted file mode 100755 index 35214e764..000000000 --- a/ring-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.java +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.dependencyinjection; - -import android.content.Context; - -import net.jami.facades.ConversationFacade; -import net.jami.services.AccountService; -import net.jami.services.CallService; -import net.jami.services.ContactService; -import net.jami.services.DaemonService; -import net.jami.services.DeviceRuntimeService; -import net.jami.services.HardwareService; -import net.jami.services.HistoryService; -import net.jami.services.LogService; -import net.jami.services.NotificationService; -import net.jami.services.PreferencesService; -import net.jami.services.VCardService; -import net.jami.utils.Log; - -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; - -import javax.inject.Named; -import javax.inject.Singleton; - -import cx.ring.application.JamiApplication; -import cx.ring.services.ContactServiceImpl; -import cx.ring.services.DeviceRuntimeServiceImpl; -import cx.ring.services.HardwareServiceImpl; -import cx.ring.services.HistoryServiceImpl; -import cx.ring.services.LogServiceImpl; -import cx.ring.services.NotificationServiceImpl; -import cx.ring.services.SharedPreferencesServiceImpl; -import cx.ring.services.VCardServiceImpl; -import dagger.Module; -import dagger.Provides; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Scheduler; - -@Module -public class ServiceInjectionModule { - - private final JamiApplication mJamiApplication; - - public ServiceInjectionModule(JamiApplication app) { - mJamiApplication = app; - } - - @Provides - @Singleton - PreferencesService provideSettingsService() { - SharedPreferencesServiceImpl settingsService = new SharedPreferencesServiceImpl(); - mJamiApplication.getInjectionComponent().inject(settingsService); - return settingsService; - } - - @Provides - @Singleton - HistoryService provideHistoryService() { - HistoryServiceImpl historyService = new HistoryServiceImpl(); - mJamiApplication.getInjectionComponent().inject(historyService); - return historyService; - } - - @Provides - @Singleton - LogService provideLogService() { - LogService service = new LogServiceImpl(); - Log.injectLogService(service); - return service; - } - - @Provides - @Singleton - NotificationService provideNotificationService() { - NotificationServiceImpl service = new NotificationServiceImpl(); - mJamiApplication.getInjectionComponent().inject(service); - service.initHelper(); - return service; - } - - @Provides - @Singleton - DeviceRuntimeService provideDeviceRuntimeService(LogService logService) { - DeviceRuntimeServiceImpl runtimeService = new DeviceRuntimeServiceImpl(); - mJamiApplication.getInjectionComponent().inject(runtimeService); - runtimeService.loadNativeLibrary(); - return runtimeService; - } - - @Provides - @Singleton - DaemonService provideDaemonService(DeviceRuntimeService deviceRuntimeService) { - DaemonService daemonService = new DaemonService(deviceRuntimeService); - mJamiApplication.getInjectionComponent().inject(daemonService); - return daemonService; - } - - @Provides - @Singleton - CallService provideCallService() { - CallService callService = new CallService(); - mJamiApplication.getInjectionComponent().inject(callService); - return callService; - } - - @Provides - @Singleton - AccountService provideAccountService() { - AccountService accountService = new AccountService(); - mJamiApplication.getInjectionComponent().inject(accountService); - return accountService; - } - - @Provides - @Singleton - HardwareService provideHardwareService(Context context) { - HardwareServiceImpl hardwareService = new HardwareServiceImpl(context); - mJamiApplication.getInjectionComponent().inject(hardwareService); - return hardwareService; - } - - @Provides - @Singleton - ContactService provideContactService(PreferencesService sharedPreferencesService) { - ContactServiceImpl contactService = new ContactServiceImpl(); - mJamiApplication.getInjectionComponent().inject(contactService); - return contactService; - } - - @Provides - @Singleton - ConversationFacade provideConversationFacade( - HistoryService historyService, - CallService callService, - ContactService contactService, - AccountService accountService, - NotificationService notificationService) { - ConversationFacade conversationFacade = new ConversationFacade(historyService, callService, accountService, contactService, notificationService); - mJamiApplication.getInjectionComponent().inject(conversationFacade); - return conversationFacade; - } - - @Provides - @Singleton - VCardService provideVCardService(Context context) { - return new VCardServiceImpl(context); - } - - @Provides - @Named("DaemonExecutor") - @Singleton - ScheduledExecutorService provideDaemonExecutorService() { - return Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "DRing")); - } - - @Provides - @Named("UiScheduler") - @Singleton - Scheduler provideUiScheduler() { - return AndroidSchedulers.mainThread(); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.kt b/ring-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.kt new file mode 100755 index 000000000..2e8bd5916 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/dependencyinjection/ServiceInjectionModule.kt @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.dependencyinjection + +import android.content.Context +import cx.ring.services.* +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Scheduler +import net.jami.services.ConversationFacade +import net.jami.services.* +import net.jami.utils.Log +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ServiceInjectionModule { + @Provides + @Singleton + fun provideSettingsService(@ApplicationContext appContext: Context, accountService: AccountService, deviceService: DeviceRuntimeService): PreferencesService { + return SharedPreferencesServiceImpl(appContext, accountService, deviceService) + } + + @Provides + @Singleton + fun provideHistoryService(@ApplicationContext appContext: Context): HistoryService { + return HistoryServiceImpl(appContext) + } + + @Provides + @Singleton + fun provideLogService(): LogService { + val service: LogService = LogServiceImpl() + Log.injectLogService(service) + return service + } + + @Provides + @Singleton + fun provideNotificationService(@ApplicationContext appContext: Context, accountService: AccountService, + contactService: ContactService, + preferencesService: PreferencesService, + deviceRuntimeService: DeviceRuntimeService): NotificationService { + val service = NotificationServiceImpl(appContext, accountService, contactService, preferencesService, deviceRuntimeService) + service.initHelper() + return service + } + + @Provides + @Singleton + fun provideDeviceRuntimeService( + @ApplicationContext appContext: Context, @Named("DaemonExecutor") executor: ScheduledExecutorService, logService: LogService + ): DeviceRuntimeService { + val runtimeService = DeviceRuntimeServiceImpl(appContext, executor, logService) + runtimeService.loadNativeLibrary() + return runtimeService + } + + @Provides + @Singleton + fun provideDaemonService(deviceRuntimeService: DeviceRuntimeService, + @Named("DaemonExecutor") executor: ScheduledExecutorService, + callService: CallService, + hardwareService: HardwareService, + accountService: AccountService): DaemonService { + return DaemonService(deviceRuntimeService, executor, callService, hardwareService, accountService) + } + + @Provides + @Singleton + fun provideCallService(@Named("DaemonExecutor") executor : ScheduledExecutorService, + contactService: ContactService, + accountService: AccountService): CallService { + return CallService(executor, contactService, accountService) + } + + @Provides + @Singleton + fun provideAccountService(@Named("DaemonExecutor") executor : ScheduledExecutorService, + historyService : HistoryService, + deviceRuntimeService : DeviceRuntimeService, + vCardService : VCardService): AccountService { + return AccountService(executor, historyService, deviceRuntimeService, vCardService) + } + + @Provides + @Singleton + fun provideHardwareService(@ApplicationContext appContext: Context, + @Named("DaemonExecutor") executor : ScheduledExecutorService, + preferenceService: PreferencesService, + @Named("UiScheduler") uiScheduler: Scheduler): HardwareService { + return HardwareServiceImpl(appContext, executor, preferenceService, uiScheduler) + } + + @Provides + @Singleton + fun provideContactService(@ApplicationContext appContext: Context, + preferenceService: PreferencesService, + deviceRuntimeService : DeviceRuntimeService, + accountService: AccountService): ContactService { + return ContactServiceImpl(appContext, preferenceService, deviceRuntimeService, accountService) + } + + @Provides + @Singleton + fun provideConversationFacade( + historyService: HistoryService, + callService: CallService, + contactService: ContactService, + accountService: AccountService, + notificationService: NotificationService, + hardwareService: HardwareService, + deviceRuntimeService: DeviceRuntimeService, + preferencesService: PreferencesService + ): ConversationFacade { + return ConversationFacade( + historyService, + callService, + accountService, + contactService, + notificationService, + hardwareService, + deviceRuntimeService, + preferencesService + ) + } + + @Provides + @Singleton + fun provideVCardService(@ApplicationContext appContext: Context): VCardService { + return VCardServiceImpl(appContext) + } + + @Provides + @Named("DaemonExecutor") + @Singleton + fun provideDaemonExecutorService(): ScheduledExecutorService { + return Executors.newSingleThreadScheduledExecutor { r: Runnable? -> Thread(r, "DRing") } + } + + @Provides + @Named("UiScheduler") + @Singleton + fun provideUiScheduler(): Scheduler { + return AndroidSchedulers.mainThread() + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/AccountMigrationFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/AccountMigrationFragment.java index 99ec80a0f..b5c369114 100644 --- a/ring-android/app/src/main/java/cx/ring/fragments/AccountMigrationFragment.java +++ b/ring-android/app/src/main/java/cx/ring/fragments/AccountMigrationFragment.java @@ -45,6 +45,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import cx.ring.R; import cx.ring.application.JamiApplication; import cx.ring.databinding.FragAccountMigrationBinding; +import dagger.hilt.android.AndroidEntryPoint; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; @@ -53,6 +54,7 @@ import net.jami.model.AccountConfig; import net.jami.model.ConfigKey; import net.jami.services.AccountService; +@AndroidEntryPoint public class AccountMigrationFragment extends Fragment { public static final String ACCOUNT_ID = "ACCOUNT_ID"; static final String TAG = AccountMigrationFragment.class.getSimpleName(); @@ -77,7 +79,6 @@ public class AccountMigrationFragment extends Fragment { @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) { binding = FragAccountMigrationBinding.inflate(inflater, parent, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); return binding.getRoot(); } diff --git a/ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountFragment.java deleted file mode 100644 index 613d346fe..000000000 --- a/ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountFragment.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.fragments; - -import android.os.Bundle; - -import androidx.fragment.app.FragmentManager; -import androidx.preference.EditTextPreference; -import androidx.preference.ListPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceGroup; -import androidx.preference.TwoStatePreference; -import android.text.TextUtils; -import android.view.View; -import android.view.inputmethod.EditorInfo; - -import java.util.ArrayList; - -import cx.ring.R; -import cx.ring.account.AccountEditionFragment; -import cx.ring.application.JamiApplication; -import net.jami.model.AccountConfig; -import net.jami.model.ConfigKey; -import cx.ring.mvp.BasePreferenceFragment; -import cx.ring.views.EditTextIntegerPreference; -import cx.ring.views.EditTextPreferenceDialog; -import cx.ring.views.PasswordPreference; - -public class AdvancedAccountFragment extends BasePreferenceFragment<AdvancedAccountPresenter> implements AdvancedAccountView, Preference.OnPreferenceChangeListener { - - public static final String TAG = AdvancedAccountFragment.class.getSimpleName(); - - private static final String DIALOG_FRAGMENT_TAG = "androidx.preference.PreferenceFragment.DIALOG"; - - @Override - public void onCreatePreferences(Bundle bundle, String s) { - ((JamiApplication) requireActivity().getApplication()).getInjectionComponent().inject(this); - super.onCreatePreferences(bundle, s); - - // Load the preferences from an XML resource - addPreferencesFromResource(R.xml.account_advanced_prefs); - - Bundle args = getArguments(); - presenter.init(args == null ? null : args.getString(AccountEditionFragment.ACCOUNT_ID_KEY)); - } - - @Override - public void onDisplayPreferenceDialog(Preference preference) { - FragmentManager fragmentManager = requireFragmentManager(); - if (fragmentManager.findFragmentByTag(DIALOG_FRAGMENT_TAG) != null) { - return; - } - if (preference instanceof EditTextIntegerPreference) { - EditTextPreferenceDialog f = EditTextPreferenceDialog.newInstance(preference.getKey(), EditorInfo.TYPE_CLASS_NUMBER); - f.setTargetFragment(this, 0); - f.show(fragmentManager, DIALOG_FRAGMENT_TAG); - } else if (preference instanceof PasswordPreference) { - EditTextPreferenceDialog f = EditTextPreferenceDialog.newInstance(preference.getKey(), EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_PASSWORD); - f.setTargetFragment(this, 0); - f.show(fragmentManager, DIALOG_FRAGMENT_TAG); - } else { - super.onDisplayPreferenceDialog(preference); - } - } - - @Override - public void initView(AccountConfig config, ArrayList<CharSequence> networkInterfaces) { - for (ConfigKey confKey : config.getKeys()) { - Preference pref = findPreference(confKey.key()); - if (pref != null) { - pref.setOnPreferenceChangeListener(this); - if (confKey == ConfigKey.LOCAL_INTERFACE) { - String val = config.get(confKey); - CharSequence[] display = networkInterfaces.toArray(new CharSequence[networkInterfaces.size()]); - ListPreference listPref = (ListPreference) pref; - listPref.setEntries(display); - listPref.setEntryValues(display); - listPref.setSummary(val); - listPref.setValue(val); - } else if (!confKey.isTwoState()) { - String val = config.get(confKey); - pref.setSummary(val); - if (pref instanceof EditTextPreference) { - ((EditTextPreference) pref).setText(val); - } - } else { - ((TwoStatePreference) pref).setChecked(config.getBool(confKey)); - } - } - } - - boolean isJamiAccount = config.get(ConfigKey.ACCOUNT_TYPE).equals(AccountConfig.ACCOUNT_TYPE_RING); - Preference bootstrap = findPreference(ConfigKey.ACCOUNT_HOSTNAME.key()); - bootstrap.setVisible(isJamiAccount); - Preference sipLocalPort = findPreference(ConfigKey.LOCAL_PORT.key()); - sipLocalPort.setVisible(!isJamiAccount); - Preference sipLocalInterface = findPreference(ConfigKey.LOCAL_INTERFACE.key()); - sipLocalInterface.setVisible(!isJamiAccount); - Preference registrationExpire = findPreference(ConfigKey.REGISTRATION_EXPIRE.key()); - registrationExpire.setVisible(!isJamiAccount); - Preference publishedSameAsLocal = findPreference(ConfigKey.PUBLISHED_SAMEAS_LOCAL.key()); - publishedSameAsLocal.setVisible(!isJamiAccount); - Preference publishedPort = findPreference(ConfigKey.PUBLISHED_PORT.key()); - publishedPort.setVisible(!isJamiAccount); - Preference publishedAddress = findPreference(ConfigKey.PUBLISHED_ADDRESS.key()); - publishedAddress.setVisible(!isJamiAccount); - Preference dhtproxy = findPreference(ConfigKey.PROXY_ENABLED.key()); - PreferenceGroup dhtGroup = dhtproxy.getParent(); - dhtGroup.setVisible(isJamiAccount); - } - - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - final ConfigKey key = ConfigKey.fromString(preference.getKey()); - - presenter.preferenceChanged(key, newValue); - if (preference instanceof TwoStatePreference) { - presenter.twoStatePreferenceChanged(key, newValue); - } else if (preference instanceof PasswordPreference) { - presenter.passwordPreferenceChanged(key, newValue); - preference.setSummary(TextUtils.isEmpty(newValue.toString()) ? "" : "******"); - } else { - presenter.preferenceChanged(key, newValue); - preference.setSummary(newValue.toString()); - } - return true; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountFragment.kt b/ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountFragment.kt new file mode 100644 index 000000000..98c0cbab3 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountFragment.kt @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.fragments + +import android.os.Bundle +import android.text.TextUtils +import android.view.inputmethod.EditorInfo +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.TwoStatePreference +import cx.ring.R +import cx.ring.account.AccountEditionFragment +import cx.ring.mvp.BasePreferenceFragment +import cx.ring.views.EditTextIntegerPreference +import cx.ring.views.EditTextPreferenceDialog +import cx.ring.views.PasswordPreference +import dagger.hilt.android.AndroidEntryPoint +import net.jami.model.AccountConfig +import net.jami.model.ConfigKey + +@AndroidEntryPoint +class AdvancedAccountFragment : BasePreferenceFragment<AdvancedAccountPresenter>(), + AdvancedAccountView, Preference.OnPreferenceChangeListener { + + override fun onCreatePreferences(bundle: Bundle?, rootKey: String?) { + super.onCreatePreferences(bundle, rootKey) + + // Load the preferences from an XML resource + addPreferencesFromResource(R.xml.account_advanced_prefs) + val args = arguments + presenter!!.init(args?.getString(AccountEditionFragment.ACCOUNT_ID_KEY)) + } + + override fun onDisplayPreferenceDialog(preference: Preference) { + val fragmentManager = parentFragmentManager + if (fragmentManager.findFragmentByTag(DIALOG_FRAGMENT_TAG) != null) { + return + } + if (preference is EditTextIntegerPreference) { + val f = EditTextPreferenceDialog.newInstance( + preference.getKey(), + EditorInfo.TYPE_CLASS_NUMBER + ) + f.setTargetFragment(this, 0) + f.show(fragmentManager, DIALOG_FRAGMENT_TAG) + } else if (preference is PasswordPreference) { + val f = EditTextPreferenceDialog.newInstance( + preference.getKey(), + EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD + ) + f.setTargetFragment(this, 0) + f.show(fragmentManager, DIALOG_FRAGMENT_TAG) + } else { + super.onDisplayPreferenceDialog(preference) + } + } + + override fun initView(config: AccountConfig, networkInterfaces: ArrayList<CharSequence>) { + for (confKey in config.keys) { + val pref = findPreference<Preference>(confKey.key()) + if (pref != null) { + pref.onPreferenceChangeListener = this + if (confKey == ConfigKey.LOCAL_INTERFACE) { + val `val` = config[confKey] + val display = networkInterfaces.toTypedArray() + val listPref = pref as ListPreference + listPref.entries = display + listPref.entryValues = display + listPref.summary = `val` + listPref.value = `val` + } else if (!confKey.isTwoState) { + val `val` = config[confKey] + pref.summary = `val` + if (pref is EditTextPreference) { + pref.text = `val` + } + } else { + (pref as TwoStatePreference).isChecked = config.getBool(confKey) + } + } + } + val isJamiAccount = config[ConfigKey.ACCOUNT_TYPE] == AccountConfig.ACCOUNT_TYPE_RING + val bootstrap = findPreference<Preference>(ConfigKey.ACCOUNT_HOSTNAME.key()) + bootstrap?.isVisible = isJamiAccount + val sipLocalPort = findPreference<Preference>(ConfigKey.LOCAL_PORT.key()) + sipLocalPort?.isVisible = !isJamiAccount + val sipLocalInterface = findPreference<Preference>(ConfigKey.LOCAL_INTERFACE.key()) + sipLocalInterface?.isVisible = !isJamiAccount + val registrationExpire = findPreference<Preference>(ConfigKey.REGISTRATION_EXPIRE.key()) + registrationExpire?.isVisible = !isJamiAccount + val publishedSameAsLocal = findPreference<Preference>(ConfigKey.PUBLISHED_SAMEAS_LOCAL.key()) + publishedSameAsLocal?.isVisible = !isJamiAccount + val publishedPort = findPreference<Preference>(ConfigKey.PUBLISHED_PORT.key()) + publishedPort?.isVisible = !isJamiAccount + val publishedAddress = findPreference<Preference>(ConfigKey.PUBLISHED_ADDRESS.key()) + publishedAddress?.isVisible = !isJamiAccount + val dhtproxy = findPreference<Preference>(ConfigKey.PROXY_ENABLED.key()) + dhtproxy?.parent?.isVisible = isJamiAccount + } + + override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { + val key = ConfigKey.fromString(preference.key) + presenter!!.preferenceChanged(key, newValue) + when (preference) { + is TwoStatePreference -> { + presenter!!.twoStatePreferenceChanged(key, newValue) + } + is PasswordPreference -> { + presenter!!.passwordPreferenceChanged(key, newValue) + preference.setSummary(if (TextUtils.isEmpty(newValue.toString())) "" else "******") + } + else -> { + presenter!!.preferenceChanged(key, newValue) + preference.summary = newValue.toString() + } + } + return true + } + + companion object { + @JvmField + val TAG = AdvancedAccountFragment::class.java.simpleName + private const val DIALOG_FRAGMENT_TAG = "androidx.preference.PreferenceFragment.DIALOG" + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountPresenter.java b/ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountPresenter.java index 3041d100c..4d2569e4a 100644 --- a/ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountPresenter.java +++ b/ring-android/app/src/main/java/cx/ring/fragments/AdvancedAccountPresenter.java @@ -28,7 +28,7 @@ import java.util.Enumeration; import javax.inject.Inject; -import net.jami.facades.ConversationFacade; +import net.jami.services.ConversationFacade; import net.jami.model.Account; import net.jami.model.ConfigKey; import net.jami.mvp.RootPresenter; diff --git a/ring-android/app/src/main/java/cx/ring/fragments/CallFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/CallFragment.java deleted file mode 100644 index 4ebc05c00..000000000 --- a/ring-android/app/src/main/java/cx/ring/fragments/CallFragment.java +++ /dev/null @@ -1,1601 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.fragments; - -import android.Manifest; -import android.animation.ValueAnimator; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.PendingIntent; -import android.app.PictureInPictureParams; -import android.app.RemoteAction; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.graphics.Matrix; -import android.graphics.PixelFormat; -import android.graphics.PointF; -import android.graphics.Rect; -import android.graphics.RectF; -import android.graphics.SurfaceTexture; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.Icon; -import android.media.projection.MediaProjection; -import android.media.projection.MediaProjectionManager; -import android.os.Build; -import android.os.Bundle; -import android.os.PowerManager; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.util.Log; -import android.util.Rational; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.MotionEvent; -import android.view.OrientationEventListener; -import android.view.Surface; -import android.view.SurfaceHolder; -import android.view.TextureView; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.LinearInterpolator; -import android.view.inputmethod.InputMethodManager; -import android.widget.FrameLayout; -import android.widget.LinearLayout; -import android.widget.RelativeLayout; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.view.menu.MenuBuilder; -import androidx.appcompat.view.menu.MenuPopupHelper; -import androidx.appcompat.widget.PopupMenu; -import androidx.databinding.DataBindingUtil; -import androidx.fragment.app.FragmentActivity; -import androidx.percentlayout.widget.PercentFrameLayout; - -import com.rodolfonavalon.shaperipplelibrary.model.Circle; - -import net.jami.call.CallPresenter; -import net.jami.call.CallView; -import net.jami.daemon.JamiService; -import net.jami.model.Call; -import net.jami.model.Conference; -import net.jami.model.Contact; -import net.jami.model.Uri; -import net.jami.services.DeviceRuntimeService; -import net.jami.services.HardwareService; -import net.jami.services.NotificationService; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Random; -import java.util.Set; - -import javax.inject.Inject; - -import cx.ring.R; -import cx.ring.adapters.ConfParticipantAdapter; -import cx.ring.application.JamiApplication; -import cx.ring.client.CallActivity; -import cx.ring.client.ContactDetailsActivity; -import cx.ring.client.ConversationActivity; -import cx.ring.client.ConversationSelectionActivity; -import cx.ring.client.HomeActivity; -import cx.ring.databinding.FragCallBinding; -import cx.ring.databinding.ItemParticipantLabelBinding; -import cx.ring.mvp.BaseSupportFragment; -import cx.ring.plugins.RecyclerPicker.RecyclerPicker; -import cx.ring.plugins.RecyclerPicker.RecyclerPickerLayoutManager; -import cx.ring.service.DRingService; -import cx.ring.utils.ActionHelper; -import cx.ring.utils.ContentUriHandler; -import cx.ring.utils.ConversationPath; -import cx.ring.utils.DeviceUtils; -import cx.ring.utils.MediaButtonsHelper; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class CallFragment extends BaseSupportFragment<CallPresenter> implements CallView, MediaButtonsHelper.MediaButtonsHelperCallback, RecyclerPickerLayoutManager.ItemSelectedListener { - - public static final String TAG = CallFragment.class.getSimpleName(); - - public static final String ACTION_PLACE_CALL = "PLACE_CALL"; - public static final String ACTION_GET_CALL = "GET_CALL"; - - public static final String KEY_ACTION = "action"; - public static final String KEY_CONF_ID = "confId"; - public static final String KEY_AUDIO_ONLY = "AUDIO_ONLY"; - - private static final int REQUEST_CODE_ADD_PARTICIPANT = 6; - private static final int REQUEST_PERMISSION_INCOMING = 1003; - private static final int REQUEST_PERMISSION_OUTGOING = 1004; - private static final int REQUEST_CODE_SCREEN_SHARE = 7; - - private FragCallBinding binding; - private OrientationEventListener mOrientationListener; - - private MenuItem dialPadBtn = null; - private MenuItem pluginsMenuBtn = null; - private boolean restartVideo = false; - private boolean restartPreview = false; - private PowerManager.WakeLock mScreenWakeLock = null; - private int mCurrentOrientation = 0; - - private int mVideoWidth = -1; - private int mVideoHeight = -1; - private int mPreviewWidth = 720, mPreviewHeight = 1280; - private int mPreviewSurfaceWidth = 0, mPreviewSurfaceHeight = 0; - - private MediaProjectionManager mProjectionManager; - - private boolean mBackstackLost = false; - - private ConfParticipantAdapter confAdapter = null; - private boolean mConferenceMode = false; - private boolean choosePluginMode = false; - public boolean isChoosePluginMode() { - return choosePluginMode; - } - private boolean pluginsModeFirst = true; - private List<String> callMediaHandlers; - private int previousPluginPosition = -1; - private RecyclerPicker rp; - private final ValueAnimator animation = new ValueAnimator(); - - private PointF previewDrag = null; - private final ValueAnimator previewSnapAnimation = new ValueAnimator(); - private final int[] previewMargins = new int[4]; - private float previewHiddenState = 0; - private enum PreviewPosition {LEFT, RIGHT} - private PreviewPosition previewPosition = PreviewPosition.RIGHT; - - @Inject - DeviceRuntimeService mDeviceRuntimeService; - - private final CompositeDisposable mCompositeDisposable = new CompositeDisposable(); - - public static CallFragment newInstance(@NonNull String action, @Nullable ConversationPath path, @Nullable String contactId, boolean audioOnly) { - Bundle bundle = new Bundle(); - bundle.putString(KEY_ACTION, action); - if (path != null) - path.toBundle(bundle); - bundle.putString(Intent.EXTRA_PHONE_NUMBER, contactId); - bundle.putBoolean(KEY_AUDIO_ONLY, audioOnly); - CallFragment countDownFragment = new CallFragment(); - countDownFragment.setArguments(bundle); - return countDownFragment; - } - - public static CallFragment newInstance(@NonNull String action, @Nullable String confId) { - Bundle bundle = new Bundle(); - bundle.putString(KEY_ACTION, action); - bundle.putString(KEY_CONF_ID, confId); - CallFragment countDownFragment = new CallFragment(); - countDownFragment.setArguments(bundle); - return countDownFragment; - } - - public static int callStateToHumanState(final Call.CallStatus state) { - switch (state) { - case SEARCHING: - return R.string.call_human_state_searching; - case CONNECTING: - return R.string.call_human_state_connecting; - case RINGING: - return R.string.call_human_state_ringing; - case CURRENT: - return R.string.call_human_state_current; - case HUNGUP: - return R.string.call_human_state_hungup; - case BUSY: - return R.string.call_human_state_busy; - case FAILURE: - return R.string.call_human_state_failure; - case HOLD: - return R.string.call_human_state_hold; - case UNHOLD: - return R.string.call_human_state_unhold; - case OVER: - return R.string.call_human_state_over; - case NONE: - default: - return R.string.call_human_state_none; - } - } - - @Override - protected void initPresenter(CallPresenter presenter) { - Bundle args = getArguments(); - if (args != null) { - String action = args.getString(KEY_ACTION); - if (action != null) { - if (action.equals(ACTION_PLACE_CALL)) { - prepareCall(false); - } else if (action.equals(ACTION_GET_CALL) || action.equals(CallActivity.ACTION_CALL_ACCEPT)) { - presenter.initIncomingCall(getArguments().getString(KEY_CONF_ID), action.equals(ACTION_GET_CALL)); - } - } - } - } - - public void onUserLeave() { - presenter.requestPipMode(); - } - - @Override - public void enterPipMode(String callId) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - return; - } - Context context = requireContext(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - PictureInPictureParams.Builder paramBuilder = new PictureInPictureParams.Builder(); - if (binding.videoSurface.getVisibility() == View.VISIBLE) { - int[] l = new int[2]; - binding.videoSurface.getLocationInWindow(l); - int x = l[0]; - int y = l[1]; - int w = binding.videoSurface.getWidth(); - int h = binding.videoSurface.getHeight(); - Rect videoBounds = new Rect(x, y, x + w, y + h); - paramBuilder.setAspectRatio(new Rational(w, h)); - paramBuilder.setSourceRectHint(videoBounds); - } else { - return; - } - ArrayList<RemoteAction> actions = new ArrayList<>(1); - actions.add(new RemoteAction( - Icon.createWithResource(context, R.drawable.baseline_call_end_24), - getString(R.string.action_call_hangup), - getString(R.string.action_call_hangup), - PendingIntent.getService(context, new Random().nextInt(), - new Intent(DRingService.ACTION_CALL_END) - .setClass(context, JamiService.class) - .putExtra(NotificationService.KEY_CALL_ID, callId), PendingIntent.FLAG_ONE_SHOT))); - paramBuilder.setActions(actions); - try { - requireActivity().enterPictureInPictureMode(paramBuilder.build()); - } catch (Exception e) { - Log.w(TAG, "Can't enter PIP mode", e); - } - } else if (DeviceUtils.isTv(context)) { - requireActivity().enterPictureInPictureMode(); - } - } - - @Override - public void onStart() { - super.onStart(); - if (restartVideo && restartPreview) { - displayVideoSurface(true, !presenter.isPipMode()); - restartVideo = false; - restartPreview = false; - } else if (restartVideo) { - displayVideoSurface(true, false); - restartVideo = false; - } - } - - @Override - public void onStop() { - super.onStop(); - previewSnapAnimation.cancel(); - if (binding.videoSurface.getVisibility() == View.VISIBLE) { - restartVideo = true; - } - if (!choosePluginMode) { - if (binding.previewContainer.getVisibility() == View.VISIBLE) { - restartPreview = true; - } - }else { - if (binding.pluginPreviewContainer.getVisibility() == View.VISIBLE) { - restartPreview = true; - presenter.stopPlugin(); - } - } - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - ((JamiApplication) requireActivity().getApplication()).getInjectionComponent().inject(this); - binding = DataBindingUtil.inflate(inflater, R.layout.frag_call, container, false); - binding.setPresenter(this); - rp = new RecyclerPicker(binding.recyclerPicker, - R.layout.item_picker, - LinearLayout.HORIZONTAL, this); - rp.setFirstLastElementsWidths(112, 112); - return binding.getRoot(); - } - - private final TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() { - @Override - public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { - mPreviewSurfaceWidth = width; - mPreviewSurfaceHeight = height; - presenter.previewVideoSurfaceCreated(binding.previewSurface); - } - - @Override - public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { - mPreviewSurfaceWidth = width; - mPreviewSurfaceHeight = height; - configurePreview(width, 1); - } - - @Override - public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { - presenter.previewVideoSurfaceDestroyed(); - return true; - } - - @Override - public void onSurfaceTextureUpdated(SurfaceTexture surface) { - } - }; - - /** - * @param hiddenState 0.f if fully shown, 1.f if fully hidden. - */ - private void setPreviewDragHiddenState(float hiddenState) { - binding.previewSurface.setAlpha(1.f - (3 * hiddenState / 4)); - binding.pluginPreviewSurface.setAlpha(1.f - (3 * hiddenState / 4)); - binding.previewHandle.setAlpha(hiddenState); - binding.pluginPreviewHandle.setAlpha(hiddenState); - } - - @SuppressLint({"ClickableViewAccessibility", "RtlHardcoded", "WakelockTimeout"}) - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - setHasOptionsMenu(true); - super.onViewCreated(view, savedInstanceState); - mCurrentOrientation = getResources().getConfiguration().orientation; - float dpRatio = requireActivity().getResources().getDisplayMetrics().density; - - animation.setDuration(150); - animation.addUpdateListener(valueAnimator -> { - if (binding == null) - return; - int upBy = (int) valueAnimator.getAnimatedValue(); - RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) binding.previewContainer.getLayoutParams(); - layoutParams.setMargins(0, 0, 0, (int) (upBy * dpRatio)); - binding.previewContainer.setLayoutParams(layoutParams); - }); - - FragmentActivity activity = getActivity(); - if (activity != null) { - activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - if (activity instanceof AppCompatActivity) { - AppCompatActivity ac_activity = (AppCompatActivity) activity; - ActionBar ab = ac_activity.getSupportActionBar(); - if (ab != null) { - ab.setHomeAsUpIndicator(R.drawable.baseline_chat_24); - ab.setDisplayHomeAsUpEnabled(true); - } - } - } - - mProjectionManager = (MediaProjectionManager) requireContext().getSystemService(Context.MEDIA_PROJECTION_SERVICE); - - PowerManager powerManager = (PowerManager) requireContext().getSystemService(Context.POWER_SERVICE); - if (powerManager != null) { - mScreenWakeLock = powerManager.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP | PowerManager.ON_AFTER_RELEASE, "ring:callLock"); - mScreenWakeLock.setReferenceCounted(false); - if (mScreenWakeLock != null && !mScreenWakeLock.isHeld()) { - mScreenWakeLock.acquire(); - } - } - - binding.videoSurface.getHolder().setFormat(PixelFormat.RGBA_8888); - binding.videoSurface.getHolder().addCallback(new SurfaceHolder.Callback() { - @Override - public void surfaceCreated(SurfaceHolder holder) { - presenter.videoSurfaceCreated(holder); - } - - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - - } - - @Override - public void surfaceDestroyed(SurfaceHolder holder) { - presenter.videoSurfaceDestroyed(); - } - }); - - binding.pluginPreviewSurface.getHolder().setFormat(PixelFormat.RGBA_8888); - binding.pluginPreviewSurface.getHolder().addCallback(new SurfaceHolder.Callback() { - @Override - public void surfaceCreated(SurfaceHolder holder) { - presenter.pluginSurfaceCreated(holder); - } - - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - - } - - @Override - public void surfaceDestroyed(SurfaceHolder holder) { - presenter.pluginSurfaceDestroyed(); - } - }); - - view.setOnSystemUiVisibilityChangeListener(visibility -> { - boolean ui = (visibility & (View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN)) == 0; - presenter.uiVisibilityChanged(ui); - }); - boolean ui = (view.getSystemUiVisibility() & (View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN)) == 0; - presenter.uiVisibilityChanged(ui); - - view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> - resetVideoSize(mVideoWidth, mVideoHeight)); - - WindowManager windowManager = (WindowManager) requireContext().getSystemService(Context.WINDOW_SERVICE); - if (windowManager != null) { - mOrientationListener = new OrientationEventListener(getContext()) { - @Override - public void onOrientationChanged(int orientation) { - int rot = windowManager.getDefaultDisplay().getRotation(); - if (mCurrentOrientation != rot) { - mCurrentOrientation = rot; - presenter.configurationChanged(rot); - } - } - }; - if (mOrientationListener.canDetectOrientation()) { - mOrientationListener.enable(); - } - } - - binding.shapeRipple.setRippleShape(new Circle()); - binding.callSpeakerBtn.setChecked(presenter.isSpeakerphoneOn()); - binding.callMicBtn.setChecked(presenter.isMicrophoneMuted()); - binding.pluginPreviewSurface.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> - configureTransform(mPreviewSurfaceWidth, mPreviewSurfaceHeight)); - binding.previewSurface.setSurfaceTextureListener(listener); - binding.previewSurface.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> - configureTransform(mPreviewSurfaceWidth, mPreviewSurfaceHeight)); - - previewSnapAnimation.setDuration(250); - previewSnapAnimation.setFloatValues(0.f, 1.f); - previewSnapAnimation.setInterpolator(new DecelerateInterpolator()); - previewSnapAnimation.addUpdateListener(animation -> { - float animatedFraction = animation == null ? 1 : animation.getAnimatedFraction(); - configurePreview(mPreviewSurfaceWidth, animatedFraction); - }); - - binding.previewContainer.setOnTouchListener((v, event) -> { - int action = event.getActionMasked(); - RelativeLayout parent = (RelativeLayout) v.getParent(); - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) v.getLayoutParams(); - - if (action == MotionEvent.ACTION_DOWN) { - previewSnapAnimation.cancel(); - previewDrag = new PointF(event.getX(), event.getY()); - v.setElevation(v.getContext().getResources().getDimension(R.dimen.call_preview_elevation_dragged)); - params.removeRule(RelativeLayout.ALIGN_PARENT_RIGHT); - params.removeRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - params.addRule(RelativeLayout.ALIGN_PARENT_TOP); - params.addRule(RelativeLayout.ALIGN_PARENT_LEFT); - params.setMargins((int) v.getX(), (int) v.getY(), parent.getWidth() - ((int) v.getX() + v.getWidth()), parent.getHeight() - ((int) v.getY() + v.getHeight())); - v.setLayoutParams(params); - return true; - } else if (action == MotionEvent.ACTION_MOVE) { - if (previewDrag != null) { - int currentXPosition = params.leftMargin + (int) (event.getX() - previewDrag.x); - int currentYPosition = params.topMargin + (int) (event.getY() - previewDrag.y); - params.setMargins( - currentXPosition, - currentYPosition, - -((currentXPosition + v.getWidth()) - (int) event.getX()), - -((currentYPosition + v.getHeight()) - (int) event.getY())); - v.setLayoutParams(params); - - float outPosition = binding.previewContainer.getWidth() * 0.85f; - float drapOut = 0.f; - if (currentXPosition < 0) { - drapOut = Math.min(1.f, -currentXPosition / outPosition); - } else if (currentXPosition + v.getWidth() > parent.getWidth()) { - drapOut = Math.min(1.f, (currentXPosition + v.getWidth() - parent.getWidth()) / outPosition); - } - setPreviewDragHiddenState(drapOut); - return true; - } - return false; - } else if (action == MotionEvent.ACTION_UP) { - if (previewDrag != null) { - int currentXPosition = params.leftMargin + (int) (event.getX() - previewDrag.x); - - previewSnapAnimation.cancel(); - previewDrag = null; - v.setElevation(v.getContext().getResources().getDimension(R.dimen.call_preview_elevation)); - int ml = 0, mr = 0, mt = 0, mb = 0; - - FrameLayout.LayoutParams hp = (FrameLayout.LayoutParams) binding.previewHandle.getLayoutParams(); - if (params.leftMargin + (v.getWidth() / 2) > parent.getWidth() / 2) { - params.removeRule(RelativeLayout.ALIGN_PARENT_LEFT); - params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); - mr = (int) (parent.getWidth() - v.getWidth() - v.getX()); - previewPosition = PreviewPosition.RIGHT; - hp.gravity = Gravity.CENTER_VERTICAL | Gravity.LEFT; - } else { - params.removeRule(RelativeLayout.ALIGN_PARENT_RIGHT); - params.addRule(RelativeLayout.ALIGN_PARENT_LEFT); - ml = (int) v.getX(); - previewPosition = PreviewPosition.LEFT; - hp.gravity = Gravity.CENTER_VERTICAL | Gravity.RIGHT; - } - binding.previewHandle.setLayoutParams(hp); - - if (params.topMargin + (v.getHeight() / 2) > parent.getHeight() / 2) { - params.removeRule(RelativeLayout.ALIGN_PARENT_TOP); - params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - mb = (int) (parent.getHeight() - v.getHeight() - v.getY()); - } else { - params.removeRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - params.addRule(RelativeLayout.ALIGN_PARENT_TOP); - mt = (int) v.getY(); - } - previewMargins[0] = ml; - previewMargins[1] = mt; - previewMargins[2] = mr; - previewMargins[3] = mb; - params.setMargins(ml, mt, mr, mb); - v.setLayoutParams(params); - - float outPosition = binding.previewContainer.getWidth() * 0.85f; - previewHiddenState = currentXPosition < 0 - ? Math.min(1.f, -currentXPosition / outPosition) - : ((currentXPosition + v.getWidth() > parent.getWidth()) - ? Math.min(1.f, (currentXPosition + v.getWidth() - parent.getWidth()) / outPosition) - : 0.f); - setPreviewDragHiddenState(previewHiddenState); - - previewSnapAnimation.start(); - return true; - } - return false; - } else { - return false; - } - }); - - binding.pluginPreviewContainer.setOnTouchListener((v, event) -> { - int action = event.getActionMasked(); - RelativeLayout parent = (RelativeLayout) v.getParent(); - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) v.getLayoutParams(); - - if (action == MotionEvent.ACTION_DOWN) { - previewSnapAnimation.cancel(); - previewDrag = new PointF(event.getX(), event.getY()); - v.setElevation(v.getContext().getResources().getDimension(R.dimen.call_preview_elevation_dragged)); - params.removeRule(RelativeLayout.ALIGN_PARENT_RIGHT); - params.removeRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - params.addRule(RelativeLayout.ALIGN_PARENT_TOP); - params.addRule(RelativeLayout.ALIGN_PARENT_LEFT); - params.setMargins((int) v.getX(), (int) v.getY(), parent.getWidth() - ((int) v.getX() + v.getWidth()), parent.getHeight() - ((int) v.getY() + v.getHeight())); - v.setLayoutParams(params); - return true; - } else if (action == MotionEvent.ACTION_MOVE) { - if (previewDrag != null) { - int currentXPosition = params.leftMargin + (int) (event.getX() - previewDrag.x); - int currentYPosition = params.topMargin + (int) (event.getY() - previewDrag.y); - params.setMargins( - currentXPosition, - currentYPosition, - -((currentXPosition + v.getWidth()) - (int) event.getX()), - -((currentYPosition + v.getHeight()) - (int) event.getY())); - v.setLayoutParams(params); - - float outPosition = binding.pluginPreviewContainer.getWidth() * 0.85f; - float drapOut = 0.f; - if (currentXPosition < 0) { - drapOut = Math.min(1.f, -currentXPosition / outPosition); - } else if (currentXPosition + v.getWidth() > parent.getWidth()) { - drapOut = Math.min(1.f, (currentXPosition + v.getWidth() - parent.getWidth()) / outPosition); - } - setPreviewDragHiddenState(drapOut); - return true; - } - return false; - } else if (action == MotionEvent.ACTION_UP) { - if (previewDrag != null) { - int currentXPosition = params.leftMargin + (int) (event.getX() - previewDrag.x); - - previewSnapAnimation.cancel(); - previewDrag = null; - v.setElevation(v.getContext().getResources().getDimension(R.dimen.call_preview_elevation)); - int ml = 0, mr = 0, mt = 0, mb = 0; - - FrameLayout.LayoutParams hp = (FrameLayout.LayoutParams) binding.pluginPreviewHandle.getLayoutParams(); - if (params.leftMargin + (v.getWidth() / 2) > parent.getWidth() / 2) { - params.removeRule(RelativeLayout.ALIGN_PARENT_LEFT); - params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); - mr = (int) (parent.getWidth() - v.getWidth() - v.getX()); - previewPosition = PreviewPosition.RIGHT; - hp.gravity = Gravity.CENTER_VERTICAL | Gravity.LEFT; - } else { - params.removeRule(RelativeLayout.ALIGN_PARENT_RIGHT); - params.addRule(RelativeLayout.ALIGN_PARENT_LEFT); - ml = (int) v.getX(); - previewPosition = PreviewPosition.LEFT; - hp.gravity = Gravity.CENTER_VERTICAL | Gravity.RIGHT; - } - binding.pluginPreviewHandle.setLayoutParams(hp); - - if (params.topMargin + (v.getHeight() / 2) > parent.getHeight() / 2) { - params.removeRule(RelativeLayout.ALIGN_PARENT_TOP); - params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - mb = (int) (parent.getHeight() - v.getHeight() - v.getY()); - } else { - params.removeRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - params.addRule(RelativeLayout.ALIGN_PARENT_TOP); - mt = (int) v.getY(); - } - previewMargins[0] = ml; - previewMargins[1] = mt; - previewMargins[2] = mr; - previewMargins[3] = mb; - params.setMargins(ml, mt, mr, mb); - v.setLayoutParams(params); - - float outPosition = binding.pluginPreviewContainer.getWidth() * 0.85f; - previewHiddenState = currentXPosition < 0 - ? Math.min(1.f, -currentXPosition / outPosition) - : ((currentXPosition + v.getWidth() > parent.getWidth()) - ? Math.min(1.f, (currentXPosition + v.getWidth() - parent.getWidth()) / outPosition) - : 0.f); - setPreviewDragHiddenState(previewHiddenState); - - previewSnapAnimation.start(); - return true; - } - return false; - } else { - return false; - } - }); - - binding.dialpadEditText.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - presenter.sendDtmf(s.subSequence(start, start + count)); - } - - @Override - public void afterTextChanged(Editable s) { - } - }); - } - - private void configurePreview(int width, float animatedFraction) { - Context context = getContext(); - if (context == null || binding == null) - return; - float margin = context.getResources().getDimension(R.dimen.call_preview_margin); - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.previewContainer.getLayoutParams(); - float r = 1.f - animatedFraction; - float hideMargin = 0.f; - float targetHiddenState = 0.f; - if (previewHiddenState > 0.f) { - targetHiddenState = 1.f; - float v = width * 0.85f * animatedFraction; - hideMargin = previewPosition == PreviewPosition.RIGHT ? v : -v; - } - setPreviewDragHiddenState(previewHiddenState * r + targetHiddenState * animatedFraction); - - float f = margin * animatedFraction; - params.setMargins( - (int) (previewMargins[0] * r + f + hideMargin), - (int) (previewMargins[1] * r + f), - (int) (previewMargins[2] * r + f - hideMargin), - (int) (previewMargins[3] * r + f)); - binding.previewContainer.setLayoutParams(params); - binding.pluginPreviewContainer.setLayoutParams(params); - } - - /** - * Releases current wakelock and acquires a new proximity wakelock if current call is audio only. - * - * @param isAudioOnly true if it is an audio call - */ - @SuppressLint("WakelockTimeout") - @Override - public void handleCallWakelock(boolean isAudioOnly) { - if (isAudioOnly) { - if (mScreenWakeLock != null && mScreenWakeLock.isHeld()) { - mScreenWakeLock.release(); - } - PowerManager powerManager = (PowerManager) requireContext().getSystemService(Context.POWER_SERVICE); - if (powerManager != null) { - mScreenWakeLock = powerManager.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP | PowerManager.ON_AFTER_RELEASE, "ring:callLock"); - mScreenWakeLock.setReferenceCounted(false); - - if (mScreenWakeLock != null && !mScreenWakeLock.isHeld()) { - mScreenWakeLock.acquire(); - } - } - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - if (mOrientationListener != null) { - mOrientationListener.disable(); - mOrientationListener = null; - } - mCompositeDisposable.clear(); - if (mScreenWakeLock != null && mScreenWakeLock.isHeld()) { - mScreenWakeLock.release(); - } - binding = null; - } - - @Override - public void onDestroy() { - super.onDestroy(); - mCompositeDisposable.dispose(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requestCode != REQUEST_PERMISSION_INCOMING && requestCode != REQUEST_PERMISSION_OUTGOING) - return; - for (int i = 0, n = permissions.length; i < n; i++) { - boolean audioGranted = mDeviceRuntimeService.hasAudioPermission(); - boolean granted = grantResults[i] == PackageManager.PERMISSION_GRANTED; - switch (permissions[i]) { - case Manifest.permission.CAMERA: - presenter.cameraPermissionChanged(granted); - if (audioGranted) { - initializeCall(requestCode == REQUEST_PERMISSION_INCOMING); - } - break; - case Manifest.permission.RECORD_AUDIO: - presenter.audioPermissionChanged(granted); - initializeCall(requestCode == REQUEST_PERMISSION_INCOMING); - break; - } - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (requestCode == REQUEST_CODE_ADD_PARTICIPANT) { - if (resultCode == Activity.RESULT_OK && data != null) { - ConversationPath path = ConversationPath.fromUri(data.getData()); - if (path != null) { - presenter.addConferenceParticipant(path.getAccountId(), path.getConversationUri()); - } - } - } else if (requestCode == REQUEST_CODE_SCREEN_SHARE) { - if (resultCode == Activity.RESULT_OK && data != null) { - try { - startScreenShare(mProjectionManager.getMediaProjection(resultCode, data)); - } catch (Exception e) { - Log.w(TAG, "Error starting screen sharing", e); - } - } else { - binding.callScreenshareBtn.setChecked(false); - } - } - } - - @Override - public void onCreateOptionsMenu(@NonNull Menu m, @NonNull MenuInflater inf) { - super.onCreateOptionsMenu(m, inf); - inf.inflate(R.menu.ac_call, m); - dialPadBtn = m.findItem(R.id.menuitem_dialpad); - pluginsMenuBtn = m.findItem(R.id.menuitem_video_plugins); - } - - @Override - public void onPrepareOptionsMenu(@NonNull Menu menu) { - super.onPrepareOptionsMenu(menu); - presenter.prepareOptionMenu(); - } - - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - super.onOptionsItemSelected(item); - int itemId = item.getItemId(); - if (itemId == android.R.id.home) { - presenter.chatClick(); - } else if (itemId == R.id.menuitem_dialpad) { - presenter.dialpadClick(); - } else if (itemId == R.id.menuitem_video_plugins) { - displayVideoPluginsCarousel(); - } - return true; - } - - @Override - public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) { - AppCompatActivity activity = (AppCompatActivity) getActivity(); - ActionBar actionBar = activity == null ? null : activity.getSupportActionBar(); - if (actionBar != null) { - if (isInPictureInPictureMode) { - actionBar.hide(); - } else { - mBackstackLost = true; - actionBar.show(); - } - } - presenter.pipModeChanged(isInPictureInPictureMode); - } - - @Override - public void displayContactBubble(final boolean display) { - if (binding != null) - binding.contactBubbleLayout.getHandler().post(() -> { - if (binding != null) binding.contactBubbleLayout.setVisibility(display ? View.VISIBLE : View.GONE); - }); - } - - @Override - public void displayVideoSurface(final boolean displayVideoSurface, final boolean displayPreviewContainer) { - binding.videoSurface.setVisibility(displayVideoSurface ? View.VISIBLE : View.GONE); - if (choosePluginMode) { - binding.pluginPreviewSurface.setVisibility(displayPreviewContainer ? View.VISIBLE : View.GONE); - binding.pluginPreviewContainer.setVisibility(displayPreviewContainer ? View.VISIBLE : View.GONE); - binding.previewContainer.setVisibility(View.GONE); - } else { - binding.pluginPreviewSurface.setVisibility(View.GONE); - binding.pluginPreviewContainer.setVisibility(View.GONE); - binding.previewContainer.setVisibility(displayPreviewContainer ? View.VISIBLE : View.GONE); - } - updateMenu(); - } - - @Override - public void displayPreviewSurface(final boolean display) { - if (display) { - binding.videoSurface.setZOrderOnTop(false); - binding.videoSurface.setZOrderMediaOverlay(false); - } else { - binding.videoSurface.setZOrderMediaOverlay(true); - binding.videoSurface.setZOrderOnTop(true); - } - } - - @Override - public void displayHangupButton(boolean display) { - Log.w(TAG, "displayHangupButton " + display); - display &= !choosePluginMode; - binding.callControlGroup.setVisibility(display ? View.VISIBLE : View.GONE); - binding.callHangupBtn.setVisibility(display ? View.VISIBLE : View.GONE); - binding.confControlGroup.setVisibility((mConferenceMode && display) ? View.VISIBLE : View.GONE); - } - - @Override - public void displayDialPadKeyboard() { - binding.dialpadEditText.requestFocus(); - InputMethodManager imm = (InputMethodManager) binding.dialpadEditText.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY); - } - - @Override - public void switchCameraIcon(boolean isFront) { - binding.callCameraFlipBtn.setImageResource(isFront ? R.drawable.baseline_camera_front_24 : R.drawable.baseline_camera_rear_24); - } - - @Override - public void updateAudioState(HardwareService.AudioState state) { - binding.callSpeakerBtn.setChecked(state.getOutputType() == HardwareService.AudioOutput.SPEAKERS); - } - - @Override - public void updateMenu() { - requireActivity().invalidateOptionsMenu(); - } - - @Override - public void updateTime(final long duration) { - if (binding != null) { - if (duration <= 0) - binding.callStatusTxt.setText(null); - else - binding.callStatusTxt.setText(String.format(Locale.getDefault(), "%d:%02d:%02d", duration / 3600, duration % 3600 / 60, duration % 60)); - } - } - - @Override - @SuppressLint("RestrictedApi") - public void updateContactBubble(@NonNull final List<Call> contacts) { - Log.w(TAG, "updateContactBubble " + contacts.size()); - - String username = contacts.size() > 1 ? "Conference with " + contacts.size() + " people" : contacts.get(0).getContact().getDisplayName(); - String displayName = contacts.size() > 1 ? null : contacts.get(0).getContact().getDisplayName(); - - boolean hasProfileName = displayName != null && !displayName.contentEquals(username); - - AppCompatActivity activity = (AppCompatActivity) getActivity(); - if (activity != null) { - ActionBar ab = activity.getSupportActionBar(); - if (ab != null) { - if (hasProfileName) { - ab.setTitle(displayName); - ab.setSubtitle(username); - } else { - ab.setTitle(username); - ab.setSubtitle(null); - } - ab.setDisplayShowTitleEnabled(true); - } - } - - if (hasProfileName) { - binding.contactBubbleNumTxt.setVisibility(View.VISIBLE); - binding.contactBubbleTxt.setText(displayName); - binding.contactBubbleNumTxt.setText(username); - } else { - binding.contactBubbleNumTxt.setVisibility(View.GONE); - binding.contactBubbleTxt.setText(username); - } - - binding.contactBubble.setImageDrawable( - new AvatarDrawable.Builder() - .withContact(contacts.get(0).getContact()) - .withCircleCrop(true) - .withPresence(false) - .build(getActivity()) - ); - - } - - @SuppressLint("RestrictedApi") - @Override - public void updateConfInfo(List<Conference.ParticipantInfo> participantInfo) { - Log.w(TAG, "updateConfInfo " + participantInfo); - - mConferenceMode = participantInfo.size() > 1; - - binding.participantLabelContainer.removeAllViews(); - if (!participantInfo.isEmpty()) { - LayoutInflater inflater = LayoutInflater.from(binding.participantLabelContainer.getContext()); - for (Conference.ParticipantInfo i : participantInfo) { - String displayName = i.contact.getDisplayName(); - if (!TextUtils.isEmpty(displayName)) { - ItemParticipantLabelBinding label = ItemParticipantLabelBinding.inflate(inflater); - PercentFrameLayout.LayoutParams params = new PercentFrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.getPercentLayoutInfo().leftMarginPercent = i.x / (float) mVideoWidth; - params.getPercentLayoutInfo().topMarginPercent = i.y / (float) mVideoHeight; - params.getPercentLayoutInfo().rightMarginPercent = 1.f - (i.x + i.w) / (float) mVideoWidth; - //params.getPercentLayoutInfo().rightMarginPercent = (i.x + i.w) / (float) mVideoWidth; - label.participantName.setText(displayName); - label.moderator.setVisibility(i.isModerator ? View.VISIBLE : View.GONE); - label.mute.setVisibility(i.audioMuted ? View.VISIBLE : View.GONE); - binding.participantLabelContainer.addView(label.getRoot(), params); - } - } - } - binding.participantLabelContainer.setVisibility(participantInfo.isEmpty() ? View.GONE : View.VISIBLE); - - if (participantInfo.isEmpty() || participantInfo.size() < 2) { - binding.confControlGroup.setVisibility(View.GONE); - } else { - binding.confControlGroup.setVisibility(View.VISIBLE); - if (confAdapter == null) { - confAdapter = new ConfParticipantAdapter((view, info) -> { - if (presenter == null) - return; - boolean maximized = presenter.isMaximized(info); - PopupMenu popup = new PopupMenu(view.getContext(), view); - popup.inflate(R.menu.conference_participant_actions); - popup.setOnMenuItemClickListener(item -> { - if (presenter == null) - return false; - int itemId = item.getItemId(); - if (itemId == R.id.conv_contact_details) { - presenter.openParticipantContact(info); - } else if (itemId == R.id.conv_contact_hangup) { - presenter.hangupParticipant(info); - } else if (itemId == R.id.conv_mute) { - //call.muteAudio(!info.audioMuted); - presenter.muteParticipant(info, !info.audioMuted); - } else if (itemId == R.id.conv_contact_maximize) { - presenter.maximizeParticipant(info); - } else { - return false; - } - return true; - }); - MenuBuilder menu = (MenuBuilder) popup.getMenu(); - MenuItem maxItem = menu.findItem(R.id.conv_contact_maximize); - MenuItem muteItem = menu.findItem(R.id.conv_mute); - if (maximized) { - maxItem.setTitle(R.string.action_call_minimize); - maxItem.setIcon(R.drawable.baseline_close_fullscreen_24); - } else { - maxItem.setTitle(R.string.action_call_maximize); - maxItem.setIcon(R.drawable.baseline_open_in_full_24); - } - if (!info.audioMuted) { - muteItem.setTitle(R.string.action_call_mute); - muteItem.setIcon(R.drawable.baseline_mic_off_24); - } else { - muteItem.setTitle(R.string.action_call_unmute); - muteItem.setIcon(R.drawable.baseline_mic_24); - } - MenuPopupHelper menuHelper = new MenuPopupHelper(view.getContext(), menu, view); - menuHelper.setGravity(Gravity.END); - menuHelper.setForceShowIcon(true); - menuHelper.show(); - }); - } - confAdapter.updateFromCalls(participantInfo); - if (binding.confControlGroup.getAdapter() == null) - binding.confControlGroup.setAdapter(confAdapter); - } - } - - @Override - public void updateParticipantRecording(Set<Contact> contacts) { - if (contacts.size() == 0) { - binding.recordLayout.setVisibility(View.INVISIBLE); - binding.recordIndicator.clearAnimation(); - return; - } - StringBuilder names = new StringBuilder(); - Iterator<Contact> contact = contacts.iterator(); - for (int i = 0; i < contacts.size(); i++) { - names.append(" ").append(contact.next().getDisplayName()); - if (i != contacts.size() - 1) { - names.append(","); - } - } - binding.recordLayout.setVisibility(View.VISIBLE); - binding.recordIndicator.setAnimation(getBlinkingAnimation()); - binding.recordName.setText(getString(R.string.remote_recording, names)); - } - - @Override - public void updateCallStatus(final Call.CallStatus callStatus) { - binding.callStatusTxt.setText(callStateToHumanState(callStatus)); - } - - @Override - public void initMenu(boolean isSpeakerOn, boolean displayFlip, boolean canDial, - boolean showPluginBtn, boolean onGoingCall) { - if (binding != null) { - binding.callCameraFlipBtn.setVisibility(displayFlip ? View.VISIBLE : View.GONE); - } - if (dialPadBtn != null) { - dialPadBtn.setVisible(canDial); - } - - if (pluginsMenuBtn != null) { - pluginsMenuBtn.setVisible(showPluginBtn); - } - updateMenu(); - } - - @Override - public void initNormalStateDisplay(final boolean audioOnly, boolean isMuted) { - Log.w(TAG, "initNormalStateDisplay"); - binding.shapeRipple.stopRipple(); - - binding.callAcceptBtn.setVisibility(View.GONE); - binding.callRefuseBtn.setVisibility(View.GONE); - binding.callControlGroup.setVisibility(View.VISIBLE); - binding.callHangupBtn.setVisibility(View.VISIBLE); - - binding.contactBubbleLayout.setVisibility(audioOnly ? View.VISIBLE : View.GONE); - binding.callMicBtn.setChecked(isMuted); - - requireActivity().invalidateOptionsMenu(); - CallActivity callActivity = (CallActivity) getActivity(); - if (callActivity != null) { - callActivity.showSystemUI(); - } - } - - @Override - public void initIncomingCallDisplay() { - Log.w(TAG, "initIncomingCallDisplay"); - - binding.callAcceptBtn.setVisibility(View.VISIBLE); - binding.callRefuseBtn.setVisibility(View.VISIBLE); - binding.callControlGroup.setVisibility(View.GONE); - binding.callHangupBtn.setVisibility(View.GONE); - - binding.contactBubbleLayout.setVisibility(View.VISIBLE); - requireActivity().invalidateOptionsMenu(); - } - - @Override - public void initOutGoingCallDisplay() { - Log.w(TAG, "initOutGoingCallDisplay"); - - binding.callAcceptBtn.setVisibility(View.GONE); - binding.callRefuseBtn.setVisibility(View.VISIBLE); - binding.callControlGroup.setVisibility(View.GONE); - binding.callHangupBtn.setVisibility(View.GONE); - - binding.contactBubbleLayout.setVisibility(View.VISIBLE); - requireActivity().invalidateOptionsMenu(); - } - - @Override - public void resetPreviewVideoSize(int previewWidth, int previewHeight, int rot) { - if (previewWidth == -1 && previewHeight == -1) - return; - mPreviewWidth = previewWidth; - mPreviewHeight = previewHeight; - boolean flip = (rot % 180) != 0; - binding.previewSurface.setAspectRatio(flip ? mPreviewHeight : mPreviewWidth, flip ? mPreviewWidth : mPreviewHeight); - } - - @Override - public void resetPluginPreviewVideoSize(int previewWidth, int previewHeight, int rot) { - if (previewWidth == -1 && previewHeight == -1) - return; - mPreviewWidth = previewWidth; - mPreviewHeight = previewHeight; - boolean flip = (rot % 180) != 0; - binding.pluginPreviewSurface.setAspectRatio(flip ? mPreviewHeight : mPreviewWidth, flip ? mPreviewWidth : mPreviewHeight); - } - - @Override - public void resetVideoSize(int videoWidth, int videoHeight) { - ViewGroup rootView = (ViewGroup) getView(); - if (rootView == null) - return; - double videoRatio = videoWidth / (double) videoHeight; - double screenRatio = rootView.getWidth() / (double) rootView.getHeight(); - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.videoSurface.getLayoutParams(); - int oldW = params.width; - int oldH = params.height; - if (videoRatio >= screenRatio) { - params.width = RelativeLayout.LayoutParams.MATCH_PARENT; - params.height = (int) (videoHeight * (double) rootView.getWidth() / (double) videoWidth); - } else { - params.height = RelativeLayout.LayoutParams.MATCH_PARENT; - params.width = (int) (videoWidth * (double) rootView.getHeight() / (double) videoHeight); - } - - if (oldW != params.width || oldH != params.height) { - binding.videoSurface.setLayoutParams(params); - } - mVideoWidth = videoWidth; - mVideoHeight = videoHeight; - } - - private void configureTransform(int viewWidth, int viewHeight) { - Activity activity = getActivity(); - if (null == binding || null == activity) { - return; - } - int rotation = activity.getWindowManager().getDefaultDisplay().getRotation(); - boolean rot = Surface.ROTATION_90 == rotation || Surface.ROTATION_270 == rotation; - // Log.w(TAG, "configureTransform " + viewWidth + "x" + viewHeight + " rot=" + rot + " mPreviewWidth=" + mPreviewWidth + " mPreviewHeight=" + mPreviewHeight); - Matrix matrix = new Matrix(); - RectF viewRect = new RectF(0, 0, viewWidth, viewHeight); - float centerX = viewRect.centerX(); - float centerY = viewRect.centerY(); - if (rot) { - RectF bufferRect = new RectF(0, 0, mPreviewHeight, mPreviewWidth); - bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY()); - matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL); - float scale = Math.max( - (float) viewHeight / mPreviewHeight, - (float) viewWidth / mPreviewWidth); - matrix.postScale(scale, scale, centerX, centerY); - matrix.postRotate(90 * (rotation - 2), centerX, centerY); - } else if (Surface.ROTATION_180 == rotation) { - matrix.postRotate(180, centerX, centerY); - } - if (!choosePluginMode) { -// binding.pluginPreviewSurface.setTransform(matrix); -// } -// else { - binding.previewSurface.setTransform(matrix); - } - } - - @Override - public void goToConversation(String accountId, Uri conversationId) { - Context context = requireContext(); - if (DeviceUtils.isTablet(context)) { - startActivity(new Intent(DRingService.ACTION_CONV_ACCEPT, ConversationPath.toUri(accountId, conversationId), context, HomeActivity.class)); - } else { - startActivityForResult(new Intent(Intent.ACTION_VIEW, ConversationPath.toUri(accountId, conversationId), context, ConversationActivity.class) - .setFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT), HomeActivity.REQUEST_CODE_CONVERSATION); - } - } - - @Override - public void goToAddContact(Contact contact) { - startActivityForResult(ActionHelper.getAddNumberIntentForContact(contact), - ConversationFragment.REQ_ADD_CONTACT); - } - - @Override - public void goToContact(String accountId, Contact contact) { - startActivity(new Intent(Intent.ACTION_VIEW, ConversationPath.toUri(accountId, contact.getUri())) - .setClass(requireContext(), ContactDetailsActivity.class)); - } - - /** - * Checks if permissions are accepted for camera and microphone. Takes into account whether call is incoming and outgoing, and requests permissions if not available. - * Initializes the call if permissions are accepted. - * - * @param isIncoming true if call is incoming, false for outgoing - * @see #initializeCall(boolean) initializeCall - */ - @Override - public void prepareCall(boolean isIncoming) { - boolean audioGranted = mDeviceRuntimeService.hasAudioPermission(); - boolean audioOnly; - int permissionType; - - if (isIncoming) { - audioOnly = presenter.isAudioOnly(); - permissionType = REQUEST_PERMISSION_INCOMING; - } else { - Bundle args = getArguments(); - audioOnly = args != null && args.getBoolean(KEY_AUDIO_ONLY); - permissionType = REQUEST_PERMISSION_OUTGOING; - } - if (!audioOnly) { - boolean videoGranted = mDeviceRuntimeService.hasVideoPermission(); - - if ((!audioGranted || !videoGranted) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - ArrayList<String> perms = new ArrayList<>(); - if (!videoGranted) { - perms.add(Manifest.permission.CAMERA); - } - if (!audioGranted) { - perms.add(Manifest.permission.RECORD_AUDIO); - } - requestPermissions(perms.toArray(new String[perms.size()]), permissionType); - } else if (audioGranted && videoGranted) { - initializeCall(isIncoming); - } - } else { - if (!audioGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, permissionType); - } else if (audioGranted) { - initializeCall(isIncoming); - } - } - } - - /** - * Starts a call. Takes into account whether call is incoming or outgoing. - * - * @param isIncoming true if call is incoming, false for outgoing - */ - private void initializeCall(boolean isIncoming) { - if (isIncoming) { - presenter.acceptCall(); - } else { - Bundle args; - args = getArguments(); - if (args != null) { - ConversationPath conversation = ConversationPath.fromBundle(args); - presenter.initOutGoing(conversation.getAccountId(), - conversation.getConversationUri(), - args.getString(Intent.EXTRA_PHONE_NUMBER), - args.getBoolean(KEY_AUDIO_ONLY)); - } - } - } - - @Override - public void finish() { - Activity activity = getActivity(); - if (activity != null) { - activity.finishAndRemoveTask(); - if (mBackstackLost) { - startActivity(Intent.makeMainActivity(new ComponentName(activity, HomeActivity.class)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - } - } - } - - public void speakerClicked() { - presenter.speakerClick(binding.callSpeakerBtn.isChecked()); - } - - private void startScreenShare(MediaProjection mediaProjection) { - if (presenter.startScreenShare(mediaProjection)) { - if(choosePluginMode) { - binding.pluginPreviewSurface.setVisibility(View.GONE); - } else { - binding.previewSurface.setVisibility(View.GONE); - } - } else { - Toast.makeText(requireContext(), "Can't start screen sharing", Toast.LENGTH_SHORT).show(); - } - } - - private void stopShareScreen() { - binding.previewSurface.setVisibility(View.VISIBLE); - presenter.stopScreenShare(); - } - - public void shareScreenClicked(boolean checked) { - if (!checked) { - stopShareScreen(); - } else { - startActivityForResult(mProjectionManager.createScreenCaptureIntent(), REQUEST_CODE_SCREEN_SHARE); - } - } - - public void micClicked() { - presenter.muteMicrophoneToggled(binding.callMicBtn.isChecked()); - binding.callMicBtn.setImageResource(binding.callMicBtn.isChecked()? R.drawable.baseline_mic_off_24 : R.drawable.baseline_mic_24); - } - - public void hangUpClicked() { - presenter.hangupCall(); - } - - public void refuseClicked() { - presenter.refuseCall(); - } - - public void acceptClicked() { - prepareCall(true); - } - - public void cameraFlip() { - presenter.switchVideoInputClick(); - } - - public void addParticipant() { - presenter.startAddParticipant(); - } - - @Override - public void startAddParticipant(String conferenceId) { - startActivityForResult( - new Intent(Intent.ACTION_PICK) - .setClass(requireActivity(), ConversationSelectionActivity.class) - .putExtra(KEY_CONF_ID, conferenceId), - CallFragment.REQUEST_CODE_ADD_PARTICIPANT); - } - - @Override - public void toggleCallMediaHandler(String id, String callId, boolean toggle) { - JamiService.toggleCallMediaHandler(id, callId, toggle); - } - - public Map<String, String> getCallMediaHandlerDetails(String id) { - return JamiService.getCallMediaHandlerDetails(id).toNative(); - } - - @Override - public void positiveMediaButtonClicked() { - presenter.positiveButtonClicked(); - } - - @Override - public void negativeMediaButtonClicked() { - presenter.negativeButtonClicked(); - } - - @Override - public void toggleMediaButtonClicked() { - presenter.toggleButtonClicked(); - } - - public boolean displayPluginsButton() { - return JamiService.getPluginsEnabled() && JamiService.getCallMediaHandlers().size() > 0; - } - - @Override - public void onConfigurationChanged(@NonNull Configuration newConfig) { - super.onConfigurationChanged(newConfig); - // Reset the padding of the RecyclerPicker on each - rp.setFirstLastElementsWidths(112, 112); - binding.recyclerPicker.setVisibility(View.GONE); - if (choosePluginMode) { - displayHangupButton(false); - binding.recyclerPicker.setVisibility(View.VISIBLE); - movePreview(true); - if (previousPluginPosition != -1) { - rp.scrollToPosition(previousPluginPosition); - } - } else { - movePreview(false); - } - } - - public void toggleVideoPluginsCarousel(boolean toggle) { - if (choosePluginMode) { - if (toggle) { - binding.recyclerPicker.setVisibility(View.VISIBLE); - movePreview(true); - } else { - binding.recyclerPicker.setVisibility(View.INVISIBLE); - movePreview(false); - } - } - } - - public void movePreview(boolean up) { - // Move the preview container (cardview) by a certain margin - if(up) { - animation.setIntValues(12, 128); - } else { - animation.setIntValues(128, 12); - } - animation.start(); - } - - /** - * Function that is called to show/hide the plugins recycler viewer and update UI - */ - @SuppressLint("UseCompatLoadingForDrawables") - public void displayVideoPluginsCarousel() { - choosePluginMode = !choosePluginMode; - - Context context = requireActivity(); - - // Create callMediaHandlers and videoPluginsItems in a lazy manner - if (pluginsModeFirst) { - // Init - callMediaHandlers = JamiService.getCallMediaHandlers(); - List<Drawable> videoPluginsItems = new ArrayList<>(callMediaHandlers.size() + 1); - - videoPluginsItems.add(context.getDrawable(R.drawable.baseline_cancel_24)); - // Search for plugin call media handlers icons - // If a call media handler doesn't have an icon use a standard android icon - for (String callMediaHandler : callMediaHandlers) { - Map<String, String> details = getCallMediaHandlerDetails(callMediaHandler); - String drawablePath = details.get("iconPath"); - if (drawablePath != null && drawablePath.endsWith("svg")) - drawablePath = drawablePath.replace(".svg", ".png"); - Drawable handlerIcon = Drawable.createFromPath(drawablePath); - if (handlerIcon == null) { - handlerIcon = context.getDrawable(R.drawable.ic_jami); - } - videoPluginsItems.add(handlerIcon); - } - - rp.updateData(videoPluginsItems); - - pluginsModeFirst = false; - } - - if (choosePluginMode) { - // hide hang up button and other call buttons - displayHangupButton(false); - // Display the plugins recyclerpicker - binding.recyclerPicker.setVisibility(View.VISIBLE); - movePreview(true); - - // Start loading the first or previous plugin if one was active - if(callMediaHandlers.size() > 0) { - // If no previous plugin was active, take the first, else previous - int position; - if (previousPluginPosition < 1) { - rp.scrollToPosition(1); - position = 1; - previousPluginPosition = 1; - } else { - position = previousPluginPosition; - } - String callMediaId = callMediaHandlers.get(position-1); - presenter.startPlugin(callMediaId); - } - - } else { - if (previousPluginPosition > 0) { - String callMediaId = callMediaHandlers. - get(previousPluginPosition-1); - - presenter.toggleCallMediaHandler(callMediaId, false); - rp.scrollToPosition(previousPluginPosition); - } - presenter.stopPlugin(); - binding.recyclerPicker.setVisibility(View.GONE); - movePreview(false); - displayHangupButton(true); - } - - //change preview image - displayVideoSurface(true,true); - } - - /** - * Called whenever a plugin drawable in the recycler picker is clicked or scrolled to - */ - @Override - public void onItemSelected(int position) { - Log.i(TAG, "selected position: " + position); - /* If there was a different plugin before, unload it - * If previousPluginPosition = -1 or 0, there was no plugin - */ - if (previousPluginPosition > 0) { - String callMediaId = callMediaHandlers.get(previousPluginPosition-1); - presenter.toggleCallMediaHandler(callMediaId, false); - } - - if (position > 0) { - previousPluginPosition = position; - String callMediaId = callMediaHandlers.get(position-1); - presenter.toggleCallMediaHandler(callMediaId, true); - } - } - - - /** - * Called whenever a plugin drawable in the recycler picker is clicked - */ - @Override - public void onItemClicked(int position) { - Log.i(TAG, "selected position: " + position); - if (position == 0) { - /* If there was a different plugin before, unload it - * If previousPluginPosition = -1 or 0, there was no plugin - */ - if (previousPluginPosition > 0) { - String callMediaId = callMediaHandlers.get(previousPluginPosition-1); - presenter.toggleCallMediaHandler(callMediaId, false); - rp.scrollToPosition(previousPluginPosition); - } - - CallActivity callActivity = (CallActivity) getActivity(); - if (callActivity != null) { - callActivity.showSystemUI(); - } - - toggleVideoPluginsCarousel(false); - displayVideoPluginsCarousel(); - } - } - - private Animation getBlinkingAnimation() { - Animation animation = new AlphaAnimation(1, 0); - animation.setDuration(400); - animation.setInterpolator(new LinearInterpolator()); - animation.setRepeatCount(Animation.INFINITE); - animation.setRepeatMode(Animation.REVERSE); - return animation; - } - -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/CallFragment.kt b/ring-android/app/src/main/java/cx/ring/fragments/CallFragment.kt new file mode 100644 index 000000000..0ffe99af7 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/fragments/CallFragment.kt @@ -0,0 +1,1480 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.fragments + +import android.Manifest +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.app.PendingIntent +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.graphics.* +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.util.Log +import android.util.Rational +import android.view.* +import android.view.TextureView.SurfaceTextureListener +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.DecelerateInterpolator +import android.view.animation.LinearInterpolator +import android.view.inputmethod.InputMethodManager +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.RelativeLayout +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.view.menu.MenuPopupHelper +import androidx.appcompat.widget.PopupMenu +import androidx.databinding.DataBindingUtil +import androidx.percentlayout.widget.PercentFrameLayout +import com.rodolfonavalon.shaperipplelibrary.model.Circle +import cx.ring.R +import cx.ring.adapters.ConfParticipantAdapter +import cx.ring.adapters.ConfParticipantAdapter.ConfParticipantSelected +import cx.ring.client.* +import cx.ring.databinding.FragCallBinding +import cx.ring.databinding.ItemParticipantLabelBinding +import cx.ring.mvp.BaseSupportFragment +import cx.ring.plugins.RecyclerPicker.RecyclerPicker +import cx.ring.plugins.RecyclerPicker.RecyclerPickerLayoutManager.ItemSelectedListener +import cx.ring.service.DRingService +import cx.ring.utils.ActionHelper +import cx.ring.utils.ConversationPath +import cx.ring.utils.DeviceUtils.isTablet +import cx.ring.utils.DeviceUtils.isTv +import cx.ring.utils.MediaButtonsHelper.MediaButtonsHelperCallback +import cx.ring.views.AvatarDrawable +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.call.CallPresenter +import net.jami.call.CallView +import net.jami.daemon.JamiService +import net.jami.model.Call +import net.jami.model.Call.CallStatus +import net.jami.model.Conference.ParticipantInfo +import net.jami.model.Contact +import net.jami.model.Uri +import net.jami.services.DeviceRuntimeService +import net.jami.services.HardwareService +import net.jami.services.HardwareService.AudioState +import net.jami.services.NotificationService +import java.util.* +import javax.inject.Inject +import kotlin.math.min + +@AndroidEntryPoint +class CallFragment : BaseSupportFragment<CallPresenter, CallView>(), CallView, + MediaButtonsHelperCallback, ItemSelectedListener { + private var binding: FragCallBinding? = null + private var mOrientationListener: OrientationEventListener? = null + private var dialPadBtn: MenuItem? = null + private var pluginsMenuBtn: MenuItem? = null + private var restartVideo = false + private var restartPreview = false + private var mScreenWakeLock: PowerManager.WakeLock? = null + private var mCurrentOrientation = 0 + private var mVideoWidth = -1 + private var mVideoHeight = -1 + private var mPreviewWidth = 720 + private var mPreviewHeight = 1280 + private var mPreviewSurfaceWidth = 0 + private var mPreviewSurfaceHeight = 0 + private lateinit var mProjectionManager: MediaProjectionManager + private var mBackstackLost = false + private var confAdapter: ConfParticipantAdapter? = null + private var mConferenceMode = false + var isChoosePluginMode = false + private set + private var pluginsModeFirst = true + private var callMediaHandlers: List<String>? = null + private var previousPluginPosition = -1 + private var rp: RecyclerPicker? = null + private val animation = ValueAnimator().apply { duration = 150 } + private var previewDrag: PointF? = null + private val previewSnapAnimation = ValueAnimator().apply { + duration = 250 + setFloatValues(0f, 1f) + interpolator = DecelerateInterpolator() + addUpdateListener { a -> configurePreview(mPreviewSurfaceWidth, a.animatedFraction) } + } + private val previewMargins = IntArray(4) + private var previewHiddenState = 0f + + private enum class PreviewPosition { LEFT, RIGHT } + private var previewPosition = PreviewPosition.RIGHT + + @Inject + lateinit var mDeviceRuntimeService: DeviceRuntimeService + + private val mCompositeDisposable = CompositeDisposable() + override fun initPresenter(presenter: CallPresenter) { + val args = requireArguments() + args.getString(KEY_ACTION)?.let { action -> + if (action == ACTION_PLACE_CALL) + prepareCall(false) + else if (action == ACTION_GET_CALL || action == CallActivity.ACTION_CALL_ACCEPT) + presenter.initIncomingCall(args.getString(KEY_CONF_ID)!!, action == ACTION_GET_CALL) + } + } + + override fun onUserLeave() { + presenter.requestPipMode() + } + + override fun enterPipMode(callId: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + val context = requireContext() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val paramBuilder = PictureInPictureParams.Builder() + if (binding!!.videoSurface.visibility == View.VISIBLE) { + val l = IntArray(2) + binding!!.videoSurface.getLocationInWindow(l) + val x = l[0] + val y = l[1] + val w = binding!!.videoSurface.width + val h = binding!!.videoSurface.height + val videoBounds = Rect(x, y, x + w, y + h) + paramBuilder.setAspectRatio(Rational(w, h)) + paramBuilder.setSourceRectHint(videoBounds) + } else { + return + } + val actions = ArrayList<RemoteAction>(1) + actions.add(RemoteAction(Icon.createWithResource(context, R.drawable.baseline_call_end_24), + getString(R.string.action_call_hangup), + getString(R.string.action_call_hangup), + PendingIntent.getService(context, Random().nextInt(), + Intent(DRingService.ACTION_CALL_END) + .setClass(context, JamiService::class.java) + .putExtra(NotificationService.KEY_CALL_ID, callId), PendingIntent.FLAG_ONE_SHOT))) + paramBuilder.setActions(actions) + try { + requireActivity().enterPictureInPictureMode(paramBuilder.build()) + } catch (e: Exception) { + Log.w(TAG, "Can't enter PIP mode", e) + } + } else if (isTv(context)) { + requireActivity().enterPictureInPictureMode() + } + } + + override fun onStart() { + super.onStart() + if (restartVideo && restartPreview) { + displayVideoSurface(true, !presenter.isPipMode) + restartVideo = false + restartPreview = false + } else if (restartVideo) { + displayVideoSurface(displayVideoSurface = true, displayPreviewContainer = false) + restartVideo = false + } + } + + override fun onStop() { + super.onStop() + previewSnapAnimation.cancel() + binding?.let { binding -> + if (binding.videoSurface.visibility == View.VISIBLE) { + restartVideo = true + } + if (!isChoosePluginMode) { + if (binding.previewContainer.visibility == View.VISIBLE) { + restartPreview = true + } + } else { + if (binding.pluginPreviewContainer.visibility == View.VISIBLE) { + restartPreview = true + presenter.stopPlugin() + } + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return (DataBindingUtil.inflate(inflater, R.layout.frag_call, container, false) as FragCallBinding).also { b -> + b.presenter = this + binding = b + rp = RecyclerPicker(b.recyclerPicker, R.layout.item_picker, LinearLayout.HORIZONTAL, this) + .apply { setFirstLastElementsWidths(112, 112) } + }.root + } + + private val listener: SurfaceTextureListener = object : SurfaceTextureListener { + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + mPreviewSurfaceWidth = width + mPreviewSurfaceHeight = height + presenter.previewVideoSurfaceCreated(binding!!.previewSurface) + } + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + mPreviewSurfaceWidth = width + mPreviewSurfaceHeight = height + configurePreview(width, 1f) + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + presenter.previewVideoSurfaceDestroyed() + return true + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {} + } + + /** + * @param hiddenState 0.f if fully shown, 1.f if fully hidden. + */ + private fun setPreviewDragHiddenState(hiddenState: Float) { + binding?.let { binding -> + binding.previewSurface.alpha = 1f - 3 * hiddenState / 4 + binding.pluginPreviewSurface.alpha = 1f - 3 * hiddenState / 4 + binding.previewHandle.alpha = hiddenState + binding.pluginPreviewHandle.alpha = hiddenState + } + } + + private val previewTouchListener = object: View.OnTouchListener { + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + val action = event.actionMasked + val parent = v.parent as RelativeLayout + val params = v.layoutParams as RelativeLayout.LayoutParams + when (action) { + MotionEvent.ACTION_DOWN -> { + previewSnapAnimation.cancel() + previewDrag = PointF(event.x, event.y) + v.elevation = v.context.resources.getDimension(R.dimen.call_preview_elevation_dragged) + params.removeRule(RelativeLayout.ALIGN_PARENT_RIGHT) + params.removeRule(RelativeLayout.ALIGN_PARENT_BOTTOM) + params.addRule(RelativeLayout.ALIGN_PARENT_TOP) + params.addRule(RelativeLayout.ALIGN_PARENT_LEFT) + params.setMargins( + v.x.toInt(), + v.y.toInt(), + parent.width - (v.x.toInt() + v.width), + parent.height - (v.y.toInt() + v.height) + ) + v.layoutParams = params + return true + } + MotionEvent.ACTION_MOVE -> { + if (previewDrag != null) { + val currentXPosition = params.leftMargin + (event.x - previewDrag!!.x).toInt() + val currentYPosition = params.topMargin + (event.y - previewDrag!!.y).toInt() + params.setMargins( + currentXPosition, + currentYPosition, + -(currentXPosition + v.width - event.x.toInt()), + -(currentYPosition + v.height - event.y.toInt()) + ) + v.layoutParams = params + val outPosition = binding!!.previewContainer.width * 0.85f + var drapOut = 0f + if (currentXPosition < 0) { + drapOut = min(1f, -currentXPosition / outPosition) + } else if (currentXPosition + v.width > parent.width) { + drapOut = min(1f, (currentXPosition + v.width - parent.width) / outPosition) + } + setPreviewDragHiddenState(drapOut) + return true + } + return false + } + MotionEvent.ACTION_UP -> { + if (previewDrag != null) { + val currentXPosition = params.leftMargin + (event.x - previewDrag!!.x).toInt() + previewSnapAnimation.cancel() + previewDrag = null + v.elevation = v.context.resources.getDimension(R.dimen.call_preview_elevation) + var ml = 0 + var mr = 0 + var mt = 0 + var mb = 0 + val hp = binding!!.previewHandle.layoutParams as FrameLayout.LayoutParams + if (params.leftMargin + v.width / 2 > parent.width / 2) { + params.removeRule(RelativeLayout.ALIGN_PARENT_LEFT) + params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT) + mr = (parent.width - v.width - v.x).toInt() + previewPosition = PreviewPosition.RIGHT + hp.gravity = Gravity.CENTER_VERTICAL or Gravity.LEFT + } else { + params.removeRule(RelativeLayout.ALIGN_PARENT_RIGHT) + params.addRule(RelativeLayout.ALIGN_PARENT_LEFT) + ml = v.x.toInt() + previewPosition = PreviewPosition.LEFT + hp.gravity = Gravity.CENTER_VERTICAL or Gravity.RIGHT + } + binding!!.previewHandle.layoutParams = hp + if (params.topMargin + v.height / 2 > parent.height / 2) { + params.removeRule(RelativeLayout.ALIGN_PARENT_TOP) + params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM) + mb = (parent.height - v.height - v.y).toInt() + } else { + params.removeRule(RelativeLayout.ALIGN_PARENT_BOTTOM) + params.addRule(RelativeLayout.ALIGN_PARENT_TOP) + mt = v.y.toInt() + } + previewMargins[0] = ml + previewMargins[1] = mt + previewMargins[2] = mr + previewMargins[3] = mb + params.setMargins(ml, mt, mr, mb) + v.layoutParams = params + val outPosition = binding!!.previewContainer.width * 0.85f + previewHiddenState = when { + currentXPosition < 0 -> + min(1f, -currentXPosition / outPosition) + currentXPosition + v.width > parent.width -> + min(1f, (currentXPosition + v.width - parent.width) / outPosition) + else -> 0f + } + setPreviewDragHiddenState(previewHiddenState) + previewSnapAnimation.start() + return true + } + return false + } + else -> return false + } + } + } + + @SuppressLint("ClickableViewAccessibility", "RtlHardcoded", "WakelockTimeout") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setHasOptionsMenu(true) + super.onViewCreated(view, savedInstanceState) + mCurrentOrientation = resources.configuration.orientation + val dpRatio = requireActivity().resources.displayMetrics.density + animation.addUpdateListener { valueAnimator -> + binding?.let { binding -> + val upBy = valueAnimator.animatedValue as Int + val layoutParams = binding.previewContainer.layoutParams as RelativeLayout.LayoutParams + layoutParams.setMargins(0, 0, 0, (upBy * dpRatio).toInt()) + binding.previewContainer.layoutParams = layoutParams + } + } + val activity = activity + if (activity != null) { + activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + if (activity is AppCompatActivity) { + val ab = activity.supportActionBar + if (ab != null) { + ab.setHomeAsUpIndicator(R.drawable.baseline_chat_24) + ab.setDisplayHomeAsUpEnabled(true) + } + } + } + mProjectionManager = requireContext().getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + val powerManager = requireContext().getSystemService(Context.POWER_SERVICE) as PowerManager + mScreenWakeLock = powerManager.newWakeLock( + PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.ON_AFTER_RELEASE, + "ring:callLock" + ).apply { + setReferenceCounted(false) + if (!isHeld) + acquire() + } + binding?.let { binding -> + binding.videoSurface.holder.setFormat(PixelFormat.RGBA_8888) + binding.videoSurface.holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + presenter.videoSurfaceCreated(holder) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} + + override fun surfaceDestroyed(holder: SurfaceHolder) { + presenter.videoSurfaceDestroyed() + } + }) + binding.pluginPreviewSurface.holder.setFormat(PixelFormat.RGBA_8888) + binding.pluginPreviewSurface.holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + presenter.pluginSurfaceCreated(holder) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} + + override fun surfaceDestroyed(holder: SurfaceHolder) { + presenter.pluginSurfaceDestroyed() + } + }) + view.setOnSystemUiVisibilityChangeListener { visibility: Int -> + val ui = visibility and (View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN) == 0 + presenter.uiVisibilityChanged(ui) + } + val ui = view.systemUiVisibility and (View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN) == 0 + presenter.uiVisibilityChanged(ui) + view.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> resetVideoSize(mVideoWidth, mVideoHeight) } + val windowManager = view.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + mOrientationListener = object : OrientationEventListener(context) { + override fun onOrientationChanged(orientation: Int) { + val rot = windowManager.defaultDisplay.rotation + if (mCurrentOrientation != rot) { + mCurrentOrientation = rot + presenter.configurationChanged(rot) + } + } + }.apply { if (canDetectOrientation()) enable() } + binding.shapeRipple.rippleShape = Circle() + binding.callSpeakerBtn.isChecked = presenter.isSpeakerphoneOn + binding.callMicBtn.isChecked = presenter.isMicrophoneMuted + binding.pluginPreviewSurface.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + configureTransform(mPreviewSurfaceWidth, mPreviewSurfaceHeight) + } + binding.previewSurface.surfaceTextureListener = listener + binding.previewSurface.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + configureTransform(mPreviewSurfaceWidth, mPreviewSurfaceHeight) + } + binding.previewContainer.setOnTouchListener(previewTouchListener) + binding.pluginPreviewContainer.setOnTouchListener { v: View, event: MotionEvent -> + val action = event.actionMasked + val parent = v.parent as RelativeLayout + val params = v.layoutParams as RelativeLayout.LayoutParams + if (action == MotionEvent.ACTION_DOWN) { + previewSnapAnimation.cancel() + previewDrag = PointF(event.x, event.y) + v.elevation = v.context.resources.getDimension(R.dimen.call_preview_elevation_dragged) + params.removeRule(RelativeLayout.ALIGN_PARENT_RIGHT) + params.removeRule(RelativeLayout.ALIGN_PARENT_BOTTOM) + params.addRule(RelativeLayout.ALIGN_PARENT_TOP) + params.addRule(RelativeLayout.ALIGN_PARENT_LEFT) + params.setMargins( + v.x.toInt(), + v.y.toInt(), + parent.width - (v.x.toInt() + v.width), + parent.height - (v.y + .toInt() + v.height) + ) + v.layoutParams = params + return@setOnTouchListener true + } else if (action == MotionEvent.ACTION_MOVE) { + if (previewDrag != null) { + val currentXPosition = params.leftMargin + (event.x - previewDrag!!.x).toInt() + val currentYPosition = params.topMargin + (event.y - previewDrag!!.y).toInt() + params.setMargins( + currentXPosition, + currentYPosition, + -(currentXPosition + v.width - event.x.toInt()), + -(currentYPosition + v.height - event.y.toInt()) + ) + v.layoutParams = params + val outPosition = binding.pluginPreviewContainer.width * 0.85f + var drapOut = 0f + if (currentXPosition < 0) { + drapOut = min(1f, -currentXPosition / outPosition) + } else if (currentXPosition + v.width > parent.width) { + drapOut = min(1f, (currentXPosition + v.width - parent.width) / outPosition) + } + setPreviewDragHiddenState(drapOut) + return@setOnTouchListener true + } + return@setOnTouchListener false + } else if (action == MotionEvent.ACTION_UP) { + if (previewDrag != null) { + val currentXPosition = params.leftMargin + (event.x - previewDrag!!.x).toInt() + previewSnapAnimation.cancel() + previewDrag = null + v.elevation = v.context.resources.getDimension(R.dimen.call_preview_elevation) + var ml = 0; var mr = 0; var mt = 0; var mb = 0 + val hp = binding.pluginPreviewHandle.layoutParams as FrameLayout.LayoutParams + if (params.leftMargin + v.width / 2 > parent.width / 2) { + params.removeRule(RelativeLayout.ALIGN_PARENT_LEFT) + params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT) + mr = (parent.width - v.width - v.x).toInt() + previewPosition = PreviewPosition.RIGHT + hp.gravity = Gravity.CENTER_VERTICAL or Gravity.LEFT + } else { + params.removeRule(RelativeLayout.ALIGN_PARENT_RIGHT) + params.addRule(RelativeLayout.ALIGN_PARENT_LEFT) + ml = v.x.toInt() + previewPosition = PreviewPosition.LEFT + hp.gravity = Gravity.CENTER_VERTICAL or Gravity.RIGHT + } + binding.pluginPreviewHandle.layoutParams = hp + if (params.topMargin + v.height / 2 > parent.height / 2) { + params.removeRule(RelativeLayout.ALIGN_PARENT_TOP) + params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM) + mb = (parent.height - v.height - v.y).toInt() + } else { + params.removeRule(RelativeLayout.ALIGN_PARENT_BOTTOM) + params.addRule(RelativeLayout.ALIGN_PARENT_TOP) + mt = v.y.toInt() + } + previewMargins[0] = ml + previewMargins[1] = mt + previewMargins[2] = mr + previewMargins[3] = mb + params.setMargins(ml, mt, mr, mb) + v.layoutParams = params + val outPosition = binding.pluginPreviewContainer.width * 0.85f + previewHiddenState = when { + currentXPosition < 0 -> min(1f, -currentXPosition / outPosition) + currentXPosition + v.width > parent.width -> min(1f, (currentXPosition + v.width - parent.width) / outPosition) + else -> 0f + } + setPreviewDragHiddenState(previewHiddenState) + previewSnapAnimation.start() + return@setOnTouchListener true + } + return@setOnTouchListener false + } else { + return@setOnTouchListener false + } + } + binding.dialpadEditText.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + presenter.sendDtmf(s.subSequence(start, start + count)) + } + + override fun afterTextChanged(s: Editable) {} + }) + } + + } + + private fun configurePreview(width: Int, animatedFraction: Float) { + val context = context + if (context == null || binding == null) return + val margin = context.resources.getDimension(R.dimen.call_preview_margin) + val params = binding!!.previewContainer.layoutParams as RelativeLayout.LayoutParams + val r = 1f - animatedFraction + var hideMargin = 0f + var targetHiddenState = 0f + if (previewHiddenState > 0f) { + targetHiddenState = 1f + val v = width * 0.85f * animatedFraction + hideMargin = if (previewPosition == PreviewPosition.RIGHT) v else -v + } + setPreviewDragHiddenState(previewHiddenState * r + targetHiddenState * animatedFraction) + val f = margin * animatedFraction + params.setMargins( + (previewMargins[0] * r + f + hideMargin).toInt(), + (previewMargins[1] * r + f).toInt(), + (previewMargins[2] * r + f - hideMargin).toInt(), + (previewMargins[3] * r + f).toInt() + ) + binding!!.previewContainer.layoutParams = params + binding!!.pluginPreviewContainer.layoutParams = params + } + + /** + * Releases current wakelock and acquires a new proximity wakelock if current call is audio only. + * + * @param isAudioOnly true if it is an audio call + */ + @SuppressLint("WakelockTimeout") + override fun handleCallWakelock(isAudioOnly: Boolean) { + if (isAudioOnly) { + mScreenWakeLock?.apply { + if (isHeld) release() + } + val powerManager = requireContext().getSystemService(Context.POWER_SERVICE) as PowerManager + mScreenWakeLock = powerManager.newWakeLock( + PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.ON_AFTER_RELEASE, + "ring:callLock" + ).apply { + setReferenceCounted(false) + if (!isHeld) + acquire() + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + if (mOrientationListener != null) { + mOrientationListener!!.disable() + mOrientationListener = null + } + mCompositeDisposable.clear() + if (mScreenWakeLock != null && mScreenWakeLock!!.isHeld) { + mScreenWakeLock!!.release() + } + binding = null + } + + override fun onDestroy() { + super.onDestroy() + mCompositeDisposable.dispose() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array<String>, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode != REQUEST_PERMISSION_INCOMING && requestCode != REQUEST_PERMISSION_OUTGOING) return + var i = 0 + val n = permissions.size + while (i < n) { + val audioGranted = mDeviceRuntimeService.hasAudioPermission() + val granted = grantResults[i] == PackageManager.PERMISSION_GRANTED + when (permissions[i]) { + Manifest.permission.CAMERA -> { + presenter.cameraPermissionChanged(granted) + if (audioGranted) { + initializeCall(requestCode == REQUEST_PERMISSION_INCOMING) + } + } + Manifest.permission.RECORD_AUDIO -> { + presenter.audioPermissionChanged(granted) + initializeCall(requestCode == REQUEST_PERMISSION_INCOMING) + } + } + i++ + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_CODE_ADD_PARTICIPANT) { + if (resultCode == Activity.RESULT_OK && data != null) { + val path = ConversationPath.fromUri(data.data) + if (path != null) { + presenter.addConferenceParticipant(path.accountId, path.conversationUri) + } + } + } else if (requestCode == REQUEST_CODE_SCREEN_SHARE) { + if (resultCode == Activity.RESULT_OK && data != null) { + try { + startScreenShare(mProjectionManager.getMediaProjection(resultCode, data)) + } catch (e: Exception) { + Log.w(TAG, "Error starting screen sharing", e) + } + } else { + binding!!.callScreenshareBtn.isChecked = false + } + } + } + + override fun onCreateOptionsMenu(m: Menu, inf: MenuInflater) { + super.onCreateOptionsMenu(m, inf) + inf.inflate(R.menu.ac_call, m) + dialPadBtn = m.findItem(R.id.menuitem_dialpad) + pluginsMenuBtn = m.findItem(R.id.menuitem_video_plugins) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + presenter.prepareOptionMenu() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + super.onOptionsItemSelected(item) + val itemId = item.itemId + if (itemId == android.R.id.home) { + presenter.chatClick() + } else if (itemId == R.id.menuitem_dialpad) { + presenter.dialpadClick() + } else if (itemId == R.id.menuitem_video_plugins) { + displayVideoPluginsCarousel() + } + return true + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + val activity = activity as AppCompatActivity? + val actionBar = activity?.supportActionBar + if (actionBar != null) { + if (isInPictureInPictureMode) { + actionBar.hide() + } else { + mBackstackLost = true + actionBar.show() + } + } + presenter.pipModeChanged(isInPictureInPictureMode) + } + + override fun displayContactBubble(display: Boolean) { + if (binding != null) binding!!.contactBubbleLayout.handler.post { + if (binding != null) binding!!.contactBubbleLayout.visibility = + if (display) View.VISIBLE else View.GONE + } + } + + override fun displayVideoSurface( + displayVideoSurface: Boolean, + displayPreviewContainer: Boolean + ) { + binding!!.videoSurface.visibility = + if (displayVideoSurface) View.VISIBLE else View.GONE + if (isChoosePluginMode) { + binding!!.pluginPreviewSurface.visibility = + if (displayPreviewContainer) View.VISIBLE else View.GONE + binding!!.pluginPreviewContainer.visibility = + if (displayPreviewContainer) View.VISIBLE else View.GONE + binding!!.previewContainer.visibility = View.GONE + } else { + binding!!.pluginPreviewSurface.visibility = View.GONE + binding!!.pluginPreviewContainer.visibility = View.GONE + binding!!.previewContainer.visibility = + if (displayPreviewContainer) View.VISIBLE else View.GONE + } + updateMenu() + } + + override fun displayPreviewSurface(display: Boolean) { + if (display) { + binding!!.videoSurface.setZOrderOnTop(false) + binding!!.videoSurface.setZOrderMediaOverlay(false) + } else { + binding!!.videoSurface.setZOrderMediaOverlay(true) + binding!!.videoSurface.setZOrderOnTop(true) + } + } + + override fun displayHangupButton(display: Boolean) { + var display = display + Log.w(TAG, "displayHangupButton $display") + display = display and !isChoosePluginMode + binding!!.callControlGroup.visibility = if (display) View.VISIBLE else View.GONE + binding!!.callHangupBtn.visibility = + if (display) View.VISIBLE else View.GONE + binding!!.confControlGroup.visibility = + if (mConferenceMode && display) View.VISIBLE else View.GONE + } + + override fun displayDialPadKeyboard() { + binding!!.dialpadEditText.requestFocus() + val imm = + binding!!.dialpadEditText.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY) + } + + override fun switchCameraIcon(isFront: Boolean) { + binding!!.callCameraFlipBtn.setImageResource(if (isFront) R.drawable.baseline_camera_front_24 else R.drawable.baseline_camera_rear_24) + } + + override fun updateAudioState(state: AudioState) { + binding!!.callSpeakerBtn.isChecked = + state.outputType == HardwareService.AudioOutput.SPEAKERS + } + + override fun updateMenu() { + requireActivity().invalidateOptionsMenu() + } + + override fun updateTime(duration: Long) { + binding?.let { binding -> + if (duration <= 0) binding.callStatusTxt.text = + null else binding.callStatusTxt.text = String.format( + Locale.getDefault(), + "%d:%02d:%02d", + duration / 3600, + duration % 3600 / 60, + duration % 60 + ) + } + } + + @SuppressLint("RestrictedApi") + override fun updateContactBubble(contacts: List<Call>) { + Log.w(TAG, "updateContactBubble " + contacts.size) + val username = if (contacts.size > 1) + "Conference with " + contacts.size + " people" + else contacts[0].contact!!.displayName + val displayName = if (contacts.size > 1) null else contacts[0].contact!!.displayName + val hasProfileName = displayName != null && !displayName.contentEquals(username) + val activity = activity as AppCompatActivity? + if (activity != null) { + val ab = activity.supportActionBar + if (ab != null) { + if (hasProfileName) { + ab.title = displayName + ab.subtitle = username + } else { + ab.title = username + ab.subtitle = null + } + ab.setDisplayShowTitleEnabled(true) + } + } + if (hasProfileName) { + binding!!.contactBubbleNumTxt.visibility = View.VISIBLE + binding!!.contactBubbleTxt.text = displayName + binding!!.contactBubbleNumTxt.text = username + } else { + binding!!.contactBubbleNumTxt.visibility = View.GONE + binding!!.contactBubbleTxt.text = username + } + binding!!.contactBubble.setImageDrawable( + AvatarDrawable.Builder() + .withContact(contacts[0].contact) + .withCircleCrop(true) + .withPresence(false) + .build(requireActivity()) + ) + } + + @SuppressLint("RestrictedApi") + override fun updateConfInfo(participantInfo: List<ParticipantInfo>) { + Log.w(TAG, "updateConfInfo $participantInfo") + mConferenceMode = participantInfo.size > 1 + binding!!.participantLabelContainer.removeAllViews() + if (participantInfo.isNotEmpty()) { + val inflater = LayoutInflater.from(binding!!.participantLabelContainer.context) + for (i in participantInfo) { + val displayName = i.contact.displayName + if (!TextUtils.isEmpty(displayName)) { + val label = ItemParticipantLabelBinding.inflate(inflater) + val params = PercentFrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + params.percentLayoutInfo.leftMarginPercent = i.x / mVideoWidth.toFloat() + params.percentLayoutInfo.topMarginPercent = i.y / mVideoHeight.toFloat() + params.percentLayoutInfo.rightMarginPercent = + 1f - (i.x + i.w) / mVideoWidth.toFloat() + //params.getPercentLayoutInfo().rightMarginPercent = (i.x + i.w) / (float) mVideoWidth; + label.participantName.text = displayName + label.moderator.visibility = if (i.isModerator) View.VISIBLE else View.GONE + label.mute.visibility = if (i.audioMuted) View.VISIBLE else View.GONE + binding!!.participantLabelContainer.addView(label.root, params) + } + } + } + binding!!.participantLabelContainer.visibility = + if (participantInfo.isEmpty()) View.GONE else View.VISIBLE + if (participantInfo.isEmpty() || participantInfo.size < 2) { + binding!!.confControlGroup.visibility = View.GONE + } else { + binding!!.confControlGroup.visibility = View.VISIBLE + if (confAdapter == null) { + confAdapter = + ConfParticipantAdapter(object : ConfParticipantSelected { + override fun onParticipantSelected(view: View, contact: ParticipantInfo) { + val maximized = presenter.isMaximized(contact) + val popup = PopupMenu(view.context, view) + popup.inflate(R.menu.conference_participant_actions) + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.conv_contact_details -> presenter.openParticipantContact(contact) + R.id.conv_contact_hangup -> presenter.hangupParticipant(contact) + R.id.conv_mute -> presenter.muteParticipant(contact, !contact.audioMuted) + R.id.conv_contact_maximize -> presenter.maximizeParticipant(contact) + else -> return@setOnMenuItemClickListener false + } + true + } + val menu = popup.menu as MenuBuilder + val maxItem = menu.findItem(R.id.conv_contact_maximize) + val muteItem = menu.findItem(R.id.conv_mute) + if (maximized) { + maxItem.setTitle(R.string.action_call_minimize) + maxItem.setIcon(R.drawable.baseline_close_fullscreen_24) + } else { + maxItem.setTitle(R.string.action_call_maximize) + maxItem.setIcon(R.drawable.baseline_open_in_full_24) + } + if (!contact.audioMuted) { + muteItem.setTitle(R.string.action_call_mute) + muteItem.setIcon(R.drawable.baseline_mic_off_24) + } else { + muteItem.setTitle(R.string.action_call_unmute) + muteItem.setIcon(R.drawable.baseline_mic_24) + } + val menuHelper = MenuPopupHelper(view.context, menu, view) + menuHelper.gravity = Gravity.END + menuHelper.setForceShowIcon(true) + menuHelper.show() + } + }) + } + confAdapter!!.updateFromCalls(participantInfo) + if (binding!!.confControlGroup.adapter == null) binding!!.confControlGroup.adapter = + confAdapter + } + } + + override fun updateParticipantRecording(contacts: Set<Contact>) { + binding?.let { binding -> + if (contacts.isEmpty()) { + binding.recordLayout.visibility = View.INVISIBLE + binding.recordIndicator.clearAnimation() + return + } + val names = StringBuilder() + val contact = contacts.iterator() + for (i in contacts.indices) { + names.append(" ").append(contact.next().displayName) + if (i != contacts.size - 1) { + names.append(",") + } + } + binding.recordLayout.visibility = View.VISIBLE + binding.recordIndicator.animation = blinkingAnimation + binding.recordName.text = getString(R.string.remote_recording, names) + } + } + + override fun updateCallStatus(callStatus: CallStatus) { + binding!!.callStatusTxt.setText(callStateToHumanState(callStatus)) + } + + override fun initMenu( + isSpeakerOn: Boolean, displayFlip: Boolean, canDial: Boolean, + showPluginBtn: Boolean, onGoingCall: Boolean + ) { + if (binding != null) { + binding!!.callCameraFlipBtn.visibility = if (displayFlip) View.VISIBLE else View.GONE + } + if (dialPadBtn != null) { + dialPadBtn!!.isVisible = canDial + } + if (pluginsMenuBtn != null) { + pluginsMenuBtn!!.isVisible = showPluginBtn + } + updateMenu() + } + + override fun initNormalStateDisplay(audioOnly: Boolean, isMuted: Boolean) { + Log.w(TAG, "initNormalStateDisplay") + binding?.apply { + shapeRipple.stopRipple() + callAcceptBtn.visibility = View.GONE + callRefuseBtn.visibility = View.GONE + callControlGroup.visibility = View.VISIBLE + callHangupBtn.visibility = View.VISIBLE + contactBubbleLayout.visibility = if (audioOnly) View.VISIBLE else View.GONE + callMicBtn.isChecked = isMuted + } + requireActivity().invalidateOptionsMenu() + val callActivity = activity as CallActivity? + callActivity?.showSystemUI() + } + + override fun initIncomingCallDisplay() { + Log.w(TAG, "initIncomingCallDisplay") + binding?.apply { + callAcceptBtn.visibility = View.VISIBLE + callRefuseBtn.visibility = View.VISIBLE + callControlGroup.visibility = View.GONE + callHangupBtn.visibility = View.GONE + contactBubbleLayout.visibility = View.VISIBLE + } + requireActivity().invalidateOptionsMenu() + } + + override fun initOutGoingCallDisplay() { + Log.w(TAG, "initOutGoingCallDisplay") + binding?.apply { + callAcceptBtn.visibility = View.GONE + callRefuseBtn.visibility = View.VISIBLE + callControlGroup.visibility = View.GONE + callHangupBtn.visibility = View.GONE + contactBubbleLayout.visibility = View.VISIBLE + } + requireActivity().invalidateOptionsMenu() + } + + override fun resetPreviewVideoSize(previewWidth: Int, previewHeight: Int, rot: Int) { + if (previewWidth == -1 && previewHeight == -1) return + mPreviewWidth = previewWidth + mPreviewHeight = previewHeight + val flip = rot % 180 != 0 + binding?.previewSurface?.setAspectRatio( + if (flip) mPreviewHeight else mPreviewWidth, + if (flip) mPreviewWidth else mPreviewHeight + ) + } + + override fun resetPluginPreviewVideoSize(previewWidth: Int, previewHeight: Int, rot: Int) { + if (previewWidth == -1 && previewHeight == -1) return + mPreviewWidth = previewWidth + mPreviewHeight = previewHeight + val flip = rot % 180 != 0 + binding?.pluginPreviewSurface?.setAspectRatio( + if (flip) mPreviewHeight else mPreviewWidth, + if (flip) mPreviewWidth else mPreviewHeight + ) + } + + override fun resetVideoSize(videoWidth: Int, videoHeight: Int) { + val rootView = view as ViewGroup? ?: return + val videoRatio = videoWidth / videoHeight.toDouble() + val screenRatio = rootView.width / rootView.height.toDouble() + val params = binding!!.videoSurface.layoutParams as RelativeLayout.LayoutParams + val oldW = params.width + val oldH = params.height + if (videoRatio >= screenRatio) { + params.width = RelativeLayout.LayoutParams.MATCH_PARENT + params.height = + (videoHeight * rootView.width.toDouble() / videoWidth.toDouble()).toInt() + } else { + params.height = RelativeLayout.LayoutParams.MATCH_PARENT + params.width = + (videoWidth * rootView.height.toDouble() / videoHeight.toDouble()).toInt() + } + if (oldW != params.width || oldH != params.height) { + binding!!.videoSurface.layoutParams = params + } + mVideoWidth = videoWidth + mVideoHeight = videoHeight + } + + private fun configureTransform(viewWidth: Int, viewHeight: Int) { + val activity: Activity? = activity + if (null == binding || null == activity) { + return + } + val rotation = activity.windowManager.defaultDisplay.rotation + val rot = Surface.ROTATION_90 == rotation || Surface.ROTATION_270 == rotation + // Log.w(TAG, "configureTransform " + viewWidth + "x" + viewHeight + " rot=" + rot + " mPreviewWidth=" + mPreviewWidth + " mPreviewHeight=" + mPreviewHeight); + val matrix = Matrix() + val viewRect = RectF(0f, 0f, viewWidth.toFloat(), viewHeight.toFloat()) + val centerX = viewRect.centerX() + val centerY = viewRect.centerY() + if (rot) { + val bufferRect = RectF(0f, 0f, mPreviewHeight.toFloat(), mPreviewWidth.toFloat()) + bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY()) + matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL) + val scale = Math.max( + viewHeight.toFloat() / mPreviewHeight, + viewWidth.toFloat() / mPreviewWidth + ) + matrix.postScale(scale, scale, centerX, centerY) + matrix.postRotate((90 * (rotation - 2)).toFloat(), centerX, centerY) + } else if (Surface.ROTATION_180 == rotation) { + matrix.postRotate(180f, centerX, centerY) + } + if (!isChoosePluginMode) { +// binding.pluginPreviewSurface.setTransform(matrix); +// } +// else { + binding!!.previewSurface.setTransform(matrix) + } + } + + override fun goToConversation(accountId: String, conversationId: Uri) { + val context = requireContext() + if (isTablet(context)) { + startActivity( + Intent(DRingService.ACTION_CONV_ACCEPT, ConversationPath.toUri(accountId, conversationId), context, HomeActivity::class.java) + ) + } else { + startActivityForResult( + Intent(Intent.ACTION_VIEW, ConversationPath.toUri(accountId, conversationId), context, ConversationActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT), + HomeActivity.REQUEST_CODE_CONVERSATION + ) + } + } + + override fun goToAddContact(contact: Contact) { + startActivityForResult(ActionHelper.getAddNumberIntentForContact(contact), ConversationFragment.REQ_ADD_CONTACT) + } + + override fun goToContact(accountId: String, contact: Contact) { + startActivity( + Intent(Intent.ACTION_VIEW, ConversationPath.toUri(accountId, contact.uri)) + .setClass(requireContext(), ContactDetailsActivity::class.java) + ) + } + + /** + * Checks if permissions are accepted for camera and microphone. Takes into account whether call is incoming and outgoing, and requests permissions if not available. + * Initializes the call if permissions are accepted. + * + * @param isIncoming true if call is incoming, false for outgoing + * @see .initializeCall + */ + override fun prepareCall(isIncoming: Boolean) { + val audioGranted = mDeviceRuntimeService.hasAudioPermission() + val audioOnly: Boolean + val permissionType: Int + if (isIncoming) { + audioOnly = presenter.isAudioOnly + permissionType = REQUEST_PERMISSION_INCOMING + } else { + val args = arguments + audioOnly = args != null && args.getBoolean(KEY_AUDIO_ONLY) + permissionType = REQUEST_PERMISSION_OUTGOING + } + if (!audioOnly) { + val videoGranted = mDeviceRuntimeService.hasVideoPermission() + if ((!audioGranted || !videoGranted) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val perms = ArrayList<String>() + if (!videoGranted) { + perms.add(Manifest.permission.CAMERA) + } + if (!audioGranted) { + perms.add(Manifest.permission.RECORD_AUDIO) + } + requestPermissions(perms.toTypedArray(), permissionType) + } else if (audioGranted && videoGranted) { + initializeCall(isIncoming) + } + } else { + if (!audioGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), permissionType) + } else if (audioGranted) { + initializeCall(isIncoming) + } + } + } + + /** + * Starts a call. Takes into account whether call is incoming or outgoing. + * + * @param isIncoming true if call is incoming, false for outgoing + */ + private fun initializeCall(isIncoming: Boolean) { + if (isIncoming) { + presenter.acceptCall() + } else { + arguments?.let { args -> + val conversation = ConversationPath.fromBundle(args)!! + presenter.initOutGoing( + conversation.accountId, + conversation.conversationUri, + args.getString(Intent.EXTRA_PHONE_NUMBER), + args.getBoolean(KEY_AUDIO_ONLY) + ) + } + } + } + + override fun finish() { + activity?.let { activity -> + activity.finishAndRemoveTask() + if (mBackstackLost) { + startActivity( + Intent.makeMainActivity(ComponentName(activity, HomeActivity::class.java)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } + } + + fun speakerClicked() { + presenter.speakerClick(binding!!.callSpeakerBtn.isChecked) + } + + private fun startScreenShare(mediaProjection: MediaProjection) { + if (presenter.startScreenShare(mediaProjection)) { + if (isChoosePluginMode) { + binding!!.pluginPreviewSurface.visibility = View.GONE + } else { + binding!!.previewSurface.visibility = View.GONE + } + } else { + Toast.makeText(requireContext(), "Can't start screen sharing", Toast.LENGTH_SHORT) + .show() + } + } + + private fun stopShareScreen() { + binding?.previewSurface?.visibility = View.VISIBLE + presenter.stopScreenShare() + } + + fun shareScreenClicked(checked: Boolean) { + if (!checked) { + stopShareScreen() + } else { + startActivityForResult(mProjectionManager.createScreenCaptureIntent(), REQUEST_CODE_SCREEN_SHARE) + } + } + + fun micClicked() { + binding?.let { binding-> + presenter.muteMicrophoneToggled(binding.callMicBtn.isChecked) + binding.callMicBtn.setImageResource(if (binding.callMicBtn.isChecked) R.drawable.baseline_mic_off_24 else R.drawable.baseline_mic_24) + } + } + + fun hangUpClicked() { + presenter.hangupCall() + } + + fun refuseClicked() { + presenter.refuseCall() + } + + fun acceptClicked() { + prepareCall(true) + } + + fun cameraFlip() { + presenter.switchVideoInputClick() + } + + fun addParticipant() { + presenter.startAddParticipant() + } + + override fun startAddParticipant(conferenceId: String) { + startActivityForResult(Intent(Intent.ACTION_PICK) + .setClass(requireActivity(), ConversationSelectionActivity::class.java) + .putExtra(KEY_CONF_ID, conferenceId), + REQUEST_CODE_ADD_PARTICIPANT) + } + + override fun toggleCallMediaHandler(id: String, callId: String, toggle: Boolean) { + JamiService.toggleCallMediaHandler(id, callId, toggle) + } + + fun getCallMediaHandlerDetails(id: String): Map<String, String> { + return JamiService.getCallMediaHandlerDetails(id).toNative() + } + + override fun positiveMediaButtonClicked() { + presenter.positiveButtonClicked() + } + + override fun negativeMediaButtonClicked() { + presenter.negativeButtonClicked() + } + + override fun toggleMediaButtonClicked() { + presenter.toggleButtonClicked() + } + + override fun displayPluginsButton(): Boolean { + return JamiService.getPluginsEnabled() && JamiService.getCallMediaHandlers().size > 0 + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // Reset the padding of the RecyclerPicker on each + rp!!.setFirstLastElementsWidths(112, 112) + binding!!.recyclerPicker.visibility = View.GONE + if (isChoosePluginMode) { + displayHangupButton(false) + binding!!.recyclerPicker.visibility = View.VISIBLE + movePreview(true) + if (previousPluginPosition != -1) { + rp!!.scrollToPosition(previousPluginPosition) + } + } else { + movePreview(false) + } + } + + fun toggleVideoPluginsCarousel(toggle: Boolean) { + if (isChoosePluginMode) { + if (toggle) { + binding!!.recyclerPicker.visibility = View.VISIBLE + movePreview(true) + } else { + binding!!.recyclerPicker.visibility = View.INVISIBLE + movePreview(false) + } + } + } + + fun movePreview(up: Boolean) { + // Move the preview container (cardview) by a certain margin + if (up) { + animation.setIntValues(12, 128) + } else { + animation.setIntValues(128, 12) + } + animation.start() + } + + /** + * Function that is called to show/hide the plugins recycler viewer and update UI + */ + @SuppressLint("UseCompatLoadingForDrawables") + fun displayVideoPluginsCarousel() { + isChoosePluginMode = !isChoosePluginMode + val context: Context = requireActivity() + + // Create callMediaHandlers and videoPluginsItems in a lazy manner + if (pluginsModeFirst) { + // Init + val callMediaHandlers = JamiService.getCallMediaHandlers() + val videoPluginsItems: MutableList<Drawable?> = ArrayList(callMediaHandlers.size + 1) + videoPluginsItems.add(context.getDrawable(R.drawable.baseline_cancel_24)) + // Search for plugin call media handlers icons + // If a call media handler doesn't have an icon use a standard android icon + for (callMediaHandler in callMediaHandlers) { + val details = getCallMediaHandlerDetails(callMediaHandler) + var drawablePath = details["iconPath"] + if (drawablePath != null && drawablePath.endsWith("svg")) drawablePath = + drawablePath.replace(".svg", ".png") + var handlerIcon = Drawable.createFromPath(drawablePath) + if (handlerIcon == null) { + handlerIcon = context.getDrawable(R.drawable.ic_jami) + } + videoPluginsItems.add(handlerIcon) + } + rp!!.updateData(videoPluginsItems) + pluginsModeFirst = false + } + if (isChoosePluginMode) { + // hide hang up button and other call buttons + displayHangupButton(false) + // Display the plugins recyclerpicker + binding!!.recyclerPicker.visibility = View.VISIBLE + movePreview(true) + + // Start loading the first or previous plugin if one was active + if (callMediaHandlers!!.isNotEmpty()) { + // If no previous plugin was active, take the first, else previous + val position: Int + if (previousPluginPosition < 1) { + rp!!.scrollToPosition(1) + position = 1 + previousPluginPosition = 1 + } else { + position = previousPluginPosition + } + val callMediaId = callMediaHandlers!![position - 1] + presenter.startPlugin(callMediaId) + } + } else { + if (previousPluginPosition > 0) { + val callMediaId = callMediaHandlers!![previousPluginPosition - 1] + presenter.toggleCallMediaHandler(callMediaId, false) + rp!!.scrollToPosition(previousPluginPosition) + } + presenter.stopPlugin() + binding!!.recyclerPicker.visibility = View.GONE + movePreview(false) + displayHangupButton(true) + } + + //change preview image + displayVideoSurface(true, true) + } + + /** + * Called whenever a plugin drawable in the recycler picker is clicked or scrolled to + */ + override fun onItemSelected(position: Int) { + Log.i(TAG, "selected position: $position") + /* If there was a different plugin before, unload it + * If previousPluginPosition = -1 or 0, there was no plugin + */if (previousPluginPosition > 0) { + val callMediaId = callMediaHandlers!![previousPluginPosition - 1] + presenter.toggleCallMediaHandler(callMediaId, false) + } + if (position > 0) { + previousPluginPosition = position + val callMediaId = callMediaHandlers!![position - 1] + presenter.toggleCallMediaHandler(callMediaId, true) + } + } + + /** + * Called whenever a plugin drawable in the recycler picker is clicked + */ + override fun onItemClicked(position: Int) { + Log.i(TAG, "selected position: $position") + if (position == 0) { + /* If there was a different plugin before, unload it + * If previousPluginPosition = -1 or 0, there was no plugin + */ + if (previousPluginPosition > 0) { + val callMediaId = callMediaHandlers!![previousPluginPosition - 1] + presenter.toggleCallMediaHandler(callMediaId, false) + rp!!.scrollToPosition(previousPluginPosition) + } + val callActivity = activity as CallActivity? + callActivity?.showSystemUI() + toggleVideoPluginsCarousel(false) + displayVideoPluginsCarousel() + } + } + + private val blinkingAnimation: Animation + get() { + return AlphaAnimation(1f, 0f).apply { + duration = 400 + interpolator = LinearInterpolator() + repeatCount = Animation.INFINITE + repeatMode = Animation.REVERSE + } + } + + companion object { + val TAG = CallFragment::class.simpleName!! + const val ACTION_PLACE_CALL = "PLACE_CALL" + const val ACTION_GET_CALL = "GET_CALL" + const val KEY_ACTION = "action" + const val KEY_CONF_ID = "confId" + const val KEY_AUDIO_ONLY = "AUDIO_ONLY" + private const val REQUEST_CODE_ADD_PARTICIPANT = 6 + private const val REQUEST_PERMISSION_INCOMING = 1003 + private const val REQUEST_PERMISSION_OUTGOING = 1004 + private const val REQUEST_CODE_SCREEN_SHARE = 7 + + fun newInstance(action: String, path: ConversationPath?, contactId: String?, audioOnly: Boolean): CallFragment { + val bundle = Bundle() + bundle.putString(KEY_ACTION, action) + path?.toBundle(bundle) + bundle.putString(Intent.EXTRA_PHONE_NUMBER, contactId) + bundle.putBoolean(KEY_AUDIO_ONLY, audioOnly) + val countDownFragment = CallFragment() + countDownFragment.arguments = bundle + return countDownFragment + } + + fun newInstance(action: String, confId: String?): CallFragment { + val bundle = Bundle() + bundle.putString(KEY_ACTION, action) + bundle.putString(KEY_CONF_ID, confId) + val countDownFragment = CallFragment() + countDownFragment.arguments = bundle + return countDownFragment + } + + fun callStateToHumanState(state: CallStatus?): Int { + return when (state) { + CallStatus.SEARCHING -> R.string.call_human_state_searching + CallStatus.CONNECTING -> R.string.call_human_state_connecting + CallStatus.RINGING -> R.string.call_human_state_ringing + CallStatus.CURRENT -> R.string.call_human_state_current + CallStatus.HUNGUP -> R.string.call_human_state_hungup + CallStatus.BUSY -> R.string.call_human_state_busy + CallStatus.FAILURE -> R.string.call_human_state_failure + CallStatus.HOLD -> R.string.call_human_state_hold + CallStatus.UNHOLD -> R.string.call_human_state_unhold + CallStatus.OVER -> R.string.call_human_state_over + CallStatus.NONE -> R.string.call_human_state_none + else -> R.string.call_human_state_none + } + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/ContactPickerFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/ContactPickerFragment.java index 0a3fa87fb..d60da326e 100644 --- a/ring-android/app/src/main/java/cx/ring/fragments/ContactPickerFragment.java +++ b/ring-android/app/src/main/java/cx/ring/fragments/ContactPickerFragment.java @@ -23,17 +23,18 @@ import javax.inject.Inject; import cx.ring.R; import cx.ring.adapters.SmartListAdapter; -import cx.ring.application.JamiApplication; import cx.ring.client.HomeActivity; import cx.ring.databinding.FragContactPickerBinding; -import net.jami.facades.ConversationFacade; +import net.jami.services.ConversationFacade; import net.jami.model.Contact; import net.jami.smartlist.SmartListViewModel; import cx.ring.viewholders.SmartListViewHolder; import cx.ring.views.AvatarDrawable; +import dagger.hilt.android.AndroidEntryPoint; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; +@AndroidEntryPoint public class ContactPickerFragment extends BottomSheetDialogFragment { public static final String TAG = ContactPickerFragment.class.getSimpleName(); @@ -59,8 +60,8 @@ public class ContactPickerFragment extends BottomSheetDialogFragment { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setRetainInstance(true); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); + //setRetainInstance(true); + //((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); } @Override diff --git a/ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.java deleted file mode 100644 index ec1c548c8..000000000 --- a/ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.java +++ /dev/null @@ -1,1283 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.fragments; - -import android.Manifest; -import android.animation.LayoutTransition; -import android.animation.ValueAnimator; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.ActivityOptions; -import android.content.ActivityNotFoundException; -import android.content.ClipData; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.graphics.Typeface; -import android.os.Bundle; -import android.os.Environment; -import android.os.IBinder; -import android.provider.MediaStore; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.inputmethod.EditorInfo; -import android.widget.ImageView; -import android.widget.RelativeLayout; -import android.widget.Spinner; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.view.menu.MenuBuilder; -import androidx.appcompat.view.menu.MenuPopupHelper; -import androidx.appcompat.widget.PopupMenu; -import androidx.appcompat.widget.Toolbar; -import androidx.core.view.ViewCompat; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.recyclerview.widget.DefaultItemAnimator; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.snackbar.Snackbar; - -import net.jami.conversation.ConversationPresenter; -import net.jami.conversation.ConversationView; -import net.jami.daemon.JamiService; -import net.jami.model.Account; -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.model.DataTransfer; -import net.jami.model.Error; -import net.jami.model.Interaction; -import net.jami.model.Phone; -import net.jami.model.Uri; -import net.jami.services.NotificationService; - -import java.io.File; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import cx.ring.R; -import cx.ring.adapters.ConversationAdapter; -import cx.ring.application.JamiApplication; -import cx.ring.client.CallActivity; -import cx.ring.client.ContactDetailsActivity; -import cx.ring.client.ConversationActivity; -import cx.ring.client.HomeActivity; -import cx.ring.databinding.FragConversationBinding; -import cx.ring.interfaces.Colorable; -import cx.ring.mvp.BaseSupportFragment; -import cx.ring.service.DRingService; -import cx.ring.service.LocationSharingService; -import cx.ring.services.NotificationServiceImpl; -import cx.ring.services.SharedPreferencesServiceImpl; -import cx.ring.utils.ActionHelper; -import cx.ring.utils.AndroidFileUtils; -import cx.ring.utils.ContentUriHandler; -import cx.ring.utils.ConversationPath; -import cx.ring.utils.DeviceUtils; -import cx.ring.utils.MediaButtonsHelper; -import cx.ring.views.AvatarDrawable; -import cx.ring.views.AvatarFactory; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -import static android.app.Activity.RESULT_OK; - -public class ConversationFragment extends BaseSupportFragment<ConversationPresenter> implements - MediaButtonsHelper.MediaButtonsHelperCallback, - ConversationView, SharedPreferences.OnSharedPreferenceChangeListener { - private static final String TAG = ConversationFragment.class.getSimpleName(); - - public static final int REQ_ADD_CONTACT = 42; - - public static final String KEY_PREFERENCE_PENDING_MESSAGE = "pendingMessage"; - public static final String KEY_PREFERENCE_CONVERSATION_COLOR = "color"; - public static final String KEY_PREFERENCE_CONVERSATION_LAST_READ = "lastRead"; - public static final String KEY_PREFERENCE_CONVERSATION_SYMBOL = "symbol"; - public static final String EXTRA_SHOW_MAP = "showMap"; - - private static final int REQUEST_CODE_FILE_PICKER = 1000; - private static final int REQUEST_PERMISSION_CAMERA = 1001; - private static final int REQUEST_CODE_TAKE_PICTURE = 1002; - private static final int REQUEST_CODE_SAVE_FILE = 1003; - private static final int REQUEST_CODE_CAPTURE_AUDIO = 1004; - private static final int REQUEST_CODE_CAPTURE_VIDEO = 1005; - - private ServiceConnection locationServiceConnection = null; - - private FragConversationBinding binding; - private MenuItem mAudioCallBtn = null; - private MenuItem mVideoCallBtn = null; - - private View currentBottomView = null; - private ConversationAdapter mAdapter = null; - private int marginPx; - private int marginPxTotal; - private final ValueAnimator animation = new ValueAnimator(); - - private SharedPreferences mPreferences; - - private File mCurrentPhoto = null; - private String mCurrentFileAbsolutePath = null; - private final CompositeDisposable mCompositeDisposable = new CompositeDisposable(); - private int mSelectedPosition; - - private boolean mIsBubble; - - private AvatarDrawable mConversationAvatar; - private final Map<String, AvatarDrawable> mParticipantAvatars = new HashMap<>(); - private final Map<String, AvatarDrawable> mSmallParticipantAvatars = new HashMap<>(); - private int mapWidth, mapHeight; - private String mLastRead; - - private boolean loading = true; - - public AvatarDrawable getConversationAvatar(String uri) { - return mParticipantAvatars.get(uri); - } - public AvatarDrawable getSmallConversationAvatar(String uri) { - synchronized (mSmallParticipantAvatars) { - return mSmallParticipantAvatars.get(uri); - } - } - - private static int getIndex(Spinner spinner, Uri myString) { - for (int i = 0, n = spinner.getCount(); i < n; i++) - if (((Phone) spinner.getItemAtPosition(i)).getNumber().equals(myString)) { - return i; - } - return 0; - } - - @Override - public void refreshView(final List<Interaction> conversation) { - if (conversation == null) { - return; - } - if (binding != null) - binding.pbLoading.setVisibility(View.GONE); - if (mAdapter != null) { - mAdapter.updateDataset(conversation); - loading = false; - } - requireActivity().invalidateOptionsMenu(); - } - - @Override - public void scrollToEnd() { - if (mAdapter.getItemCount() > 0) { - binding.histList.scrollToPosition(mAdapter.getItemCount() - 1); - } - } - - private static void setBottomPadding(@NonNull View view, int padding) { - view.setPadding( - view.getPaddingLeft(), - view.getPaddingTop(), - view.getPaddingRight(), - padding); - } - - private void updateListPadding() { - if (currentBottomView != null && currentBottomView.getHeight() != 0) { - setBottomPadding(binding.histList, currentBottomView.getHeight() + marginPxTotal); - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.mapCard.getLayoutParams(); - params.bottomMargin = currentBottomView.getHeight() + marginPxTotal; - binding.mapCard.setLayoutParams(params); - } - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - Resources res = getResources(); - marginPx = res.getDimensionPixelSize(R.dimen.conversation_message_input_margin); - mapWidth = res.getDimensionPixelSize(R.dimen.location_sharing_minmap_width); - mapHeight = res.getDimensionPixelSize(R.dimen.location_sharing_minmap_height); - marginPxTotal = marginPx; - - binding = FragConversationBinding.inflate(inflater, container, false); - binding.setPresenter(this); - - animation.setDuration(150); - animation.addUpdateListener(valueAnimator -> setBottomPadding(binding.histList, (Integer)valueAnimator.getAnimatedValue())); - - ViewCompat.setOnApplyWindowInsetsListener(binding.histList, (v, insets) -> { - marginPxTotal = marginPx + insets.getSystemWindowInsetBottom(); - updateListPadding(); - insets.consumeSystemWindowInsets(); - return insets; - }); - View layout = binding.conversationLayout; - - // remove action bar height for tablet layout - if (DeviceUtils.isTablet(layout.getContext())) { - layout.setPadding(layout.getPaddingLeft(), 0, layout.getPaddingRight(), layout.getPaddingBottom()); - } - - int paddingTop = layout.getPaddingTop(); - ViewCompat.setOnApplyWindowInsetsListener(layout, (v, insets) -> { - v.setPadding( - v.getPaddingLeft(), - paddingTop + insets.getSystemWindowInsetTop(), - v.getPaddingRight(), - v.getPaddingBottom()); - insets.consumeSystemWindowInsets(); - return insets; - }); - - binding.ongoingcallPane.setVisibility(View.GONE); - binding.msgInputTxt.setMediaListener(contentInfo -> startFileSend(AndroidFileUtils - .getCacheFile(requireContext(), contentInfo.getContentUri()) - .flatMapCompletable(this::sendFile) - .doFinally(contentInfo::releasePermission))); - binding.msgInputTxt.setOnEditorActionListener((v, actionId, event) -> actionSendMsgText(actionId)); - binding.msgInputTxt.setOnFocusChangeListener((view, hasFocus) -> { - if (hasFocus) { - Fragment fragment = getChildFragmentManager().findFragmentById(R.id.mapLayout); - if (fragment != null) { - ((LocationSharingFragment) fragment).hideControls(); - } - } - }); - binding.msgInputTxt.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - - @Override - public void afterTextChanged(Editable s) { - String message = s.toString(); - boolean hasMessage = !TextUtils.isEmpty(message); - presenter.onComposingChanged(hasMessage); - if (hasMessage) { - binding.msgSend.setVisibility(View.VISIBLE); - binding.emojiSend.setVisibility(View.GONE); - } else { - binding.msgSend.setVisibility(View.GONE); - binding.emojiSend.setVisibility(View.VISIBLE); - } - if (mPreferences != null) { - if (hasMessage) - mPreferences.edit().putString(KEY_PREFERENCE_PENDING_MESSAGE, message).apply(); - else - mPreferences.edit().remove(KEY_PREFERENCE_PENDING_MESSAGE).apply(); - } - } - }); - - setHasOptionsMenu(true); - return binding.getRoot(); - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - if (mPreferences != null) { - String pendingMessage = mPreferences.getString(KEY_PREFERENCE_PENDING_MESSAGE, null); - if (!TextUtils.isEmpty(pendingMessage)) { - binding.msgInputTxt.setText(pendingMessage); - binding.msgSend.setVisibility(View.VISIBLE); - binding.emojiSend.setVisibility(View.GONE); - } - } - - binding.msgInputTxt.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { - if (oldBottom == 0 && oldTop == 0) { - updateListPadding(); - } else { - if (animation.isStarted()) - animation.cancel(); - animation.setIntValues(binding.histList.getPaddingBottom(), (currentBottomView == null ? 0 : currentBottomView.getHeight()) + marginPxTotal); - animation.start(); - } - }); - - binding.histList.addOnScrollListener(new RecyclerView.OnScrollListener() { - // The minimum amount of items to have below current scroll position - // before loading more. - static private final int visibleThreshold = 3; - - @Override - public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { - } - - @Override - public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { - LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager(); - if (!loading && layoutManager.findFirstVisibleItemPosition() < visibleThreshold) { - loading = true; - presenter.loadMore(); - } - } - }); - - DefaultItemAnimator animator = (DefaultItemAnimator) binding.histList.getItemAnimator(); - if (animator != null) - animator.setSupportsChangeAnimations(false); - binding.histList.setAdapter(mAdapter); - } - - @Override - public void setConversationColor(int color) { - Colorable activity = (Colorable) getActivity(); - if (activity != null) - activity.setColor(color); - mAdapter.setPrimaryColor(color); - } - - @Override - public void setConversationSymbol(CharSequence symbol) { - binding.emojiSend.setText(symbol); - } - - @Override - public void onDestroyView() { - if (mPreferences != null) - mPreferences.unregisterOnSharedPreferenceChangeListener(this); - animation.removeAllUpdateListeners(); - binding.histList.setAdapter(null); - mCompositeDisposable.clear(); - if (locationServiceConnection != null) { - try { - requireContext().unbindService(locationServiceConnection); - } catch (Exception e) { - Log.w(TAG, "Error unbinding service: " + e.getMessage()); - } - } - mAdapter = null; - super.onDestroyView(); - binding = null; - } - - @Override - public boolean onContextItemSelected(@NonNull MenuItem item) { - if (mAdapter.onContextItemSelected(item)) - return true; - return super.onContextItemSelected(item); - } - - public void updateAdapterItem() { - if (mSelectedPosition != -1) { - mAdapter.notifyItemChanged(mSelectedPosition); - mSelectedPosition = -1; - } - } - - public void sendMessageText() { - String message = binding.msgInputTxt.getText().toString(); - clearMsgEdit(); - presenter.sendTextMessage(message); - } - - public void sendEmoji() { - presenter.sendTextMessage(binding.emojiSend.getText().toString()); - } - - @SuppressLint("RestrictedApi") - public void expandMenu(View v) { - Context context = requireContext(); - PopupMenu popup = new PopupMenu(context, v); - popup.inflate(R.menu.conversation_share_actions); - popup.setOnMenuItemClickListener(item -> { - int itemId = item.getItemId(); - if (itemId == R.id.conv_send_audio) { - sendAudioMessage(); - } else if (itemId == R.id.conv_send_video) { - sendVideoMessage(); - } else if (itemId == R.id.conv_send_file) { - presenter.selectFile(); - } else if (itemId == R.id.conv_share_location) { - shareLocation(); - } else if (itemId == R.id.chat_plugins) { - presenter.showPluginListHandlers(); - } - return false; - }); - popup.getMenu().findItem(R.id.chat_plugins).setVisible(JamiService.getPluginsEnabled() && !JamiService.getChatHandlers().isEmpty()); - MenuPopupHelper menuHelper = new MenuPopupHelper(context, (MenuBuilder) popup.getMenu(), v); - menuHelper.setForceShowIcon(true); - menuHelper.show(); - } - - public void showPluginListHandlers(String accountId, String contactId) { - Log.w(TAG, "show Plugin Chat Handlers List"); - - FragmentManager fragmentManager = getChildFragmentManager(); - PluginHandlersListFragment fragment = PluginHandlersListFragment.newInstance(accountId, contactId); - fragmentManager.beginTransaction() - .add(R.id.pluginListHandlers, fragment, PluginHandlersListFragment.TAG) - .commit(); - - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.mapCard.getLayoutParams(); - if (params.width != ViewGroup.LayoutParams.MATCH_PARENT) { - params.width = ViewGroup.LayoutParams.MATCH_PARENT; - params.height = ViewGroup.LayoutParams.MATCH_PARENT; - binding.mapCard.setLayoutParams(params); - } - binding.mapCard.setVisibility(View.VISIBLE); - } - - public void hidePluginListHandlers() { - if (binding.mapCard.getVisibility() != View.GONE) { - binding.mapCard.setVisibility(View.GONE); - - FragmentManager fragmentManager = getChildFragmentManager(); - Fragment fragment = fragmentManager.findFragmentById(R.id.pluginListHandlers); - - if (fragment != null) { - fragmentManager.beginTransaction() - .remove(fragment) - .commit(); - } - } - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.mapCard.getLayoutParams(); - if (params.width != mapWidth) { - params.width = mapWidth; - params.height = mapHeight; - binding.mapCard.setLayoutParams(params); - } - } - - public void shareLocation() { - presenter.shareLocation(); - } - - public void closeLocationSharing(boolean isSharing) { - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.mapCard.getLayoutParams(); - if (params.width != mapWidth) { - params.width = mapWidth; - params.height = mapHeight; - binding.mapCard.setLayoutParams(params); - } - if (!isSharing) - hideMap(); - } - - public void openLocationSharing() { - binding.conversationLayout.getLayoutTransition().enableTransitionType(LayoutTransition.CHANGING); - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.mapCard.getLayoutParams(); - if (params.width != ViewGroup.LayoutParams.MATCH_PARENT) { - params.width = ViewGroup.LayoutParams.MATCH_PARENT; - params.height = ViewGroup.LayoutParams.MATCH_PARENT; - binding.mapCard.setLayoutParams(params); - } - } - - @Override - public void startShareLocation(String accountId, String conversationId) { - showMap(accountId, conversationId, true); - } - - /** - * Used to update with the past adapter position when a long click was registered - */ - public void updatePosition(int position) { - mSelectedPosition = position; - } - - @Override - public void showMap(String accountId, String contactId, boolean open) { - if (binding.mapCard.getVisibility() == View.GONE) { - Log.w(TAG, "showMap " + accountId + " " + contactId); - - FragmentManager fragmentManager = getChildFragmentManager(); - LocationSharingFragment fragment = LocationSharingFragment.newInstance(accountId, contactId, open); - fragmentManager.beginTransaction() - .add(R.id.mapLayout, fragment, "map") - .commit(); - binding.mapCard.setVisibility(View.VISIBLE); - } - if (open) { - Fragment fragment = getChildFragmentManager().findFragmentById(R.id.mapLayout); - if (fragment != null) { - ((LocationSharingFragment) fragment).showControls(); - } - } - } - - @Override - public void hideMap() { - if (binding.mapCard.getVisibility() != View.GONE) { - binding.mapCard.setVisibility(View.GONE); - - FragmentManager fragmentManager = getChildFragmentManager(); - Fragment fragment = fragmentManager.findFragmentById(R.id.mapLayout); - - if (fragment != null) { - fragmentManager.beginTransaction() - .remove(fragment) - .commit(); - } - } - } - - public void sendAudioMessage() { - if (!presenter.getDeviceRuntimeService().hasAudioPermission()) { - requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, REQUEST_CODE_CAPTURE_AUDIO); - } else { - try { - Context ctx = requireContext(); - Intent intent = new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION); - mCurrentPhoto = AndroidFileUtils.createAudioFile(ctx); - startActivityForResult(intent, REQUEST_CODE_CAPTURE_AUDIO); - } catch (Exception ex) { - Log.e(TAG, "sendAudioMessage: error", ex); - Toast.makeText(getActivity(), "Can't find audio recorder app", Toast.LENGTH_SHORT).show(); - } - } - } - - public void sendVideoMessage() { - if (!presenter.getDeviceRuntimeService().hasVideoPermission()) { - requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_CODE_CAPTURE_VIDEO); - } else { - try { - Context context = requireContext(); - Intent intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE); - intent.putExtra("android.intent.extras.CAMERA_FACING", android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT); - intent.putExtra("android.intent.extras.LENS_FACING_FRONT", 1); - intent.putExtra("android.intent.extra.USE_FRONT_CAMERA", true); - mCurrentPhoto = AndroidFileUtils.createVideoFile(context); - intent.putExtra(MediaStore.EXTRA_OUTPUT, ContentUriHandler.getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, mCurrentPhoto)); - startActivityForResult(intent, REQUEST_CODE_CAPTURE_VIDEO); - } catch (Exception ex) { - Log.e(TAG, "sendVideoMessage: error", ex); - Toast.makeText(getActivity(), "Can't find video recorder app", Toast.LENGTH_SHORT).show(); - } - } - } - - public void takePicture() { - if (!presenter.getDeviceRuntimeService().hasVideoPermission()) { - requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_CODE_TAKE_PICTURE); - return; - } - Context c = getContext(); - if (c == null) - return; - try { - File photoFile = AndroidFileUtils.createImageFile(c); - Log.i(TAG, "takePicture: trying to save to " + photoFile); - android.net.Uri photoURI = ContentUriHandler.getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, photoFile); - Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE).putExtra(MediaStore.EXTRA_OUTPUT, photoURI) - .putExtra("android.intent.extras.CAMERA_FACING", 1) - .putExtra("android.intent.extras.LENS_FACING_FRONT", 1) - .putExtra("android.intent.extra.USE_FRONT_CAMERA", true); - mCurrentPhoto = photoFile; - startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PICTURE); - } catch (Exception e) { - Toast.makeText(c, "Error taking picture: " + e.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void askWriteExternalStoragePermission() { - requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, JamiApplication.PERMISSIONS_REQUEST); - } - - @Override - public void openFilePicker() { - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType("*/*"); - - startActivityForResult(intent, REQUEST_CODE_FILE_PICKER); - } - - private Completable sendFile(File file) { - return Completable.fromAction(() -> presenter.sendFile(file)); - } - - private void startFileSend(Completable op) { - setLoading(true); - op.observeOn(AndroidSchedulers.mainThread()) - .doFinally(() -> setLoading(false)) - .subscribe(() -> {}, e -> { - Log.e(TAG, "startFileSend: not able to create cache file", e); - displayErrorToast(Error.INVALID_FILE); - }); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent resultData) { - Log.w(TAG, "onActivityResult: " + requestCode + " " + resultCode + " " + resultData); - android.net.Uri uri = resultData == null ? null : resultData.getData(); - if (requestCode == REQUEST_CODE_FILE_PICKER) { - if (resultCode == RESULT_OK && uri != null) { - startFileSend(AndroidFileUtils.getCacheFile(requireContext(), uri) - .observeOn(AndroidSchedulers.mainThread()) - .flatMapCompletable(this::sendFile)); - } - } else if (requestCode == REQUEST_CODE_TAKE_PICTURE - || requestCode == REQUEST_CODE_CAPTURE_AUDIO - || requestCode == REQUEST_CODE_CAPTURE_VIDEO) - { - if (resultCode != RESULT_OK) { - mCurrentPhoto = null; - return; - } - Log.w(TAG, "onActivityResult: mCurrentPhoto " + mCurrentPhoto.getAbsolutePath() + " " + mCurrentPhoto.exists() + " " + mCurrentPhoto.length()); - Single<File> file = null; - if (mCurrentPhoto == null || !mCurrentPhoto.exists() || mCurrentPhoto.length() == 0) { - if (uri != null) { - file = AndroidFileUtils.getCacheFile(requireContext(), uri); - } - } else { - file = Single.just(mCurrentPhoto); - } - mCurrentPhoto = null; - if (file == null) { - Toast.makeText(getActivity(), "Can't find picture", Toast.LENGTH_SHORT).show(); - return; - } - startFileSend(file.flatMapCompletable(this::sendFile)); - } - // File download trough SAF - else if (requestCode == ConversationFragment.REQUEST_CODE_SAVE_FILE) { - if (resultCode == RESULT_OK && uri != null) { - writeToFile(uri); - } - } - } - - private void writeToFile(android.net.Uri data) { - File input = new File(mCurrentFileAbsolutePath); - if (requireContext().getContentResolver() != null) - mCompositeDisposable.add(AndroidFileUtils.copyFileToUri(requireContext().getContentResolver(), input, data). - observeOn(AndroidSchedulers.mainThread()). - subscribe(() -> Toast.makeText(getContext(), R.string.file_saved_successfully, Toast.LENGTH_SHORT).show(), - error -> Toast.makeText(getContext(), R.string.generic_error, Toast.LENGTH_SHORT).show())); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - for (int i = 0, n = permissions.length; i < n; i++) { - boolean granted = grantResults[i] == PackageManager.PERMISSION_GRANTED; - switch (permissions[i]) { - case Manifest.permission.CAMERA: - presenter.cameraPermissionChanged(granted); - if (granted) { - if (requestCode == REQUEST_CODE_CAPTURE_VIDEO) { - sendVideoMessage(); - } else if (requestCode == REQUEST_CODE_TAKE_PICTURE) { - takePicture(); - } - } - return; - case Manifest.permission.RECORD_AUDIO: - if (granted) { - if (requestCode == REQUEST_CODE_CAPTURE_AUDIO) { - sendAudioMessage(); - } - } - return; - default: - break; - } - } - } - - @Override - public void addElement(Interaction element) { - if (mLastRead != null && mLastRead.equals(element.getMessageId())) - element.read(); - if (mAdapter.add(element)) - scrollToEnd(); - loading = false; - } - - @Override - public void updateElement(Interaction element) { - mAdapter.update(element); - } - - @Override - public void removeElement(Interaction element) { - mAdapter.remove(element); - } - - @Override - public void setComposingStatus(Account.ComposingStatus composingStatus) { - mAdapter.setComposingStatus(composingStatus); - if (composingStatus == Account.ComposingStatus.Active) - scrollToEnd(); - } - - @Override - public void setLastDisplayed(Interaction interaction) { - mAdapter.setLastDisplayed(interaction); - } - - @Override - public void acceptFile(String accountId, Uri conversationUri, DataTransfer transfer) { - File cacheDir = requireContext().getCacheDir(); - long spaceLeft = AndroidFileUtils.getSpaceLeft(cacheDir.toString()); - if (spaceLeft == -1L || transfer.getTotalSize() > spaceLeft) { - presenter.noSpaceLeft(); - return; - } - requireActivity().startService(new Intent(DRingService.ACTION_FILE_ACCEPT, ConversationPath.toUri(accountId, conversationUri), requireContext(), DRingService.class) - .putExtra(DRingService.KEY_MESSAGE_ID, transfer.getMessageId()) - .putExtra(DRingService.KEY_TRANSFER_ID, transfer.getFileId())); - } - - @Override - public void refuseFile(String accountId, Uri conversationUri, DataTransfer transfer) { - requireActivity().startService(new Intent(DRingService.ACTION_FILE_CANCEL, ConversationPath.toUri(accountId, conversationUri), requireContext(), DRingService.class) - .putExtra(DRingService.KEY_MESSAGE_ID, transfer.getMessageId()) - .putExtra(DRingService.KEY_TRANSFER_ID, transfer.getFileId())); - } - - @Override - public void shareFile(File path, String displayName) { - Context c = getContext(); - if (c == null) - return; - android.net.Uri fileUri = null; - try { - fileUri = ContentUriHandler.getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, path, displayName); - } catch (IllegalArgumentException e) { - Log.e("File Selector", "The selected file can't be shared: " + path.getName()); - } - if (fileUri != null) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - String type = c.getContentResolver().getType(fileUri.buildUpon().appendPath(displayName).build()); - sendIntent.setDataAndType(fileUri, type); - sendIntent.putExtra(Intent.EXTRA_STREAM, fileUri); - startActivity(Intent.createChooser(sendIntent, null)); - } - } - - @Override - public void openFile(File path, String displayName) { - Context c = getContext(); - if (c == null) - return; - android.net.Uri fileUri = null; - try { - fileUri = ContentUriHandler.getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, path, displayName); - } catch (IllegalArgumentException e) { - Log.e(TAG, "The selected file can't be shared: " + path.getName()); - } - if (fileUri != null) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_VIEW); - sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - String type = c.getContentResolver().getType(fileUri.buildUpon().appendPath(displayName).build()); - sendIntent.setDataAndType(fileUri, type); - sendIntent.putExtra(Intent.EXTRA_STREAM, fileUri); - //startActivity(Intent.createChooser(sendIntent, null)); - try { - startActivity(sendIntent); - } catch (ActivityNotFoundException e) { - Snackbar.make(getView(), R.string.conversation_open_file_error, Snackbar.LENGTH_LONG).show(); - Log.e("File Loader", "File of unknown type, could not open: " + path.getName()); - } - } - } - - boolean actionSendMsgText(int actionId) { - switch (actionId) { - case EditorInfo.IME_ACTION_SEND: - sendMessageText(); - return true; - } - return false; - } - - public void onClick() { - presenter.clickOnGoingPane(); - } - - @Override - public void onStart() { - super.onStart(); - presenter.resume(mIsBubble); - } - - @Override - public void onStop() { - super.onStop(); - presenter.pause(); - } - - @Override - public void onPause() { - super.onPause(); - //presenter.pause(); - } - - @Override - public void onResume() { - super.onResume(); - //presenter.resume(mIsBubble); - } - - @Override - public void onDestroy() { - mCompositeDisposable.dispose(); - super.onDestroy(); - } - - @Override - public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { - if (!isVisible()) { - return; - } - inflater.inflate(R.menu.conversation_actions, menu); - mAudioCallBtn = menu.findItem(R.id.conv_action_audiocall); - mVideoCallBtn = menu.findItem(R.id.conv_action_videocall); - } - - public void openContact() { - presenter.openContact(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - int itemId = item.getItemId(); - if (itemId == android.R.id.home) { - startActivity(new Intent(getActivity(), HomeActivity.class)); - return true; - } else if (itemId == R.id.conv_action_audiocall) { - presenter.goToCall(true); - return true; - } else if (itemId == R.id.conv_action_videocall) { - presenter.goToCall(false); - return true; - } else if (itemId == R.id.conv_contact_details) { - presenter.openContact(); - return true; - } - return super.onOptionsItemSelected(item); - } - - @Override - protected void initPresenter(ConversationPresenter presenter) { - ConversationPath path = ConversationPath.fromBundle(getArguments()); - mIsBubble = getArguments().getBoolean(NotificationServiceImpl.EXTRA_BUBBLE); - Log.w(TAG, "initPresenter " + path); - if (path == null) - return; - - Uri uri = path.getConversationUri(); - mAdapter = new ConversationAdapter(this, presenter); - presenter.init(uri, path.getAccountId()); - try { - mPreferences = SharedPreferencesServiceImpl.getConversationPreferences(requireContext(), path.getAccountId(), uri); - mPreferences.registerOnSharedPreferenceChangeListener(this); - presenter.setConversationColor(mPreferences.getInt(KEY_PREFERENCE_CONVERSATION_COLOR, getResources().getColor(R.color.color_primary_light))); - presenter.setConversationSymbol(mPreferences.getString(KEY_PREFERENCE_CONVERSATION_SYMBOL, getResources().getText(R.string.conversation_default_emoji).toString())); - mLastRead = mPreferences.getString(KEY_PREFERENCE_CONVERSATION_LAST_READ, null); - } catch (Exception e) { - Log.e(TAG, "Can't load conversation preferences"); - } - - if (locationServiceConnection == null) { - locationServiceConnection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - Log.w(TAG, "onServiceConnected"); - LocationSharingService.LocalBinder binder = (LocationSharingService.LocalBinder) service; - LocationSharingService locationService = binder.getService(); - ConversationPath path = new ConversationPath(presenter.getPath()); - if (locationService.isSharing(path)) { - showMap(path.getAccountId(), uri.getUri(), false); - } - try { - requireContext().unbindService(locationServiceConnection); - } catch (Exception e) { - Log.w(TAG, "Error unbinding service", e); - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - Log.w(TAG, "onServiceDisconnected"); - locationServiceConnection = null; - } - }; - - Log.w(TAG, "bindService"); - requireContext().bindService(new Intent(requireContext(), LocationSharingService.class), locationServiceConnection, 0); - } - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences prefs, String key) { - switch (key) { - case KEY_PREFERENCE_CONVERSATION_COLOR: - presenter.setConversationColor(prefs.getInt(KEY_PREFERENCE_CONVERSATION_COLOR, getResources().getColor(R.color.color_primary_light))); - break; - case KEY_PREFERENCE_CONVERSATION_SYMBOL: - presenter.setConversationSymbol(prefs.getString(KEY_PREFERENCE_CONVERSATION_SYMBOL, getResources().getText(R.string.conversation_default_emoji).toString())); - break; - } - } - - @Override - public void updateContact(Contact contact) { - String contactKey = contact.getPrimaryNumber(); - AvatarDrawable a = mSmallParticipantAvatars.get(contactKey); - if (a != null) { - a.update(contact); - mParticipantAvatars.get(contactKey).update(contact); - mAdapter.setPhoto(); - } else { - mCompositeDisposable.add(AvatarFactory.getAvatar(requireContext(), contact, true) - .subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(avatar -> { - mParticipantAvatars.put(contactKey, (AvatarDrawable) avatar); - mSmallParticipantAvatars.put(contactKey, new AvatarDrawable.Builder() - .withContact(contact) - .withCircleCrop(true) - .withPresence(false) - .build(requireContext())); - mAdapter.setPhoto(); - })); - } - } - - @Override - public void displayContact(Conversation conversation) { - mCompositeDisposable.add(AvatarFactory.getAvatar(requireContext(), conversation, true) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(d -> { - mConversationAvatar = (AvatarDrawable) d; - mParticipantAvatars.put(conversation.getUri().getRawRingId(), new AvatarDrawable((AvatarDrawable) d)); - setupActionbar(conversation); - })); - } - - @Override - public void displayOnGoingCallPane(final boolean display) { - binding.ongoingcallPane.setVisibility(display ? View.VISIBLE : View.GONE); - } - - @Override - public void displayNumberSpinner(final Conversation conversation, final Uri number) { - binding.numberSelector.setVisibility(View.VISIBLE); - //binding.numberSelector.setAdapter(new NumberAdapter(getActivity(), conversation.getContact(), false)); - binding.numberSelector.setSelection(getIndex(binding.numberSelector, number)); - } - - @Override - public void hideNumberSpinner() { - binding.numberSelector.setVisibility(View.GONE); - } - - @Override - public void clearMsgEdit() { - binding.msgInputTxt.setText(""); - } - - @Override - public void goToHome() { - - if (getActivity() instanceof ConversationActivity) { - getActivity().finish(); - } - } - - @Override - public void goToAddContact(Contact contact) { - startActivityForResult(ActionHelper.getAddNumberIntentForContact(contact), REQ_ADD_CONTACT); - } - - @Override - public void goToCallActivity(String conferenceId) { - startActivity(new Intent(Intent.ACTION_VIEW) - .setClass(requireActivity().getApplicationContext(), CallActivity.class) - .putExtra(NotificationService.KEY_CALL_ID, conferenceId)); - } - - @Override - public void goToContactActivity(String accountId, Uri uri) { - Toolbar toolbar = requireActivity().findViewById(R.id.main_toolbar); - ImageView logo = toolbar.findViewById(R.id.contact_image); - startActivity(new Intent(Intent.ACTION_VIEW, ConversationPath.toUri(accountId, uri)) - .setClass(requireActivity().getApplicationContext(), ContactDetailsActivity.class), - ActivityOptions.makeSceneTransitionAnimation(getActivity(), logo, "conversationIcon").toBundle()); - } - - @Override - public void goToCallActivityWithResult(String accountId, Uri conversationUri, Uri contactUri, boolean audioOnly) { - Intent intent = new Intent(Intent.ACTION_CALL) - .setClass(requireContext(), CallActivity.class) - .putExtras(ConversationPath.toBundle(accountId, conversationUri)) - .putExtra(Intent.EXTRA_PHONE_NUMBER, contactUri.getUri()) - .putExtra(CallFragment.KEY_AUDIO_ONLY, audioOnly); - startActivityForResult(intent, HomeActivity.REQUEST_CODE_CALL); - } - - private void setupActionbar(Conversation conversation) { - if (!isVisible()) { - return; - } - Activity activity = requireActivity(); - String displayName = conversation.getTitle(); - String identity = conversation.getUriTitle(); - - Toolbar toolbar = activity.findViewById(R.id.main_toolbar); - TextView title = toolbar.findViewById(R.id.contact_title); - TextView subtitle = toolbar.findViewById(R.id.contact_subtitle); - ImageView logo = toolbar.findViewById(R.id.contact_image); - - logo.setImageDrawable(mConversationAvatar); - logo.setVisibility(View.VISIBLE); - title.setText(displayName); - title.setTextSize(15); - title.setTypeface(null, Typeface.NORMAL); - - if (identity != null && !identity.equals(displayName)) { - subtitle.setText(identity); - subtitle.setVisibility(View.VISIBLE); - /*RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) title.getLayoutParams(); - params.addRule(RelativeLayout.ALIGN_TOP, R.id.contact_image); - title.setLayoutParams(params);*/ - } else { - subtitle.setText(""); - subtitle.setVisibility(View.GONE); - - /*RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) title.getLayoutParams(); - params.removeRule(RelativeLayout.ALIGN_TOP); - params.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE); - title.setLayoutParams(params);*/ - } - } - - public void blockContactRequest() { - presenter.onBlockIncomingContactRequest(); - } - - public void refuseContactRequest() { - presenter.onRefuseIncomingContactRequest(); - } - - public void acceptContactRequest() { - presenter.onAcceptIncomingContactRequest(); - } - - public void addContact() { - presenter.onAddContact(); - } - - @Override - public void onPrepareOptionsMenu(@NonNull Menu menu) { - super.onPrepareOptionsMenu(menu); - boolean visible = binding.cvMessageInput.getVisibility() == View.VISIBLE; - if (mAudioCallBtn != null) - mAudioCallBtn.setVisible(visible); - if (mVideoCallBtn != null) - mVideoCallBtn.setVisible(visible); - } - - @Override - public void switchToUnknownView(String contactDisplayName) { - binding.cvMessageInput.setVisibility(View.GONE); - binding.unknownContactPrompt.setVisibility(View.VISIBLE); - binding.trustRequestPrompt.setVisibility(View.GONE); - binding.tvTrustRequestMessage.setText(String.format(getString(R.string.message_contact_not_trusted), contactDisplayName)); - binding.trustRequestMessageLayout.setVisibility(View.VISIBLE); - currentBottomView = binding.unknownContactPrompt; - requireActivity().invalidateOptionsMenu(); - updateListPadding(); - } - - @Override - public void switchToIncomingTrustRequestView(String contactDisplayName) { - binding.cvMessageInput.setVisibility(View.GONE); - binding.unknownContactPrompt.setVisibility(View.GONE); - binding.trustRequestPrompt.setVisibility(View.VISIBLE); - binding.tvTrustRequestMessage.setText(String.format(getString(R.string.message_contact_not_trusted_yet), contactDisplayName)); - binding.trustRequestMessageLayout.setVisibility(View.VISIBLE); - currentBottomView = binding.trustRequestPrompt; - requireActivity().invalidateOptionsMenu(); - updateListPadding(); - } - - @Override - public void switchToConversationView() { - binding.cvMessageInput.setVisibility(View.VISIBLE); - binding.unknownContactPrompt.setVisibility(View.GONE); - binding.trustRequestPrompt.setVisibility(View.GONE); - binding.trustRequestMessageLayout.setVisibility(View.GONE); - currentBottomView = binding.cvMessageInput; - requireActivity().invalidateOptionsMenu(); - updateListPadding(); - } - - @Override - public void positiveMediaButtonClicked() { - presenter.clickOnGoingPane(); - } - - @Override - public void negativeMediaButtonClicked() { - presenter.clickOnGoingPane(); - } - - @Override - public void toggleMediaButtonClicked() { - presenter.clickOnGoingPane(); - } - - private void setLoading(boolean isLoading) { - if (binding == null) - return; - if (isLoading) { - binding.btnTakePicture.setVisibility(View.GONE); - binding.pbDataTransfer.setVisibility(View.VISIBLE); - } else { - binding.btnTakePicture.setVisibility(View.VISIBLE); - binding.pbDataTransfer.setVisibility(View.GONE); - } - } - - public void handleShareIntent(Intent intent) { - Log.w(TAG, "handleShareIntent " + intent); - - String action = intent.getAction(); - if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { - String type = intent.getType(); - if (type == null) { - Log.w(TAG, "Can't share with no type"); - return; - } - if (type.startsWith("text/plain")) { - binding.msgInputTxt.setText(intent.getStringExtra(Intent.EXTRA_TEXT)); - } else { - android.net.Uri uri = intent.getData(); - ClipData clip = intent.getClipData(); - if (uri == null && clip != null && clip.getItemCount() > 0) - uri = clip.getItemAt(0).getUri(); - if (uri == null) - return; - startFileSend(AndroidFileUtils.getCacheFile(requireContext(), uri).flatMapCompletable(this::sendFile)); - } - } else if (Intent.ACTION_VIEW.equals(action)) { - ConversationPath path = ConversationPath.fromIntent(intent); - if (path != null && intent.getBooleanExtra(EXTRA_SHOW_MAP, false)) { - shareLocation(); - } - } - } - - /** - * Creates an intent using Android Storage Access Framework - * This intent is then received by applications that can handle it like - * Downloads or Google drive - * @param file DataTransfer of the file that is going to be stored - * @param currentFileAbsolutePath absolute path of the file we want to save - */ - public void startSaveFile(DataTransfer file, String currentFileAbsolutePath){ - //Get the current file absolute path and store it - mCurrentFileAbsolutePath = currentFileAbsolutePath; - - try { - //Use Android Storage File Access to download the file - Intent downloadFileIntent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - downloadFileIntent.setType(AndroidFileUtils.getMimeTypeFromExtension(file.getExtension())); - downloadFileIntent.addCategory(Intent.CATEGORY_OPENABLE); - downloadFileIntent.putExtra(Intent.EXTRA_TITLE,file.getDisplayName()); - - startActivityForResult(downloadFileIntent, ConversationFragment.REQUEST_CODE_SAVE_FILE); - } catch (Exception e) { - Log.i(TAG, "No app detected for saving files."); - File directory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - if (!directory.exists()) { - directory.mkdirs(); - } - writeToFile(android.net.Uri.fromFile(new File(directory, file.getDisplayName()))); - } - } - - @Override - public void displayNetworkErrorPanel() { - if (binding != null) { - binding.errorMsgPane.setVisibility(View.VISIBLE); - binding.errorMsgPane.setOnClickListener(null); - binding.errorMsgPane.setText(R.string.error_no_network); - } - } - - @Override - public void displayAccountOfflineErrorPanel() { - if (binding != null) { - binding.errorMsgPane.setVisibility(View.VISIBLE); - binding.errorMsgPane.setOnClickListener(null); - binding.errorMsgPane.setText(R.string.error_account_offline); - for ( int idx = 0 ; idx < binding.btnContainer.getChildCount() ; idx++) { - binding.btnContainer.getChildAt(idx).setEnabled(false); - } - } - } - - @Override - public void setReadIndicatorStatus(boolean show) { - if (mAdapter != null) { - mAdapter.setReadIndicatorStatus(show); - } - } - - @Override - public void updateLastRead(String last) { - Log.w(TAG, "Updated last read " + mLastRead); - mLastRead = last; - if (mPreferences != null) - mPreferences.edit().putString(KEY_PREFERENCE_CONVERSATION_LAST_READ, last).apply(); - } - - @Override - public void hideErrorPanel() { - if (binding != null) { - binding.errorMsgPane.setVisibility(View.GONE); - } - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.kt b/ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.kt new file mode 100644 index 000000000..8da6e2e92 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.kt @@ -0,0 +1,1190 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.fragments + +import android.Manifest +import android.animation.LayoutTransition +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.app.ActivityOptions +import android.content.* +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import android.content.pm.PackageManager +import android.graphics.Typeface +import android.hardware.Camera +import android.net.Uri +import android.os.Bundle +import android.os.Environment +import android.os.IBinder +import android.provider.MediaStore +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.util.Log +import android.view.* +import android.view.inputmethod.EditorInfo +import android.widget.* +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.view.menu.MenuPopupHelper +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.Toolbar +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.inputmethod.InputContentInfoCompat +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import cx.ring.R +import cx.ring.adapters.ConversationAdapter +import cx.ring.application.JamiApplication +import cx.ring.client.CallActivity +import cx.ring.client.ContactDetailsActivity +import cx.ring.client.ConversationActivity +import cx.ring.client.HomeActivity +import cx.ring.databinding.FragConversationBinding +import cx.ring.interfaces.Colorable +import cx.ring.mvp.BaseSupportFragment +import cx.ring.service.DRingService +import cx.ring.service.LocationSharingService +import cx.ring.service.LocationSharingService.LocalBinder +import cx.ring.services.NotificationServiceImpl +import cx.ring.services.SharedPreferencesServiceImpl.Companion.getConversationPreferences +import cx.ring.utils.ActionHelper +import cx.ring.utils.AndroidFileUtils.copyFileToUri +import cx.ring.utils.AndroidFileUtils.createAudioFile +import cx.ring.utils.AndroidFileUtils.createImageFile +import cx.ring.utils.AndroidFileUtils.createVideoFile +import cx.ring.utils.AndroidFileUtils.getCacheFile +import cx.ring.utils.AndroidFileUtils.getMimeTypeFromExtension +import cx.ring.utils.AndroidFileUtils.getSpaceLeft +import cx.ring.utils.ContentUriHandler +import cx.ring.utils.ContentUriHandler.getUriForFile +import cx.ring.utils.ConversationPath +import cx.ring.utils.DeviceUtils.isTablet +import cx.ring.utils.MediaButtonsHelper.MediaButtonsHelperCallback +import cx.ring.views.AvatarDrawable +import cx.ring.views.AvatarFactory +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.conversation.ConversationPresenter +import net.jami.conversation.ConversationView +import net.jami.daemon.JamiService +import net.jami.model.* +import net.jami.model.Account.ComposingStatus +import net.jami.services.NotificationService +import java.io.File +import java.util.* + +@AndroidEntryPoint +class ConversationFragment : BaseSupportFragment<ConversationPresenter, ConversationView>(), + MediaButtonsHelperCallback, ConversationView, OnSharedPreferenceChangeListener { + private var locationServiceConnection: ServiceConnection? = null + private var binding: FragConversationBinding? = null + private var mAudioCallBtn: MenuItem? = null + private var mVideoCallBtn: MenuItem? = null + private var currentBottomView: View? = null + private var mAdapter: ConversationAdapter? = null + private var marginPx = 0 + private var marginPxTotal = 0 + private val animation = ValueAnimator() + private var mPreferences: SharedPreferences? = null + private var mCurrentPhoto: File? = null + private var mCurrentFileAbsolutePath: String? = null + private val mCompositeDisposable = CompositeDisposable() + private var mSelectedPosition = 0 + private var mIsBubble = false + private var mConversationAvatar: AvatarDrawable? = null + private val mParticipantAvatars: MutableMap<String, AvatarDrawable> = HashMap() + private val mSmallParticipantAvatars: MutableMap<String, AvatarDrawable> = HashMap() + private var mapWidth = 0 + private var mapHeight = 0 + private var mLastRead: String? = null + private var loading = true + + fun getConversationAvatar(uri: String): AvatarDrawable? { + return mParticipantAvatars[uri] + } + + fun getSmallConversationAvatar(uri: String): AvatarDrawable? { + synchronized(mSmallParticipantAvatars) { return mSmallParticipantAvatars[uri] } + } + + override fun refreshView(conversation: List<Interaction>) { + if (binding != null) binding!!.pbLoading.visibility = View.GONE + mAdapter?.let { adapter -> + adapter.updateDataset(conversation) + loading = false + } + requireActivity().invalidateOptionsMenu() + } + + override fun scrollToEnd() { + if (mAdapter!!.itemCount > 0) { + binding!!.histList.scrollToPosition(mAdapter!!.itemCount - 1) + } + } + + private fun updateListPadding() { + if (currentBottomView != null && currentBottomView!!.height != 0) { + val bottomViewHeight = if (currentBottomView != null) currentBottomView!!.height else 0 + setBottomPadding(binding!!.histList, bottomViewHeight + marginPxTotal) + val params = binding!!.mapCard.layoutParams as RelativeLayout.LayoutParams + params.bottomMargin = bottomViewHeight + marginPxTotal + binding!!.mapCard.layoutParams = params + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val res = resources + marginPx = res.getDimensionPixelSize(R.dimen.conversation_message_input_margin) + mapWidth = res.getDimensionPixelSize(R.dimen.location_sharing_minmap_width) + mapHeight = res.getDimensionPixelSize(R.dimen.location_sharing_minmap_height) + marginPxTotal = marginPx + return FragConversationBinding.inflate(inflater, container, false).let { binding -> + this@ConversationFragment.binding = binding + binding.presenter = this@ConversationFragment + animation.duration = 150 + animation.addUpdateListener { valueAnimator: ValueAnimator -> setBottomPadding(binding.histList, valueAnimator.animatedValue as Int) } + + ViewCompat.setOnApplyWindowInsetsListener(binding.histList) { _, insets: WindowInsetsCompat -> + marginPxTotal = marginPx + insets.systemWindowInsetBottom + updateListPadding() + insets.consumeSystemWindowInsets() + insets + } + val layout: View = binding.conversationLayout + + // remove action bar height for tablet layout + if (isTablet(layout.context)) { + layout.setPadding(layout.paddingLeft, 0, layout.paddingRight, layout.paddingBottom) + } + val paddingTop = layout.paddingTop + ViewCompat.setOnApplyWindowInsetsListener(layout) { v: View, insets: WindowInsetsCompat -> + v.setPadding(v.paddingLeft, paddingTop + insets.systemWindowInsetTop, v.paddingRight, v.paddingBottom) + insets.consumeSystemWindowInsets() + insets + } + binding.ongoingcallPane.visibility = View.GONE + binding.msgInputTxt.setMediaListener { contentInfo: InputContentInfoCompat -> + startFileSend( + getCacheFile(requireContext(), contentInfo.contentUri) + .flatMapCompletable { file: File -> sendFile(file) } + .doFinally { contentInfo.releasePermission() }) + } + binding.msgInputTxt.setOnEditorActionListener { _, actionId: Int, _ -> actionSendMsgText(actionId) } + binding.msgInputTxt.onFocusChangeListener = View.OnFocusChangeListener { view: View, hasFocus: Boolean -> + if (hasFocus) { + val fragment = childFragmentManager.findFragmentById(R.id.mapLayout) + if (fragment != null) { + (fragment as LocationSharingFragment).hideControls() + } + } + } + binding.msgInputTxt.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable) { + val message = s.toString() + val hasMessage = !TextUtils.isEmpty(message) + presenter.onComposingChanged(hasMessage) + if (hasMessage) { + binding.msgSend.visibility = View.VISIBLE + binding.emojiSend.visibility = View.GONE + } else { + binding.msgSend.visibility = View.GONE + binding.emojiSend.visibility = View.VISIBLE + } + mPreferences?.let { preferences -> + if (hasMessage) + preferences.edit().putString(KEY_PREFERENCE_PENDING_MESSAGE, message).apply() + else + preferences.edit().remove(KEY_PREFERENCE_PENDING_MESSAGE).apply() + } + } + }) + setHasOptionsMenu(true) + binding.root + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding?.let { binding -> + mPreferences?.let { preferences -> + val pendingMessage = preferences.getString(KEY_PREFERENCE_PENDING_MESSAGE, null) + if (pendingMessage != null && pendingMessage.isNotEmpty()) { + binding.msgInputTxt.setText(pendingMessage) + binding.msgSend.visibility = View.VISIBLE + binding.emojiSend.visibility = View.GONE + } + } + binding.msgInputTxt.addOnLayoutChangeListener { _, _, _, _, _, oldLeft, oldTop, oldRight, oldBottom -> + if (oldBottom == 0 && oldTop == 0) { + updateListPadding() + } else { + if (animation.isStarted) animation.cancel() + animation.setIntValues( + binding.histList.paddingBottom, + (if (currentBottomView == null) 0 else currentBottomView!!.height) + marginPxTotal + ) + animation.start() + } + } + binding.histList.addOnScrollListener(object : RecyclerView.OnScrollListener() { + // The minimum amount of items to have below current scroll position + // before loading more. + val visibleThreshold = 3 + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {} + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val layoutManager = recyclerView.layoutManager as LinearLayoutManager? + if (!loading && layoutManager!!.findFirstVisibleItemPosition() < visibleThreshold) { + loading = true + presenter.loadMore() + } + } + }) + val animator = binding.histList.itemAnimator as DefaultItemAnimator? + animator?.supportsChangeAnimations = false + binding.histList.adapter = mAdapter + } + } + + override fun setConversationColor(color: Int) { + val activity = activity as Colorable? + activity?.setColor(color) + mAdapter?.setPrimaryColor(color) + } + + override fun setConversationSymbol(symbol: CharSequence) { + binding?.emojiSend?.text = symbol + } + + override fun onDestroyView() { + mPreferences?.unregisterOnSharedPreferenceChangeListener(this) + animation.removeAllUpdateListeners() + binding?.histList?.adapter = null + mCompositeDisposable.clear() + locationServiceConnection?.let { + try { + requireContext().unbindService(it) + } catch (e: Exception) { + Log.w(TAG, "Error unbinding service: " + e.message) + } + } + mAdapter = null + super.onDestroyView() + binding = null + } + + override fun onContextItemSelected(item: MenuItem): Boolean { + return if (mAdapter!!.onContextItemSelected(item)) true + else super.onContextItemSelected(item) + } + + fun updateAdapterItem() { + if (mSelectedPosition != -1) { + mAdapter!!.notifyItemChanged(mSelectedPosition) + mSelectedPosition = -1 + } + } + + fun sendMessageText() { + val message = binding!!.msgInputTxt.text.toString() + clearMsgEdit() + presenter.sendTextMessage(message) + } + + fun sendEmoji() { + presenter.sendTextMessage(binding!!.emojiSend.text.toString()) + } + + @SuppressLint("RestrictedApi") + fun expandMenu(v: View) { + val context = requireContext() + val popup = PopupMenu(context, v) + popup.inflate(R.menu.conversation_share_actions) + popup.setOnMenuItemClickListener { item: MenuItem -> + when (item.itemId) { + R.id.conv_send_audio -> sendAudioMessage() + R.id.conv_send_video -> sendVideoMessage() + R.id.conv_send_file -> presenter.selectFile() + R.id.conv_share_location -> shareLocation() + R.id.chat_plugins -> presenter.showPluginListHandlers() + } + false + } + popup.menu.findItem(R.id.chat_plugins).isVisible = JamiService.getPluginsEnabled() && !JamiService.getChatHandlers().isEmpty() + val menuHelper = MenuPopupHelper(context, (popup.menu as MenuBuilder), v) + menuHelper.setForceShowIcon(true) + menuHelper.show() + } + + override fun showPluginListHandlers(accountId: String, contactId: String) { + Log.w(TAG, "show Plugin Chat Handlers List") + val fragment = PluginHandlersListFragment.newInstance(accountId, contactId) + childFragmentManager.beginTransaction() + .add(R.id.pluginListHandlers, fragment, PluginHandlersListFragment.TAG) + .commit() + binding?.let { binding -> + val params = binding.mapCard.layoutParams as RelativeLayout.LayoutParams + if (params.width != ViewGroup.LayoutParams.MATCH_PARENT) { + params.width = ViewGroup.LayoutParams.MATCH_PARENT + params.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.mapCard.layoutParams = params + } + binding.mapCard.visibility = View.VISIBLE + } + } + + fun hidePluginListHandlers() { + if (binding!!.mapCard.visibility != View.GONE) { + binding!!.mapCard.visibility = View.GONE + val fragmentManager = childFragmentManager + val fragment = fragmentManager.findFragmentById(R.id.pluginListHandlers) + if (fragment != null) { + fragmentManager.beginTransaction() + .remove(fragment) + .commit() + } + } + val params = binding!!.mapCard.layoutParams as RelativeLayout.LayoutParams + if (params.width != mapWidth) { + params.width = mapWidth + params.height = mapHeight + binding!!.mapCard.layoutParams = params + } + } + + private fun shareLocation() { + presenter.shareLocation() + } + + fun closeLocationSharing(isSharing: Boolean) { + val params = binding!!.mapCard.layoutParams as RelativeLayout.LayoutParams + if (params.width != mapWidth) { + params.width = mapWidth + params.height = mapHeight + binding!!.mapCard.layoutParams = params + } + if (!isSharing) hideMap() + } + + fun openLocationSharing() { + binding!!.conversationLayout.layoutTransition.enableTransitionType(LayoutTransition.CHANGING) + val params = binding!!.mapCard.layoutParams as RelativeLayout.LayoutParams + if (params.width != ViewGroup.LayoutParams.MATCH_PARENT) { + params.width = ViewGroup.LayoutParams.MATCH_PARENT + params.height = ViewGroup.LayoutParams.MATCH_PARENT + binding!!.mapCard.layoutParams = params + } + } + + override fun startShareLocation(accountId: String, conversationId: String) { + showMap(accountId, conversationId, true) + } + + /** + * Used to update with the past adapter position when a long click was registered + */ + fun updatePosition(position: Int) { + mSelectedPosition = position + } + + override fun showMap(accountId: String, contactId: String, open: Boolean) { + if (binding!!.mapCard.visibility == View.GONE) { + Log.w(TAG, "showMap $accountId $contactId") + val fragmentManager = childFragmentManager + val fragment = LocationSharingFragment.newInstance(accountId, contactId, open) + fragmentManager.beginTransaction() + .add(R.id.mapLayout, fragment, "map") + .commit() + binding!!.mapCard.visibility = View.VISIBLE + } + if (open) { + val fragment = childFragmentManager.findFragmentById(R.id.mapLayout) + if (fragment != null) { + (fragment as LocationSharingFragment).showControls() + } + } + } + + override fun hideMap() { + if (binding!!.mapCard.visibility != View.GONE) { + binding!!.mapCard.visibility = View.GONE + val fragmentManager = childFragmentManager + val fragment = fragmentManager.findFragmentById(R.id.mapLayout) + if (fragment != null) { + fragmentManager.beginTransaction() + .remove(fragment) + .commit() + } + } + } + + private fun sendAudioMessage() { + if (!presenter.deviceRuntimeService.hasAudioPermission()) { + requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_CODE_CAPTURE_AUDIO) + } else { + try { + val ctx = requireContext() + val intent = Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION) + mCurrentPhoto = createAudioFile(ctx) + startActivityForResult(intent, REQUEST_CODE_CAPTURE_AUDIO) + } catch (ex: Exception) { + Log.e(TAG, "sendAudioMessage: error", ex) + Toast.makeText(activity, "Can't find audio recorder app", Toast.LENGTH_SHORT).show() + } + } + } + + private fun sendVideoMessage() { + if (!presenter.deviceRuntimeService.hasVideoPermission()) { + requestPermissions(arrayOf(Manifest.permission.CAMERA), REQUEST_CODE_CAPTURE_VIDEO) + } else { + try { + val context = requireContext() + val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply { + putExtra("android.intent.extras.CAMERA_FACING", Camera.CameraInfo.CAMERA_FACING_FRONT) + putExtra("android.intent.extras.LENS_FACING_FRONT", 1) + putExtra("android.intent.extra.USE_FRONT_CAMERA", true) + putExtra(MediaStore.EXTRA_OUTPUT, getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, createVideoFile(context).apply { + mCurrentPhoto = this + })) + } + startActivityForResult(intent, REQUEST_CODE_CAPTURE_VIDEO) + } catch (ex: Exception) { + Log.e(TAG, "sendVideoMessage: error", ex) + Toast.makeText(activity, "Can't find video recorder app", Toast.LENGTH_SHORT).show() + } + } + } + + fun takePicture() { + if (!presenter.deviceRuntimeService.hasVideoPermission()) { + requestPermissions(arrayOf(Manifest.permission.CAMERA), REQUEST_CODE_TAKE_PICTURE) + return + } + val c = context ?: return + try { + val photoFile = createImageFile(c) + Log.i(TAG, "takePicture: trying to save to $photoFile") + val photoURI = getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, photoFile) + val takePictureIntent = + Intent(MediaStore.ACTION_IMAGE_CAPTURE).putExtra(MediaStore.EXTRA_OUTPUT, photoURI) + .putExtra("android.intent.extras.CAMERA_FACING", 1) + .putExtra("android.intent.extras.LENS_FACING_FRONT", 1) + .putExtra("android.intent.extra.USE_FRONT_CAMERA", true) + mCurrentPhoto = photoFile + startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PICTURE) + } catch (e: Exception) { + Toast.makeText(c, "Error taking picture: " + e.localizedMessage, Toast.LENGTH_SHORT) + .show() + } + } + + override fun askWriteExternalStoragePermission() { + requestPermissions( + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + JamiApplication.PERMISSIONS_REQUEST + ) + } + + override fun openFilePicker() { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "*/*" + startActivityForResult(intent, REQUEST_CODE_FILE_PICKER) + } + + private fun sendFile(file: File): Completable { + return Completable.fromAction { presenter.sendFile(file) } + } + + private fun startFileSend(op: Completable) { + setLoading(true) + op.observeOn(AndroidSchedulers.mainThread()) + .doFinally { setLoading(false) } + .subscribe({}) { e: Throwable? -> + Log.e(TAG, "startFileSend: not able to create cache file", e) + displayErrorToast(Error.INVALID_FILE) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { + Log.w(TAG, "onActivityResult: $requestCode $resultCode $resultData") + val uri = resultData?.data + if (requestCode == REQUEST_CODE_FILE_PICKER) { + if (resultCode == Activity.RESULT_OK && uri != null) { + startFileSend( + getCacheFile(requireContext(), uri) + .observeOn(AndroidSchedulers.mainThread()) + .flatMapCompletable { file: File -> sendFile(file) }) + } + } else if (requestCode == REQUEST_CODE_TAKE_PICTURE || requestCode == REQUEST_CODE_CAPTURE_AUDIO || requestCode == REQUEST_CODE_CAPTURE_VIDEO) { + if (resultCode != Activity.RESULT_OK) { + mCurrentPhoto = null + return + } + Log.w(TAG, "onActivityResult: mCurrentPhoto " + mCurrentPhoto!!.absolutePath + " " + mCurrentPhoto!!.exists() + " " + mCurrentPhoto!!.length()) + var file: Single<File>? = null + if (mCurrentPhoto == null || !mCurrentPhoto!!.exists() || mCurrentPhoto!!.length() == 0L) { + if (uri != null) { + file = getCacheFile(requireContext(), uri) + } + } else { + file = Single.just(mCurrentPhoto) + } + mCurrentPhoto = null + if (file == null) { + Toast.makeText(activity, "Can't find picture", Toast.LENGTH_SHORT).show() + return + } + startFileSend(file.flatMapCompletable { f -> sendFile(f) }) + } else if (requestCode == REQUEST_CODE_SAVE_FILE) { + if (resultCode == Activity.RESULT_OK && uri != null) { + writeToFile(uri) + } + } + } + + private fun writeToFile(data: Uri) { + val path = mCurrentFileAbsolutePath ?: return + val cr = context?.contentResolver ?: return + val input = File(path) + mCompositeDisposable.add( + copyFileToUri(cr, input, data) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ Toast.makeText(context, R.string.file_saved_successfully, Toast.LENGTH_SHORT).show() }) + { Toast.makeText(context, R.string.generic_error, Toast.LENGTH_SHORT).show() }) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array<String>, + grantResults: IntArray + ) { + var i = 0 + val n = permissions.size + while (i < n) { + val granted = grantResults[i] == PackageManager.PERMISSION_GRANTED + when (permissions[i]) { + Manifest.permission.CAMERA -> { + presenter.cameraPermissionChanged(granted) + if (granted) { + if (requestCode == REQUEST_CODE_CAPTURE_VIDEO) { + sendVideoMessage() + } else if (requestCode == REQUEST_CODE_TAKE_PICTURE) { + takePicture() + } + } + return + } + Manifest.permission.RECORD_AUDIO -> { + if (granted) { + if (requestCode == REQUEST_CODE_CAPTURE_AUDIO) { + sendAudioMessage() + } + } + return + } + else -> { + } + } + i++ + } + } + + override fun addElement(element: Interaction) { + if (mLastRead != null && mLastRead == element.messageId) element.read() + if (mAdapter!!.add(element)) scrollToEnd() + loading = false + } + + override fun updateElement(element: Interaction) { + mAdapter!!.update(element) + } + + override fun removeElement(element: Interaction) { + mAdapter!!.remove(element) + } + + override fun setComposingStatus(composingStatus: ComposingStatus) { + mAdapter!!.setComposingStatus(composingStatus) + if (composingStatus == ComposingStatus.Active) scrollToEnd() + } + + override fun setLastDisplayed(interaction: Interaction) { + mAdapter!!.setLastDisplayed(interaction) + } + + override fun acceptFile(accountId: String, conversationUri: net.jami.model.Uri, transfer: DataTransfer) { + if (transfer.messageId == null && transfer.fileId == null) + return + val cacheDir = requireContext().cacheDir + val spaceLeft = getSpaceLeft(cacheDir.toString()) + if (spaceLeft == -1L || transfer.totalSize > spaceLeft) { + presenter.noSpaceLeft() + return + } + requireActivity().startService(Intent(DRingService.ACTION_FILE_ACCEPT, ConversationPath.toUri(accountId, conversationUri), + requireContext(), DRingService::class.java) + .putExtra(DRingService.KEY_MESSAGE_ID, transfer.messageId) + .putExtra(DRingService.KEY_TRANSFER_ID, transfer.fileId) + ) + } + + override fun refuseFile(accountId: String, conversationUri: net.jami.model.Uri, transfer: DataTransfer) { + if (transfer.messageId == null && transfer.fileId == null) + return + requireActivity().startService(Intent(DRingService.ACTION_FILE_CANCEL, ConversationPath.toUri(accountId, conversationUri), + requireContext(), DRingService::class.java) + .putExtra(DRingService.KEY_MESSAGE_ID, transfer.messageId) + .putExtra(DRingService.KEY_TRANSFER_ID, transfer.fileId) + ) + } + + override fun shareFile(path: File, displayName: String) { + val c = context ?: return + var fileUri: Uri? = null + try { + fileUri = getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, path, displayName) + } catch (e: IllegalArgumentException) { + Log.e("File Selector", "The selected file can't be shared: " + path.name) + } + if (fileUri != null) { + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_SEND + sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val type = + c.contentResolver.getType(fileUri.buildUpon().appendPath(displayName).build()) + sendIntent.setDataAndType(fileUri, type) + sendIntent.putExtra(Intent.EXTRA_STREAM, fileUri) + startActivity(Intent.createChooser(sendIntent, null)) + } + } + + override fun openFile(path: File, displayName: String) { + val c = context ?: return + var fileUri: Uri? = null + try { + fileUri = getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, path, displayName) + } catch (e: IllegalArgumentException) { + Log.e(TAG, "The selected file can't be shared: " + path.name) + } + if (fileUri != null) { + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_VIEW + sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val type = + c.contentResolver.getType(fileUri.buildUpon().appendPath(displayName).build()) + sendIntent.setDataAndType(fileUri, type) + sendIntent.putExtra(Intent.EXTRA_STREAM, fileUri) + //startActivity(Intent.createChooser(sendIntent, null)); + try { + startActivity(sendIntent) + } catch (e: ActivityNotFoundException) { + Snackbar.make(requireView(), R.string.conversation_open_file_error, Snackbar.LENGTH_LONG) + .show() + Log.e("File Loader", "File of unknown type, could not open: " + path.name) + } + } + } + + fun actionSendMsgText(actionId: Int): Boolean { + when (actionId) { + EditorInfo.IME_ACTION_SEND -> { + sendMessageText() + return true + } + } + return false + } + + fun onClick() { + presenter.clickOnGoingPane() + } + + override fun onStart() { + super.onStart() + presenter.resume(mIsBubble) + } + + override fun onStop() { + super.onStop() + presenter.pause() + } + + override fun onPause() { + super.onPause() + //presenter.pause(); + } + + override fun onResume() { + super.onResume() + //presenter.resume(mIsBubble); + } + + override fun onDestroy() { + mCompositeDisposable.dispose() + super.onDestroy() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + if (!isVisible) { + return + } + inflater.inflate(R.menu.conversation_actions, menu) + mAudioCallBtn = menu.findItem(R.id.conv_action_audiocall) + mVideoCallBtn = menu.findItem(R.id.conv_action_videocall) + } + + fun openContact() { + presenter.openContact() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val itemId = item.itemId + if (itemId == android.R.id.home) { + startActivity(Intent(activity, HomeActivity::class.java)) + return true + } else if (itemId == R.id.conv_action_audiocall) { + presenter.goToCall(true) + return true + } else if (itemId == R.id.conv_action_videocall) { + presenter.goToCall(false) + return true + } else if (itemId == R.id.conv_contact_details) { + presenter.openContact() + return true + } + return super.onOptionsItemSelected(item) + } + + override fun initPresenter(presenter: ConversationPresenter) { + val path = ConversationPath.fromBundle(arguments) + mIsBubble = requireArguments().getBoolean(NotificationServiceImpl.EXTRA_BUBBLE) + Log.w(TAG, "initPresenter $path") + if (path == null) return + val uri = path.conversationUri + mAdapter = ConversationAdapter(this, presenter) + presenter.init(uri, path.accountId) + try { + mPreferences = getConversationPreferences(requireContext(), path.accountId, uri).also { preferences -> + preferences.registerOnSharedPreferenceChangeListener(this) + presenter.setConversationColor(preferences.getInt(KEY_PREFERENCE_CONVERSATION_COLOR, resources.getColor(R.color.color_primary_light))) + presenter.setConversationSymbol(preferences.getString(KEY_PREFERENCE_CONVERSATION_SYMBOL, resources.getText(R.string.conversation_default_emoji).toString())!!) + mLastRead = preferences.getString(KEY_PREFERENCE_CONVERSATION_LAST_READ, null) + } + } catch (e: Exception) { + Log.e(TAG, "Can't load conversation preferences") + } + var connection = locationServiceConnection + if (connection == null) { + connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + Log.w(TAG, "onServiceConnected") + val binder = service as LocalBinder + val locationService = binder.service + //val path = ConversationPath(presenter.path) + if (locationService.isSharing(path)) { + showMap(path.accountId, uri.uri, false) + } + /*try { + requireContext().unbindService(locationServiceConnection!!) + } catch (e: Exception) { + Log.w(TAG, "Error unbinding service", e) + }*/ + } + + override fun onServiceDisconnected(name: ComponentName) { + Log.w(TAG, "onServiceDisconnected") + locationServiceConnection = null + } + } + locationServiceConnection = connection + Log.w(TAG, "bindService") + requireContext().bindService(Intent(requireContext(), LocationSharingService::class.java), connection, 0) + } + } + + override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) { + when (key) { + KEY_PREFERENCE_CONVERSATION_COLOR -> presenter.setConversationColor( + prefs.getInt(KEY_PREFERENCE_CONVERSATION_COLOR, resources.getColor(R.color.color_primary_light))) + KEY_PREFERENCE_CONVERSATION_SYMBOL -> presenter.setConversationSymbol( + prefs.getString(KEY_PREFERENCE_CONVERSATION_SYMBOL, resources.getText(R.string.conversation_default_emoji).toString())!!) + } + } + + override fun updateContact(contact: Contact) { + val contactKey = contact.primaryNumber + val a = mSmallParticipantAvatars[contactKey] + if (a != null) { + a.update(contact) + mParticipantAvatars[contactKey]!!.update(contact) + mAdapter?.setPhoto() + } else { + mCompositeDisposable.add(AvatarFactory.getAvatar(requireContext(), contact, true) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { avatar -> + mParticipantAvatars[contactKey] = avatar as AvatarDrawable + mSmallParticipantAvatars[contactKey] = AvatarDrawable.Builder() + .withContact(contact) + .withCircleCrop(true) + .withPresence(false) + .build(requireContext()) + mAdapter?.setPhoto() + }) + } + } + + override fun displayContact(conversation: Conversation) { + mCompositeDisposable.add(AvatarFactory.getAvatar(requireContext(), conversation, true) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { d -> + mConversationAvatar = d as AvatarDrawable + mParticipantAvatars[conversation.uri.rawRingId] = AvatarDrawable(d) + setupActionbar(conversation) + }) + } + + override fun displayOnGoingCallPane(display: Boolean) { + binding!!.ongoingcallPane.visibility = if (display) View.VISIBLE else View.GONE + } + + override fun displayNumberSpinner(conversation: Conversation, number: net.jami.model.Uri) { + binding!!.numberSelector.visibility = View.VISIBLE + //binding.numberSelector.setAdapter(new NumberAdapter(getActivity(), conversation.getContact(), false)); + binding!!.numberSelector.setSelection(getIndex(binding!!.numberSelector, number)) + } + + override fun hideNumberSpinner() { + binding!!.numberSelector.visibility = View.GONE + } + + override fun clearMsgEdit() { + binding!!.msgInputTxt.setText("") + } + + override fun goToHome() { + if (activity is ConversationActivity) { + requireActivity().finish() + } + } + + override fun goToAddContact(contact: Contact) { + startActivityForResult(ActionHelper.getAddNumberIntentForContact(contact), REQ_ADD_CONTACT) + } + + override fun goToCallActivity(conferenceId: String) { + startActivity(Intent(Intent.ACTION_VIEW) + .setClass(requireContext().applicationContext, CallActivity::class.java) + .putExtra(NotificationService.KEY_CALL_ID, conferenceId)) + } + + override fun goToContactActivity(accountId: String, uri: net.jami.model.Uri) { + val toolbar: Toolbar = requireActivity().findViewById(R.id.main_toolbar) + val logo = toolbar.findViewById<ImageView>(R.id.contact_image) + startActivity(Intent(Intent.ACTION_VIEW, ConversationPath.toUri(accountId, uri)) + .setClass(requireContext().applicationContext, ContactDetailsActivity::class.java), + ActivityOptions.makeSceneTransitionAnimation(activity, logo, "conversationIcon") + .toBundle()) + } + + override fun goToCallActivityWithResult( + accountId: String, + conversationUri: net.jami.model.Uri, + contactUri: net.jami.model.Uri, + audioOnly: Boolean + ) { + val intent = Intent(Intent.ACTION_CALL) + .setClass(requireContext(), CallActivity::class.java) + .putExtras(ConversationPath.toBundle(accountId, conversationUri)) + .putExtra(Intent.EXTRA_PHONE_NUMBER, contactUri.uri) + .putExtra(CallFragment.KEY_AUDIO_ONLY, audioOnly) + startActivityForResult(intent, HomeActivity.REQUEST_CODE_CALL) + } + + private fun setupActionbar(conversation: Conversation) { + if (!isVisible) { + return + } + val activity: Activity = requireActivity() + val displayName = conversation.title + val identity = conversation.uriTitle + val toolbar: Toolbar = activity.findViewById(R.id.main_toolbar) + val title = toolbar.findViewById<TextView>(R.id.contact_title) + val subtitle = toolbar.findViewById<TextView>(R.id.contact_subtitle) + val logo = toolbar.findViewById<ImageView>(R.id.contact_image) + logo.setImageDrawable(mConversationAvatar) + logo.visibility = View.VISIBLE + title.text = displayName + title.textSize = 15f + title.setTypeface(null, Typeface.NORMAL) + if (identity != null && identity != displayName) { + subtitle.text = identity + subtitle.visibility = View.VISIBLE + /*RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) title.getLayoutParams(); + params.addRule(RelativeLayout.ALIGN_TOP, R.id.contact_image); + title.setLayoutParams(params);*/ + } else { + subtitle.text = "" + subtitle.visibility = View.GONE + + /*RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) title.getLayoutParams(); + params.removeRule(RelativeLayout.ALIGN_TOP); + params.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE); + title.setLayoutParams(params);*/ + } + } + + fun blockContactRequest() { + presenter.onBlockIncomingContactRequest() + } + + fun refuseContactRequest() { + presenter.onRefuseIncomingContactRequest() + } + + fun acceptContactRequest() { + presenter.onAcceptIncomingContactRequest() + } + + fun addContact() { + presenter.onAddContact() + } + + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + val visible = binding!!.cvMessageInput.visibility == View.VISIBLE + if (mAudioCallBtn != null) mAudioCallBtn!!.isVisible = visible + if (mVideoCallBtn != null) mVideoCallBtn!!.isVisible = visible + } + + override fun switchToUnknownView(contactDisplayName: String) { + binding?.apply { + cvMessageInput.visibility = View.GONE + unknownContactPrompt.visibility = View.VISIBLE + trustRequestPrompt.visibility = View.GONE + tvTrustRequestMessage.text = String.format(getString(R.string.message_contact_not_trusted), contactDisplayName) + trustRequestMessageLayout.visibility = View.VISIBLE + currentBottomView = unknownContactPrompt + } + requireActivity().invalidateOptionsMenu() + updateListPadding() + } + + override fun switchToIncomingTrustRequestView(contactDisplayName: String) { + binding?.apply { + cvMessageInput.visibility = View.GONE + unknownContactPrompt.visibility = View.GONE + trustRequestPrompt.visibility = View.VISIBLE + tvTrustRequestMessage.text = String.format(getString(R.string.message_contact_not_trusted_yet), contactDisplayName) + trustRequestMessageLayout.visibility = View.VISIBLE + currentBottomView = trustRequestPrompt + } + requireActivity().invalidateOptionsMenu() + updateListPadding() + } + + override fun switchToConversationView() { + binding?.apply { + cvMessageInput.visibility = View.VISIBLE + unknownContactPrompt.visibility = View.GONE + trustRequestPrompt.visibility = View.GONE + trustRequestMessageLayout.visibility = View.GONE + currentBottomView = cvMessageInput + } + requireActivity().invalidateOptionsMenu() + updateListPadding() + } + + override fun switchToSyncingView() { + binding?.apply { + cvMessageInput.visibility = View.GONE + unknownContactPrompt.visibility = View.GONE + trustRequestPrompt.visibility = View.GONE + trustRequestMessageLayout.visibility = View.VISIBLE + tvTrustRequestMessage.text = "Syncing conversation..." + } + currentBottomView = null + requireActivity().invalidateOptionsMenu() + updateListPadding() + } + override fun switchToEndedView() { + binding?.apply { + cvMessageInput.visibility = View.GONE + unknownContactPrompt.visibility = View.GONE + trustRequestPrompt.visibility = View.GONE + trustRequestMessageLayout.visibility = View.VISIBLE + tvTrustRequestMessage.text = "Conversation ended" + } + currentBottomView = null + requireActivity().invalidateOptionsMenu() + updateListPadding() + } + + override fun positiveMediaButtonClicked() { + presenter.clickOnGoingPane() + } + + override fun negativeMediaButtonClicked() { + presenter.clickOnGoingPane() + } + + override fun toggleMediaButtonClicked() { + presenter.clickOnGoingPane() + } + + private fun setLoading(isLoading: Boolean) { + if (binding == null) return + if (isLoading) { + binding!!.btnTakePicture.visibility = View.GONE + binding!!.pbDataTransfer.visibility = View.VISIBLE + } else { + binding!!.btnTakePicture.visibility = View.VISIBLE + binding!!.pbDataTransfer.visibility = View.GONE + } + } + + fun handleShareIntent(intent: Intent) { + Log.w(TAG, "handleShareIntent $intent") + val action = intent.action + if (Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action) { + val type = intent.type + if (type == null) { + Log.w(TAG, "Can't share with no type") + return + } + if (type.startsWith("text/plain")) { + binding!!.msgInputTxt.setText(intent.getStringExtra(Intent.EXTRA_TEXT)) + } else { + var uri = intent.data + val clip = intent.clipData + if (uri == null && clip != null && clip.itemCount > 0) uri = clip.getItemAt(0).uri + if (uri == null) return + startFileSend( + getCacheFile(requireContext(), uri) + .flatMapCompletable { file -> sendFile(file) }) + } + } else if (Intent.ACTION_VIEW == action) { + val path = ConversationPath.fromIntent(intent) + if (path != null && intent.getBooleanExtra(EXTRA_SHOW_MAP, false)) { + shareLocation() + } + } + } + + /** + * Creates an intent using Android Storage Access Framework + * This intent is then received by applications that can handle it like + * Downloads or Google drive + * @param file DataTransfer of the file that is going to be stored + * @param currentFileAbsolutePath absolute path of the file we want to save + */ + override fun startSaveFile(file: DataTransfer, currentFileAbsolutePath: String) { + //Get the current file absolute path and store it + mCurrentFileAbsolutePath = currentFileAbsolutePath + try { + //Use Android Storage File Access to download the file + val downloadFileIntent = Intent(Intent.ACTION_CREATE_DOCUMENT) + downloadFileIntent.type = getMimeTypeFromExtension(file.extension) + downloadFileIntent.addCategory(Intent.CATEGORY_OPENABLE) + downloadFileIntent.putExtra(Intent.EXTRA_TITLE, file.displayName) + startActivityForResult(downloadFileIntent, REQUEST_CODE_SAVE_FILE) + } catch (e: Exception) { + Log.i(TAG, "No app detected for saving files.") + val directory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!directory.exists()) { + directory.mkdirs() + } + writeToFile(Uri.fromFile(File(directory, file.displayName))) + } + } + + override fun displayNetworkErrorPanel() { + binding?.apply { + errorMsgPane.visibility = View.VISIBLE + errorMsgPane.setOnClickListener(null) + errorMsgPane.setText(R.string.error_no_network) + } + } + + override fun displayAccountOfflineErrorPanel() { + binding?.apply { + errorMsgPane.visibility = View.VISIBLE + errorMsgPane.setOnClickListener(null) + errorMsgPane.setText(R.string.error_account_offline) + for (idx in 0 until btnContainer.childCount) { + btnContainer.getChildAt(idx).isEnabled = false + } + } + } + + override fun setReadIndicatorStatus(show: Boolean) { + mAdapter?.setReadIndicatorStatus(show) + } + + override fun updateLastRead(last: String) { + Log.w(TAG, "Updated last read $mLastRead") + mLastRead = last + mPreferences?.edit()?.putString(KEY_PREFERENCE_CONVERSATION_LAST_READ, last)?.apply() + } + + override fun hideErrorPanel() { + binding?.errorMsgPane?.visibility = View.GONE + } + + companion object { + private val TAG = ConversationFragment::class.java.simpleName + const val REQ_ADD_CONTACT = 42 + const val KEY_PREFERENCE_PENDING_MESSAGE = "pendingMessage" + const val KEY_PREFERENCE_CONVERSATION_COLOR = "color" + const val KEY_PREFERENCE_CONVERSATION_LAST_READ = "lastRead" + const val KEY_PREFERENCE_CONVERSATION_SYMBOL = "symbol" + const val EXTRA_SHOW_MAP = "showMap" + private const val REQUEST_CODE_FILE_PICKER = 1000 + private const val REQUEST_PERMISSION_CAMERA = 1001 + private const val REQUEST_CODE_TAKE_PICTURE = 1002 + private const val REQUEST_CODE_SAVE_FILE = 1003 + private const val REQUEST_CODE_CAPTURE_AUDIO = 1004 + private const val REQUEST_CODE_CAPTURE_VIDEO = 1005 + private fun getIndex(spinner: Spinner, myString: net.jami.model.Uri): Int { + var i = 0 + val n = spinner.count + while (i < n) { + if ((spinner.getItemAtPosition(i) as Phone).number == myString) { + return i + } + i++ + } + return 0 + } + + private fun setBottomPadding(view: View, padding: Int) { + view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, padding) + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/GeneralAccountFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/GeneralAccountFragment.java index 7c4e1d188..f414197c3 100644 --- a/ring-android/app/src/main/java/cx/ring/fragments/GeneralAccountFragment.java +++ b/ring-android/app/src/main/java/cx/ring/fragments/GeneralAccountFragment.java @@ -50,7 +50,9 @@ import net.jami.utils.Tuple; import cx.ring.views.EditTextIntegerPreference; import cx.ring.views.EditTextPreferenceDialog; import cx.ring.views.PasswordPreference; +import dagger.hilt.android.AndroidEntryPoint; +@AndroidEntryPoint public class GeneralAccountFragment extends BasePreferenceFragment<GeneralAccountPresenter> implements GeneralAccountView { public static final String TAG = GeneralAccountFragment.class.getSimpleName(); @@ -152,7 +154,6 @@ public class GeneralAccountFragment extends BasePreferenceFragment<GeneralAccoun @Override public void onCreatePreferences(Bundle bundle, String rootKey) { - ((JamiApplication) requireActivity().getApplication()).getInjectionComponent().inject(this); super.onCreatePreferences(bundle, rootKey); Bundle args = getArguments(); diff --git a/ring-android/app/src/main/java/cx/ring/fragments/GeneralAccountPresenter.java b/ring-android/app/src/main/java/cx/ring/fragments/GeneralAccountPresenter.java index 32690a975..eb4b9b6ac 100644 --- a/ring-android/app/src/main/java/cx/ring/fragments/GeneralAccountPresenter.java +++ b/ring-android/app/src/main/java/cx/ring/fragments/GeneralAccountPresenter.java @@ -38,17 +38,16 @@ public class GeneralAccountPresenter extends RootPresenter<GeneralAccountView> { private static final String TAG = GeneralAccountPresenter.class.getSimpleName(); - protected AccountService mAccountService; + private final AccountService mAccountService; + private final HardwareService mHardwareService; + private final PreferencesService mPreferenceService; - protected HardwareService mHardwareService; - - protected PreferencesService mPreferenceService; - - private Account mAccount; @Inject @Named("UiScheduler") protected Scheduler mUiScheduler; + private Account mAccount; + @Inject GeneralAccountPresenter(AccountService accountService, HardwareService hardwareService, PreferencesService preferencesService) { this.mAccountService = accountService; diff --git a/ring-android/app/src/main/java/cx/ring/fragments/LinkDeviceFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/LinkDeviceFragment.java index 5e04bac96..5f167d250 100644 --- a/ring-android/app/src/main/java/cx/ring/fragments/LinkDeviceFragment.java +++ b/ring-android/app/src/main/java/cx/ring/fragments/LinkDeviceFragment.java @@ -54,7 +54,9 @@ import cx.ring.databinding.FragLinkDeviceBinding; import cx.ring.mvp.BaseBottomSheetFragment; import cx.ring.utils.DeviceUtils; import cx.ring.utils.KeyboardVisibilityManager; +import dagger.hilt.android.AndroidEntryPoint; +@AndroidEntryPoint public class LinkDeviceFragment extends BaseBottomSheetFragment<LinkDevicePresenter> implements LinkDeviceView { public static final String TAG = LinkDeviceFragment.class.getSimpleName(); @@ -77,7 +79,6 @@ public class LinkDeviceFragment extends BaseBottomSheetFragment<LinkDevicePresen @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { super.onCreateView(inflater, container, savedInstanceState); - ((JamiApplication) requireActivity().getApplication()).getInjectionComponent().inject(this); mBinding = FragLinkDeviceBinding.inflate(inflater, container, false); return mBinding.getRoot(); } diff --git a/ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.java deleted file mode 100644 index 251add4e1..000000000 --- a/ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.java +++ /dev/null @@ -1,628 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.fragments; - -import android.Manifest; -import android.animation.LayoutTransition; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.pm.PackageManager; -import android.graphics.drawable.BitmapDrawable; -import android.icu.text.MeasureFormat; -import android.icu.util.Measure; -import android.icu.util.MeasureUnit; -import android.location.Location; -import android.os.Build; -import android.os.Bundle; -import android.os.IBinder; -import android.text.format.DateUtils; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.activity.OnBackPressedCallback; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.Fragment; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import net.jami.facades.ConversationFacade; -import net.jami.model.Account; -import net.jami.model.Contact; -import net.jami.model.Uri; - -import org.osmdroid.config.Configuration; -import org.osmdroid.config.IConfigurationProvider; -import org.osmdroid.tileprovider.tilesource.TileSourceFactory; -import org.osmdroid.util.BoundingBox; -import org.osmdroid.util.GeoPoint; -import org.osmdroid.views.CustomZoomButtonsController; -import org.osmdroid.views.overlay.Marker; -import org.osmdroid.views.overlay.mylocation.IMyLocationConsumer; -import org.osmdroid.views.overlay.mylocation.IMyLocationProvider; -import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -import javax.inject.Inject; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.views.AvatarFactory; -import cx.ring.databinding.FragLocationSharingBinding; -import cx.ring.service.LocationSharingService; -import cx.ring.utils.ConversationPath; -import cx.ring.utils.TouchClickListener; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public class LocationSharingFragment extends Fragment { - private static final String TAG = LocationSharingFragment.class.getSimpleName(); - private static final int REQUEST_CODE_LOCATION = 47892; - private static final String KEY_SHOW_CONTROLS = "showControls"; - - private final CompositeDisposable mDisposableBag = new CompositeDisposable(); - private final CompositeDisposable mServiceDisposableBag = new CompositeDisposable(); - private Disposable mCountdownDisposable = null; - - enum MapState {NONE, MINI, FULL} - - @Inject - ConversationFacade mConversationFacade; - - private ConversationPath mPath; - private Contact mContact; - - private final Subject<Boolean> mShowControlsSubject = BehaviorSubject.create(); - private final Subject<Boolean> mIsSharingSubject = BehaviorSubject.create(); - private final Subject<Boolean> mIsContactSharingSubject = BehaviorSubject.create(); - private final Observable<MapState> mShowMapSubject = Observable.combineLatest( - mShowControlsSubject, - mIsSharingSubject, - mIsContactSharingSubject, - (showControls, isSharing, isContactSharing) -> showControls - ? MapState.FULL - : ((isSharing || isContactSharing) ? MapState.MINI : MapState.NONE)) - .distinctUntilChanged(); - - private int bubbleSize; - - private MyLocationNewOverlay overlay; - private Marker marker; - private BoundingBox lastBoundingBox = null; - private boolean trackAll = true; - private Integer mStartSharingPending = null; - - private FragLocationSharingBinding binding = null; - private LocationSharingService mService = null; - private boolean mBound = false; - - @Nullable - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragLocationSharingBinding.inflate(inflater, container, false); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - public static LocationSharingFragment newInstance(String accountId, String conversationId, boolean showControls) { - LocationSharingFragment fragment = new LocationSharingFragment(); - Bundle args = ConversationPath.toBundle(accountId, conversationId); - args.putBoolean(KEY_SHOW_CONTROLS, showControls); - fragment.setArguments(args); - return fragment; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - ((JamiApplication) requireActivity().getApplication()).getInjectionComponent().inject(this); - setRetainInstance(true); - - Bundle args = getArguments(); - if (args != null) { - mPath = ConversationPath.fromBundle(args); - mShowControlsSubject.onNext(args.getBoolean(KEY_SHOW_CONTROLS, true)); - } - - Context ctx = requireContext(); - File osmPath = new File(ctx.getCacheDir(), "osm"); - IConfigurationProvider configuration = Configuration.getInstance(); - configuration.setOsmdroidBasePath(osmPath); - configuration.setOsmdroidTileCache(new File(osmPath, "tiles")); - configuration.setUserAgentValue("net.jami.android"); - configuration.setMapViewHardwareAccelerated(true); - configuration.setMapViewRecyclerFriendly(false); - bubbleSize = ctx.getResources().getDimensionPixelSize(R.dimen.location_sharing_avatar_size); - } - - @RequiresApi(api = Build.VERSION_CODES.N) - private static CharSequence formatDuration(long millis, MeasureFormat.FormatWidth width) { - final MeasureFormat formatter = MeasureFormat.getInstance(Locale.getDefault(), width); - if (millis >= DateUtils.HOUR_IN_MILLIS) { - final int hours = (int) ((millis + DateUtils.HOUR_IN_MILLIS/2) / DateUtils.HOUR_IN_MILLIS); - return formatter.format(new Measure(hours, MeasureUnit.HOUR)); - } else if (millis >= DateUtils.MINUTE_IN_MILLIS) { - final int minutes = (int) ((millis + DateUtils.MINUTE_IN_MILLIS/2) / DateUtils.MINUTE_IN_MILLIS); - return formatter.format(new Measure(minutes, MeasureUnit.MINUTE)); - } else { - final int seconds = (int) ((millis + DateUtils.SECOND_IN_MILLIS/2) / DateUtils.SECOND_IN_MILLIS); - return formatter.format(new Measure(seconds, MeasureUnit.SECOND)); - } - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - binding.locationShareTime1h.setText(formatDuration( DateUtils.HOUR_IN_MILLIS, MeasureFormat.FormatWidth.WIDE)); - binding.locationShareTime10m.setText(formatDuration( 10 * DateUtils.MINUTE_IN_MILLIS, MeasureFormat.FormatWidth.WIDE)); - } - binding.infoBtn.setOnClickListener(v -> { - int padding = v.getResources().getDimensionPixelSize(R.dimen.padding_large); - TextView textView = new TextView(v.getContext()); - textView.setText(R.string.location_share_about_message); - textView.setOnClickListener(tv -> tv.getContext().startActivity(new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(getString(R.string.location_share_about_osm_copy_url))))); - textView.setPadding(padding, padding, padding, padding); - new MaterialAlertDialogBuilder(view.getContext()) - .setTitle(R.string.location_share_about_title) - .setView(textView) - .create().show(); - }); - - View locateView = view.findViewById(R.id.btn_center_position); - locateView.setOnClickListener(v -> { - if (overlay != null) { - trackAll = true; - if (lastBoundingBox != null) - binding.map.zoomToBoundingBox(lastBoundingBox, true); - else - overlay.enableFollowLocation(); - } - }); - binding.locationShareTimeGroup.setOnCheckedChangeListener((group, id) -> { - if (id == View.NO_ID) - group.check(R.id.location_share_time_1h); - }); - binding.locshareToolbar.setNavigationOnClickListener(v -> mShowControlsSubject.onNext(false)); - binding.locationShareStop.setOnClickListener(v -> stopSharing()); - - binding.map.setTileSource(TileSourceFactory.MAPNIK); - binding.map.setHorizontalMapRepetitionEnabled(false); - binding.map.setTilesScaledToDpi(true); - binding.map.setMapOrientation(0, false); - binding.map.setMinZoomLevel(1d); - binding.map.setMaxZoomLevel(19.d); - binding.map.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER); - binding.map.getController().setZoom(14.0); - ((ViewGroup)view).getLayoutTransition().enableTransitionType(LayoutTransition.CHANGING); - } - - private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { - @Override - public void handleOnBackPressed() { - mShowControlsSubject.onNext(false); - } - }; - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - requireActivity().getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); - } - - @Override - public void onDestroy() { - super.onDestroy(); - mShowControlsSubject.onComplete(); - mIsSharingSubject.onComplete(); - mIsContactSharingSubject.onComplete(); - } - - public void onResume() { - super.onResume(); - binding.map.onResume(); - if (overlay != null) { - try { - overlay.enableMyLocation(); - } catch (Exception e) { - Log.w(TAG, e); - } - } - } - - public void onPause(){ - super.onPause(); - binding.map.onPause(); - if (overlay != null) - overlay.disableMyLocation(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - if (requestCode == REQUEST_CODE_LOCATION) { - boolean granted = false; - for (int result : grantResults) - granted |= (result == PackageManager.PERMISSION_GRANTED); - if (granted) { - startService(); - } else { - mIsSharingSubject.onNext(false); - mShowControlsSubject.onNext(false); - } - } - } - - @Override - public void onStart() { - super.onStart(); - mDisposableBag.add(mServiceDisposableBag); - mDisposableBag.add(mShowControlsSubject.subscribe(this::setShowControls)); - mDisposableBag.add(mIsSharingSubject.subscribe(this::setIsSharing)); - mDisposableBag.add(mShowMapSubject - .subscribeOn(AndroidSchedulers.mainThread()) - .subscribe(state -> { - Fragment p = getParentFragment(); - if (p instanceof ConversationFragment) { - ConversationFragment parent = (ConversationFragment) p; - if (state == MapState.FULL) - parent.openLocationSharing(); - else - parent.closeLocationSharing(state == MapState.MINI); - } - })); - mDisposableBag.add(mIsContactSharingSubject - .distinctUntilChanged() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(sharing -> { - if (sharing) { - String sharingString = getString(R.string.location_share_contact, mContact.getDisplayName()); - binding.locshareToolbar.setSubtitle(sharingString); - binding.locshareSnipetTxt.setVisibility(View.VISIBLE); - binding.locshareSnipetTxtShadow.setVisibility(View.VISIBLE); - binding.locshareSnipetTxt.setText(sharingString); - } else { - binding.locshareToolbar.setSubtitle(null); - binding.locshareSnipetTxt.setVisibility(View.GONE); - binding.locshareSnipetTxtShadow.setVisibility(View.GONE); - binding.locshareSnipetTxt.setText(null); - } - })); - - final Uri contactUri = mPath.getConversationUri(); - - mDisposableBag.add(mConversationFacade - .getAccountSubject(mPath.getAccountId()) - .flatMapObservable(account -> account.getLocationsUpdates() - .map(locations -> { - List<Observable<LocationViewModel>> r = new ArrayList<>(locations.size()); - boolean isContactSharing = false; - for (Map.Entry<Contact, Observable<Account.ContactLocation>> l : locations.entrySet()) { - if (l.getKey() == account.getContactFromCache(contactUri)) { - isContactSharing = true; - mContact = l.getKey(); - } - r.add(l.getValue().map(cl -> new LocationViewModel(l.getKey(), cl))); - } - mIsContactSharingSubject.onNext(isContactSharing); - return r; - })) - .flatMap(locations -> Observable.combineLatest(locations, locsArray -> { - List<LocationViewModel> list = new ArrayList<>(locsArray.length); - for (Object vm : locsArray) - list.add((LocationViewModel)vm); - return list; - })) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(locations -> { - Context context = getContext(); - if (context != null) { - binding.map.getOverlays().clear(); - if (overlay != null) - binding.map.getOverlays().add(overlay); - if (marker != null) - binding.map.getOverlays().add(marker); - - List<GeoPoint> geoLocations = new ArrayList<>(locations.size() + 1); - GeoPoint myLoc = overlay == null ? null : overlay.getMyLocation(); - if (myLoc != null) { - geoLocations.add(myLoc); - } - - for (LocationViewModel vm : locations) { - Marker m = new Marker(binding.map); - GeoPoint position = new GeoPoint(vm.location.latitude, vm.location.longitude); - m.setInfoWindow(null); - m.setPosition(position); - m.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER); - geoLocations.add(position); - mDisposableBag.add(AvatarFactory.getBitmapAvatar(context, vm.contact, bubbleSize, false).subscribe(avatar -> { - BitmapDrawable bd = new BitmapDrawable(context.getResources(), avatar); - m.setIcon(bd); - m.setInfoWindow(null); - binding.map.getOverlays().add(m); - })); - } - - if (trackAll) { - if (geoLocations.size() == 1) { - lastBoundingBox = null; - binding.map.getController().animateTo(geoLocations.get(0)); - } else { - BoundingBox bb = BoundingBox.fromGeoPointsSafe(geoLocations); - bb = bb.increaseByScale(1.5f); - lastBoundingBox = bb; - binding.map.zoomToBoundingBox(bb, true); - } - } - } - }, e -> Log.w(TAG, "Error updating contact position", e)) - ); - - Context ctx = requireContext(); - if (ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED - && ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { - mIsSharingSubject.onNext(false); - mDisposableBag.add(mShowControlsSubject - .firstElement() - .subscribe(showControls -> { - if (showControls) { - requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, REQUEST_CODE_LOCATION); - } - })); - } else { - startService(); - } - } - - @Override - public void onStop() { - super.onStop(); - if (mBound) { - requireContext().unbindService(mConnection); - mConnection.onServiceDisconnected(null); - mBound = false; - } - mDisposableBag.clear(); - } - - private void startService() { - Context ctx = requireContext(); - ctx.bindService(new Intent(ctx, LocationSharingService.class), mConnection, Context.BIND_AUTO_CREATE); - } - - void showControls() { - mShowControlsSubject.onNext(true); - } - - void hideControls() { - mShowControlsSubject.onNext(false); - } - - private void setShowControls(boolean show) { - if (show) { - onBackPressedCallback.setEnabled(true); - binding.locshareSnipet.setVisibility(View.GONE); - binding.shareControlsMini.setVisibility(View.GONE); - binding.shareControlsMini.postDelayed(() -> { - if (binding != null) { - binding.shareControlsMini.setVisibility(View.GONE); - binding.locshareSnipet.setVisibility(View.GONE); - } - }, 300); - binding.shareControls.setVisibility(View.VISIBLE); - binding.locshareToolbar.setVisibility(View.VISIBLE); - binding.map.setOnTouchListener(null); - binding.map.setMultiTouchControls(true); - } else { - onBackPressedCallback.setEnabled(false); - binding.shareControls.setVisibility(View.GONE); - binding.shareControlsMini.postDelayed(() -> { - if (binding != null) { - binding.shareControlsMini.setVisibility(View.VISIBLE); - binding.locshareSnipet.setVisibility(View.VISIBLE); - } - }, 300); - binding.locshareToolbar.setVisibility(View.GONE); - binding.map.setMultiTouchControls(false); - binding.map.setOnTouchListener(new TouchClickListener(binding.map.getContext(), v -> mShowControlsSubject.onNext(true))); - } - } - - static class RxLocationListener implements IMyLocationProvider { - private final CompositeDisposable mDisposableBag = new CompositeDisposable(); - private Observable<Location> mLocation; - - RxLocationListener(Observable<Location> location) { - mLocation = location; - } - - @Override - public boolean startLocationProvider(IMyLocationConsumer myLocationConsumer) { - mDisposableBag.add(mLocation.subscribe(loc -> myLocationConsumer.onLocationChanged(loc, this))); - return false; - } - - @Override - public void stopLocationProvider() { - mDisposableBag.clear(); - } - - @Override - public Location getLastKnownLocation() { - return mLocation.blockingFirst(); - } - - @Override - public void destroy() { - mDisposableBag.dispose(); - mLocation = null; - } - } - - static class LocationViewModel { - Contact contact; - Account.ContactLocation location; - LocationViewModel(Contact c, Account.ContactLocation cl) { - contact = c; - location = cl; - } - } - - private ServiceConnection mConnection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - Log.w(TAG, "onServiceConnected"); - LocationSharingService.LocalBinder binder = (LocationSharingService.LocalBinder) service; - mService = binder.getService(); - mBound = true; - - if (marker == null) { - marker = new Marker(binding.map); - marker.setInfoWindow(null); - marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER); - mServiceDisposableBag.add(mConversationFacade - .getAccountSubject(mPath.getAccountId()) - .flatMap(account -> AvatarFactory.getBitmapAvatar(requireContext(), account, bubbleSize)) - .subscribe(avatar -> { - marker.setIcon(new BitmapDrawable(requireContext().getResources(), avatar)); - binding.map.getOverlays().add(marker); - })); - } - - mServiceDisposableBag.add(mService.getContactSharing() - .subscribe(location -> mIsSharingSubject.onNext(location.contains(mPath)))); - mServiceDisposableBag.add(mService.getMyLocation() - .subscribe(location -> marker.setPosition(new GeoPoint(location)))); - mServiceDisposableBag.add(mService.getMyLocation() - .firstElement() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(location -> { - // start map on first location - binding.map.setExpectedCenter(new GeoPoint(location)); - overlay = new MyLocationNewOverlay(new RxLocationListener(mService.getMyLocation()), binding.map); - overlay.enableMyLocation(); - binding.map.getOverlays().add(overlay); - })); - - if (mStartSharingPending != null) { - Integer pending = mStartSharingPending; - mStartSharingPending = null; - startSharing(pending); - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - Log.w(TAG, "onServiceDisconnected"); - mBound = false; - mServiceDisposableBag.clear(); - mService = null; - } - }; - - private int getSelectedDuration() { - switch (binding.locationShareTimeGroup.getCheckedChipId()) { - case R.id.location_share_time_10m: - return 10 * 60; - case R.id.location_share_time_1h: - default: - return 60 * 60; - } - } - - private void setIsSharing(boolean sharing) { - if (sharing) { - binding.btnShareLocation.setBackgroundColor(ContextCompat.getColor(binding.btnShareLocation.getContext(), R.color.design_default_color_error)); - binding.btnShareLocation.setText(R.string.location_share_action_stop); - binding.btnShareLocation.setOnClickListener(v -> stopSharing()); - binding.locationShareTimeGroup.setVisibility(View.GONE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (mService != null) { - binding.locationShareTimeRemaining.setVisibility(View.VISIBLE); - if (mCountdownDisposable == null || mCountdownDisposable.isDisposed()) { - mCountdownDisposable = mService.getContactSharingExpiration(mPath) - .subscribe(l -> binding.locationShareTimeRemaining.setText(formatDuration(l, MeasureFormat.FormatWidth.SHORT))); - mServiceDisposableBag.add(mCountdownDisposable); - } - } - } - binding.locationShareStop.setVisibility(View.VISIBLE); - requireView().post(this::hideControls); - } else { - if (mCountdownDisposable != null) { - mCountdownDisposable.dispose(); - mCountdownDisposable = null; - } - binding.btnShareLocation.setBackgroundColor(ContextCompat.getColor(binding.btnShareLocation.getContext(), R.color.colorSecondary)); - binding.btnShareLocation.setText(R.string.location_share_action_start); - binding.btnShareLocation.setOnClickListener(v -> startSharing(getSelectedDuration())); - binding.locationShareTimeRemaining.setVisibility(View.GONE); - binding.locationShareTimeGroup.setVisibility(View.VISIBLE); - binding.locationShareStop.setVisibility(View.GONE); - } - } - - private void startSharing(int durationSec) { - Context ctx = requireContext(); - try { - if (ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED - && ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { - mStartSharingPending = durationSec; - requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, REQUEST_CODE_LOCATION); - } else { - Intent intent = new Intent(LocationSharingService.ACTION_START, mPath.toUri(), ctx, LocationSharingService.class) - .putExtra(LocationSharingService.EXTRA_SHARING_DURATION, durationSec); - ContextCompat.startForegroundService(ctx, intent); - } - } catch (Exception e) { - Toast.makeText(ctx, "Error starting location sharing: " + e.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); - } - } - - private void stopSharing() { - Context ctx = requireContext(); - try { - Intent intent = new Intent(LocationSharingService.ACTION_STOP, mPath.toUri(), ctx, LocationSharingService.class); - ctx.startService(intent); - } catch (Exception e) { - Log.w(TAG, "Error stopping location sharing", e); - } - } -} diff --git a/ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.kt b/ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.kt new file mode 100644 index 000000000..ef3fa55f5 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.kt @@ -0,0 +1,587 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.fragments + +import android.Manifest +import android.animation.LayoutTransition +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.icu.text.MeasureFormat +import android.icu.text.MeasureFormat.FormatWidth +import android.icu.util.Measure +import android.icu.util.MeasureUnit +import android.location.Location +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.text.format.DateUtils +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.annotation.RequiresApi +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import com.google.android.material.chip.ChipGroup +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import cx.ring.R +import cx.ring.databinding.FragLocationSharingBinding +import cx.ring.service.LocationSharingService +import cx.ring.service.LocationSharingService.LocalBinder +import cx.ring.utils.ConversationPath +import cx.ring.utils.TouchClickListener +import cx.ring.views.AvatarFactory +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.services.ConversationFacade +import net.jami.model.Account +import net.jami.model.Account.ContactLocation +import net.jami.model.Contact +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.mylocation.IMyLocationConsumer +import org.osmdroid.views.overlay.mylocation.IMyLocationProvider +import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay +import java.io.File +import java.util.* +import javax.inject.Inject + +@AndroidEntryPoint +class LocationSharingFragment : Fragment() { + private val mDisposableBag = CompositeDisposable() + private val mServiceDisposableBag = CompositeDisposable() + private var mCountdownDisposable: Disposable? = null + + internal enum class MapState { NONE, MINI, FULL } + + @Inject + lateinit var mConversationFacade: ConversationFacade + + private lateinit var mPath: ConversationPath + private var mContact: Contact? = null + private val mShowControlsSubject: Subject<Boolean> = BehaviorSubject.create() + private val mIsSharingSubject: Subject<Boolean> = BehaviorSubject.create() + private val mIsContactSharingSubject: Subject<Boolean> = BehaviorSubject.create() + private val mShowMapSubject = Observable.combineLatest( + mShowControlsSubject, + mIsSharingSubject, + mIsContactSharingSubject, + { showControls, isSharing, isContactSharing -> + if (showControls) + MapState.FULL + else if (isSharing || isContactSharing) + MapState.MINI + else + MapState.NONE + }) + .distinctUntilChanged() + + private var bubbleSize = 0 + private var overlay: MyLocationNewOverlay? = null + private var marker: Marker? = null + private var lastBoundingBox: BoundingBox? = null + private var trackAll = true + private var mStartSharingPending: Int? = null + private var binding: FragLocationSharingBinding? = null + private var mService: LocationSharingService? = null + private var mBound = false + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragLocationSharingBinding.inflate(inflater, container, false) + return binding!!.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requireArguments().let { args -> + mPath = ConversationPath.fromBundle(args)!! + mShowControlsSubject.onNext(args.getBoolean(KEY_SHOW_CONTROLS, true)) + } + val ctx = requireContext() + val osmPath = File(ctx.cacheDir, "osm") + val configuration = Configuration.getInstance() + configuration.osmdroidBasePath = osmPath + configuration.osmdroidTileCache = File(osmPath, "tiles") + configuration.userAgentValue = "net.jami.android" + configuration.isMapViewHardwareAccelerated = true + configuration.isMapViewRecyclerFriendly = false + bubbleSize = ctx.resources.getDimensionPixelSize(R.dimen.location_sharing_avatar_size) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding?.let { binding -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + binding.locationShareTime1h.text = formatDuration(DateUtils.HOUR_IN_MILLIS, FormatWidth.WIDE) + binding.locationShareTime10m.text = formatDuration(10 * DateUtils.MINUTE_IN_MILLIS, FormatWidth.WIDE) + } + binding.infoBtn.setOnClickListener { v: View -> + val padding = v.resources.getDimensionPixelSize(R.dimen.padding_large) + val textView = TextView(v.context) + textView.setText(R.string.location_share_about_message) + textView.setOnClickListener { tv -> tv.context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.location_share_about_osm_copy_url)))) } + textView.setPadding(padding, padding, padding, padding) + MaterialAlertDialogBuilder(view.context) + .setTitle(R.string.location_share_about_title) + .setView(textView) + .create().show() + } + binding.btnCenterPosition.setOnClickListener { + overlay?.let { overlay -> + trackAll = true + if (lastBoundingBox != null) binding.map.zoomToBoundingBox(lastBoundingBox, true) + else overlay.enableFollowLocation() + } + } + binding.locationShareTimeGroup.setOnCheckedChangeListener { group: ChipGroup, id: Int -> + if (id == View.NO_ID) group.check(R.id.location_share_time_1h) + } + binding.locshareToolbar.setNavigationOnClickListener { mShowControlsSubject.onNext(false) } + binding.locationShareStop.setOnClickListener { stopSharing() } + binding.map.setTileSource(TileSourceFactory.MAPNIK) + binding.map.isHorizontalMapRepetitionEnabled = false + binding.map.isTilesScaledToDpi = true + binding.map.setMapOrientation(0f, false) + binding.map.minZoomLevel = 1.0 + binding.map.maxZoomLevel = 19.0 + binding.map.zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + binding.map.controller.setZoom(14.0) + } + (view as ViewGroup).layoutTransition.enableTransitionType(LayoutTransition.CHANGING) + } + + private val onBackPressedCallback: OnBackPressedCallback = + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + mShowControlsSubject.onNext(false) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + requireActivity().onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } + + override fun onDestroy() { + super.onDestroy() + mShowControlsSubject.onComplete() + mIsSharingSubject.onComplete() + mIsContactSharingSubject.onComplete() + } + + override fun onResume() { + super.onResume() + binding?.map?.onResume() + try { + overlay?.enableMyLocation() + } catch (e: Exception) { + Log.w(TAG, e) + } + } + + override fun onPause() { + super.onPause() + binding?.map?.onPause() + overlay?.disableMyLocation() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array<String>, + grantResults: IntArray + ) { + if (requestCode == REQUEST_CODE_LOCATION) { + var granted = false + for (result in grantResults) granted = + granted or (result == PackageManager.PERMISSION_GRANTED) + if (granted) { + startService() + } else { + mIsSharingSubject.onNext(false) + mShowControlsSubject.onNext(false) + } + } + } + + override fun onStart() { + super.onStart() + mDisposableBag.add(mServiceDisposableBag) + mDisposableBag.add(mShowControlsSubject.subscribe { show: Boolean -> setShowControls(show) }) + mDisposableBag.add(mIsSharingSubject.subscribe { sharing: Boolean -> setIsSharing(sharing) }) + mDisposableBag.add(mShowMapSubject + .subscribeOn(AndroidSchedulers.mainThread()) + .subscribe { state: MapState -> + val p = parentFragment + if (p is ConversationFragment) { + if (state == MapState.FULL) + p.openLocationSharing() + else + p.closeLocationSharing(state == MapState.MINI) + } + }) + mDisposableBag.add(mIsContactSharingSubject + .distinctUntilChanged() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { sharing -> + binding?.let { binding -> + if (sharing) { + val sharingString = + getString(R.string.location_share_contact, mContact!!.displayName) + binding.locshareToolbar.subtitle = sharingString + binding.locshareSnipetTxt.visibility = View.VISIBLE + binding.locshareSnipetTxtShadow.visibility = View.VISIBLE + binding.locshareSnipetTxt.text = sharingString + } else { + binding.locshareToolbar.subtitle = null + binding.locshareSnipetTxt.visibility = View.GONE + binding.locshareSnipetTxtShadow.visibility = View.GONE + binding.locshareSnipetTxt.text = null + } + } + }) + val contactUri = mPath.conversationUri + mDisposableBag.add(mConversationFacade + .getAccountSubject(mPath.accountId) + .flatMapObservable { account: Account -> + account.locationsUpdates + .map<List<Observable<LocationViewModel>>> { locations -> + val r: MutableList<Observable<LocationViewModel>> = ArrayList(locations.size) + var isContactSharing = false + for ((key, value) in locations) { + if (key === account.getContactFromCache(contactUri)) { + isContactSharing = true + mContact = key + } + r.add(value.map { cl -> LocationViewModel(key, cl) }) + } + mIsContactSharingSubject.onNext(isContactSharing) + r + } + } + .flatMap { locations: List<Observable<LocationViewModel>>? -> + Observable.combineLatest<LocationViewModel, List<LocationViewModel>>(locations) { locsArray: Array<Any> -> + val list: MutableList<LocationViewModel> = ArrayList(locsArray.size) + for (vm in locsArray) list.add(vm as LocationViewModel) + list + } + } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ locations: List<LocationViewModel> -> + val context = context + if (context != null) { + binding!!.map.overlays.clear() + if (overlay != null) binding!!.map.overlays.add(overlay) + if (marker != null) binding!!.map.overlays.add(marker) + val geoLocations: MutableList<GeoPoint> = ArrayList(locations.size + 1) + overlay?.myLocation?.let { myLoc -> geoLocations.add(myLoc) } + for (vm in locations) { + val m = Marker(binding!!.map) + val position = GeoPoint(vm.location.latitude, vm.location.longitude) + m.setInfoWindow(null) + m.position = position + m.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + geoLocations.add(position) + mDisposableBag.add( + AvatarFactory.getBitmapAvatar(context, vm.contact, bubbleSize, false) + .subscribe { avatar: Bitmap -> + val bd = BitmapDrawable(context.resources, avatar) + m.icon = bd + m.setInfoWindow(null) + binding!!.map.overlays.add(m) + }) + } + if (trackAll) { + if (geoLocations.size == 1) { + lastBoundingBox = null + binding!!.map.controller.animateTo(geoLocations[0]) + } else { + var bb = BoundingBox.fromGeoPointsSafe(geoLocations) + bb = bb.increaseByScale(1.5f) + lastBoundingBox = bb + binding!!.map.zoomToBoundingBox(bb, true) + } + } + } + }) { e: Throwable -> Log.w(TAG, "Error updating contact position", e) } + ) + val ctx = requireContext() + if (ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED + && ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + mIsSharingSubject.onNext(false) + mDisposableBag.add(mShowControlsSubject + .firstElement() + .subscribe { showControls -> + if (showControls) { + requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), REQUEST_CODE_LOCATION) + } + }) + } else { + startService() + } + } + + override fun onStop() { + super.onStop() + if (mBound) { + requireContext().unbindService(mConnection) + mConnection.onServiceDisconnected(null) + mBound = false + } + mDisposableBag.clear() + } + + private fun startService() { + val ctx = requireContext() + ctx.bindService(Intent(ctx, LocationSharingService::class.java), mConnection, Context.BIND_AUTO_CREATE) + } + + fun showControls() { + mShowControlsSubject.onNext(true) + } + + fun hideControls() { + mShowControlsSubject.onNext(false) + } + + private fun setShowControls(show: Boolean) { + binding?.let { b -> + if (show) { + onBackPressedCallback.isEnabled = true + b.locshareSnipet.visibility = View.GONE + b.shareControlsMini.visibility = View.GONE + b.shareControlsMini.postDelayed({ + binding?.let { b -> + b.shareControlsMini.visibility = View.GONE + b.locshareSnipet.visibility = View.GONE + } + }, 300) + b.shareControls.visibility = View.VISIBLE + b.locshareToolbar.visibility = View.VISIBLE + b.map.setOnTouchListener(null) + b.map.setMultiTouchControls(true) + } else { + onBackPressedCallback.isEnabled = false + b.shareControls.visibility = View.GONE + b.shareControlsMini.postDelayed({ + binding?.let { b -> + b.shareControlsMini.visibility = View.VISIBLE + b.locshareSnipet.visibility = View.VISIBLE + } + }, 300) + b.locshareToolbar.visibility = View.GONE + b.map.setMultiTouchControls(false) + b.map.setOnTouchListener(TouchClickListener(binding!!.map.context) { mShowControlsSubject.onNext(true) }) + } + } + } + + internal class RxLocationListener(private val mLocation: Observable<Location>) : IMyLocationProvider { + private val mDisposableBag = CompositeDisposable() + + override fun startLocationProvider(myLocationConsumer: IMyLocationConsumer): Boolean { + mDisposableBag.add(mLocation.subscribe { loc -> myLocationConsumer.onLocationChanged(loc, this) }) + return false + } + + override fun stopLocationProvider() { + mDisposableBag.clear() + } + + override fun getLastKnownLocation(): Location { + return mLocation.blockingFirst() + } + + override fun destroy() { + mDisposableBag.dispose() + } + } + + internal class LocationViewModel(var contact: Contact, var location: ContactLocation) + + private val mConnection: ServiceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + Log.w(TAG, "onServiceConnected") + val binder = service as LocalBinder + mService = binder.service + mBound = true + if (marker == null) { + marker = Marker(binding!!.map).apply { + setInfoWindow(null) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + } + mServiceDisposableBag.add(mConversationFacade + .getAccountSubject(mPath.accountId) + .flatMap { account -> AvatarFactory.getBitmapAvatar(requireContext(), account, bubbleSize) } + .subscribe { avatar -> + marker!!.icon = BitmapDrawable(requireContext().resources, avatar) + binding!!.map.overlays.add(marker) + }) + } + mServiceDisposableBag.add(binder.service.contactSharing + .subscribe { location -> mIsSharingSubject.onNext(location.contains(mPath)) }) + mServiceDisposableBag.add(binder.service.myLocation + .subscribe { location -> marker!!.position = GeoPoint(location) }) + mServiceDisposableBag.add(binder.service.myLocation + .firstElement() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { location -> + // start map on first location + binding?.let { binding -> + binding.map.setExpectedCenter(GeoPoint(location)) + overlay = MyLocationNewOverlay(RxLocationListener(binder.service.myLocation), binding.map) + .apply { enableMyLocation() } + binding.map.overlays.add(overlay) + } + }) + mStartSharingPending?.let { pending -> + mStartSharingPending = null + startSharing(pending) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + Log.w(TAG, "onServiceDisconnected") + mBound = false + mServiceDisposableBag.clear() + mService = null + } + } + private val selectedDuration: Int + get() = when (binding!!.locationShareTimeGroup.checkedChipId) { + R.id.location_share_time_10m -> 10 * 60 + R.id.location_share_time_1h -> 60 * 60 + else -> 60 * 60 + } + + private fun setIsSharing(sharing: Boolean) { + binding?.let { binding -> + if (sharing) { + binding.btnShareLocation.setBackgroundColor(ContextCompat.getColor(binding.btnShareLocation.context, R.color.design_default_color_error)) + binding.btnShareLocation.setText(R.string.location_share_action_stop) + binding.btnShareLocation.setOnClickListener { v: View? -> stopSharing() } + binding.locationShareTimeGroup.visibility = View.GONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + mService?.let { service -> + binding.locationShareTimeRemaining.visibility = View.VISIBLE + if (mCountdownDisposable == null || mCountdownDisposable!!.isDisposed) { + mCountdownDisposable = service.getContactSharingExpiration(mPath) + .subscribe { l -> binding.locationShareTimeRemaining.text = formatDuration(l, FormatWidth.SHORT) } + mServiceDisposableBag.add(mCountdownDisposable) + } + } + } + binding.locationShareStop.visibility = View.VISIBLE + requireView().post { hideControls() } + } else { + mCountdownDisposable?.let { disposable -> + disposable.dispose() + mCountdownDisposable = null + } + binding.btnShareLocation.setBackgroundColor(ContextCompat.getColor(binding.btnShareLocation.context, R.color.colorSecondary)) + binding.btnShareLocation.setText(R.string.location_share_action_start) + binding.btnShareLocation.setOnClickListener { startSharing(selectedDuration) } + binding.locationShareTimeRemaining.visibility = View.GONE + binding.locationShareTimeGroup.visibility = View.VISIBLE + binding.locationShareStop.visibility = View.GONE + } + } + } + + private fun startSharing(durationSec: Int) { + val ctx = requireContext() + try { + if (ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED + && ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + mStartSharingPending = durationSec + requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), REQUEST_CODE_LOCATION) + } else { + val intent = Intent(LocationSharingService.ACTION_START, mPath.toUri(), ctx, LocationSharingService::class.java) + .putExtra(LocationSharingService.EXTRA_SHARING_DURATION, durationSec) + ContextCompat.startForegroundService(ctx, intent) + } + } catch (e: Exception) { + Toast.makeText(ctx, "Error starting location sharing: " + e.localizedMessage, Toast.LENGTH_SHORT).show() + } + } + + private fun stopSharing() { + try { + val ctx = requireContext() + ctx.startService(Intent(LocationSharingService.ACTION_STOP, mPath.toUri(), ctx, LocationSharingService::class.java)) + } catch (e: Exception) { + Log.w(TAG, "Error stopping location sharing", e) + } + } + + companion object { + private val TAG = LocationSharingFragment::class.java.simpleName + private const val REQUEST_CODE_LOCATION = 47892 + private const val KEY_SHOW_CONTROLS = "showControls" + + fun newInstance(accountId: String, conversationId: String, showControls: Boolean): LocationSharingFragment { + val fragment = LocationSharingFragment() + val args = ConversationPath.toBundle(accountId, conversationId) + args.putBoolean(KEY_SHOW_CONTROLS, showControls) + fragment.arguments = args + return fragment + } + + @RequiresApi(api = Build.VERSION_CODES.N) + private fun formatDuration(millis: Long, width: FormatWidth): CharSequence { + val formatter = MeasureFormat.getInstance(Locale.getDefault(), width) + return when { + millis >= DateUtils.HOUR_IN_MILLIS -> { + val hours = ((millis + DateUtils.HOUR_IN_MILLIS / 2) / DateUtils.HOUR_IN_MILLIS).toInt() + formatter.format(Measure(hours, MeasureUnit.HOUR)) + } + millis >= DateUtils.MINUTE_IN_MILLIS -> { + val minutes = ((millis + DateUtils.MINUTE_IN_MILLIS / 2) / DateUtils.MINUTE_IN_MILLIS).toInt() + formatter.format(Measure(minutes, MeasureUnit.MINUTE)) + } + else -> { + val seconds = ((millis + DateUtils.SECOND_IN_MILLIS / 2) / DateUtils.SECOND_IN_MILLIS).toInt() + formatter.format(Measure(seconds, MeasureUnit.SECOND)) + } + } + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/MediaPreferenceFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/MediaPreferenceFragment.java index de2dea99e..e85f508d5 100644 --- a/ring-android/app/src/main/java/cx/ring/fragments/MediaPreferenceFragment.java +++ b/ring-android/app/src/main/java/cx/ring/fragments/MediaPreferenceFragment.java @@ -42,7 +42,9 @@ import net.jami.model.AccountConfig; import net.jami.model.Codec; import net.jami.model.ConfigKey; import cx.ring.mvp.BasePreferenceFragment; +import dagger.hilt.android.AndroidEntryPoint; +@AndroidEntryPoint public class MediaPreferenceFragment extends BasePreferenceFragment<MediaPreferencePresenter> implements MediaPreferenceView { public static final String TAG = MediaPreferenceFragment.class.getSimpleName(); @@ -74,7 +76,6 @@ public class MediaPreferenceFragment extends BasePreferenceFragment<MediaPrefere @Override public void onCreatePreferences(Bundle bundle, String rootKey) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); super.onCreatePreferences(bundle, rootKey); String accountId = getArguments().getString(AccountEditionFragment.ACCOUNT_ID_KEY); diff --git a/ring-android/app/src/main/java/cx/ring/fragments/PluginHandlersListFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/PluginHandlersListFragment.java deleted file mode 100644 index f33207d6e..000000000 --- a/ring-android/app/src/main/java/cx/ring/fragments/PluginHandlersListFragment.java +++ /dev/null @@ -1,85 +0,0 @@ -package cx.ring.fragments; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import net.jami.daemon.JamiService; - -import cx.ring.databinding.FragPluginHandlersListBinding; -import cx.ring.plugins.PluginUtils; -import cx.ring.settings.pluginssettings.PluginDetails; -import cx.ring.settings.pluginssettings.PluginsListAdapter; -import cx.ring.utils.ConversationPath; - - -public class PluginHandlersListFragment extends Fragment implements PluginsListAdapter.PluginListItemListener { - public static final String TAG = "PluginListHandlers"; - private FragPluginHandlersListBinding binding; - private PluginsListAdapter mAdapter; - private ConversationPath mPath; - - - @Nullable - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragPluginHandlersListBinding.inflate(inflater, container, false); - - binding.handlerList.setHasFixedSize(true); - mAdapter = new PluginsListAdapter(PluginUtils.getChatHandlersDetails(binding.handlerList.getContext(), mPath.getAccountId(), mPath.getConversationId()), this); - binding.handlerList.setAdapter(mAdapter); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - Bundle args = getArguments(); - if (args != null) { - mPath = ConversationPath.fromBundle(args); - } - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - binding.chatPluginsToolbar.setVisibility(View.VISIBLE); - binding.chatPluginsToolbar.setOnClickListener(v -> { - Fragment fragment = getParentFragment(); - if (fragment instanceof ConversationFragment) { - ConversationFragment parent = (ConversationFragment) fragment; - parent.hidePluginListHandlers(); - } - }); - } - - public static PluginHandlersListFragment newInstance(String accountId, String peerId) { - PluginHandlersListFragment fragment = new PluginHandlersListFragment(); - - Bundle args = ConversationPath.toBundle(accountId, peerId); - - fragment.setArguments(args); - return fragment; - } - - @Override - public void onPluginItemClicked(PluginDetails pluginDetails) { - JamiService.toggleChatHandler(pluginDetails.getmHandlerId(), mPath.getAccountId(), mPath.getConversationId(), pluginDetails.isEnabled()); - } - - @Override - public void onPluginEnabled(PluginDetails pluginDetails) { - JamiService.toggleChatHandler(pluginDetails.getmHandlerId(), mPath.getAccountId(), mPath.getConversationId(), pluginDetails.isEnabled()); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/fragments/PluginHandlersListFragment.kt b/ring-android/app/src/main/java/cx/ring/fragments/PluginHandlersListFragment.kt new file mode 100644 index 000000000..85d83be18 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/fragments/PluginHandlersListFragment.kt @@ -0,0 +1,70 @@ +package cx.ring.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import cx.ring.databinding.FragPluginHandlersListBinding +import cx.ring.plugins.PluginUtils +import cx.ring.settings.pluginssettings.PluginDetails +import cx.ring.settings.pluginssettings.PluginsListAdapter +import cx.ring.settings.pluginssettings.PluginsListAdapter.PluginListItemListener +import cx.ring.utils.ConversationPath +import net.jami.daemon.JamiService + +class PluginHandlersListFragment : Fragment(), PluginListItemListener { + private var binding: FragPluginHandlersListBinding? = null + private lateinit var mPath: ConversationPath + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mPath = ConversationPath.fromBundle(requireArguments())!! + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return FragPluginHandlersListBinding.inflate(inflater, container, false).also { b -> + b.handlerList.setHasFixedSize(true) + b.handlerList.adapter = PluginsListAdapter( + PluginUtils.getChatHandlersDetails(b.handlerList.context, mPath.accountId, mPath.conversationId), this) + binding = b + }.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding!!.chatPluginsToolbar.visibility = View.VISIBLE + binding!!.chatPluginsToolbar.setOnClickListener { v: View? -> + val fragment = parentFragment + if (fragment is ConversationFragment) { + fragment.hidePluginListHandlers() + } + } + } + + override fun onPluginItemClicked(pluginDetails: PluginDetails) { + JamiService.toggleChatHandler(pluginDetails.getmHandlerId(), mPath.accountId, mPath.conversationId, pluginDetails.isEnabled) + } + + override fun onPluginEnabled(pluginDetails: PluginDetails) { + JamiService.toggleChatHandler(pluginDetails.getmHandlerId(), mPath.accountId, mPath.conversationId, pluginDetails.isEnabled) + } + + companion object { + const val TAG = "PluginListHandlers" + fun newInstance(accountId: String, peerId: String): PluginHandlersListFragment { + val fragment = PluginHandlersListFragment() + fragment.arguments = ConversationPath.toBundle(accountId, peerId) + return fragment + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/QRCodeFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/QRCodeFragment.java deleted file mode 100644 index d1e35ed9f..000000000 --- a/ring-android/app/src/main/java/cx/ring/fragments/QRCodeFragment.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.fragments; - -import android.app.Dialog; -import android.content.Context; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentPagerAdapter; - -import com.google.android.material.bottomsheet.BottomSheetBehavior; -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; - -import cx.ring.R; -import cx.ring.databinding.FragQrcodeBinding; -import cx.ring.share.ScanFragment; -import cx.ring.share.ShareFragment; -import cx.ring.utils.DeviceUtils; - -public class QRCodeFragment extends BottomSheetDialogFragment { - - public static final String TAG = QRCodeFragment.class.getSimpleName(); - public static final String ARG_START_PAGE_INDEX = "start_page"; - - public static final int INDEX_CODE = 0; - public static final int INDEX_SCAN = 1; - - public static QRCodeFragment newInstance(int startPage) { - QRCodeFragment fragment = new QRCodeFragment(); - Bundle args = new Bundle(); - args.putInt(ARG_START_PAGE_INDEX, startPage); - fragment.setArguments(args); - return fragment; - } - - private FragQrcodeBinding mBinding = null; - private int mStartPageIndex; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - super.onCreateView(inflater, container, savedInstanceState); - - Bundle args = getArguments(); - mStartPageIndex = args.getInt(ARG_START_PAGE_INDEX, 0); - - mBinding = FragQrcodeBinding.inflate(inflater, container, false); - mBinding.viewPager.setAdapter(new SectionsPagerAdapter(getContext(), getChildFragmentManager())); - mBinding.tabs.setupWithViewPager(mBinding.viewPager); - return mBinding.getRoot(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - if (mStartPageIndex != 0) { - mBinding.tabs.getTabAt(mStartPageIndex).select(); - } - } - - @Override - public void onDestroyView() { - mBinding = null; - super.onDestroyView(); - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - final Dialog dialog = super.onCreateDialog(savedInstanceState); - dialog.setOnShowListener(dialogINterface -> { - if (DeviceUtils.isTablet(getContext())) { - dialog.getWindow().setLayout( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.MATCH_PARENT); - } - }); - return dialog; - } - - static class SectionsPagerAdapter extends FragmentPagerAdapter { - @StringRes - private final int[] TAB_TITLES = new int[]{R.string.tab_code, R.string.tab_scan}; - private final Context mContext; - - SectionsPagerAdapter(Context context, FragmentManager fm) { - super(fm); - mContext = context; - } - - @NonNull - @Override - public Fragment getItem(int position) { - switch (position) { - case 0: - return new ShareFragment(); - case 1: - return new ScanFragment(); - default: - return null; - } - } - - @Nullable - @Override - public CharSequence getPageTitle(int position) { - return mContext.getResources().getString(TAB_TITLES[position]); - } - - @Override - public int getCount() { - return TAB_TITLES.length; - } - } - - @Override - public void onResume() { - super.onResume(); - addGlobalLayoutListener(getView()); - } - - private void addGlobalLayoutListener(final View view) { - view.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { - setPeekHeight(v.getMeasuredHeight()); - v.removeOnLayoutChangeListener(this); - } - }); - } - - public void setPeekHeight(int peekHeight) { - BottomSheetBehavior<?> behavior = getBottomSheetBehaviour(); - if (behavior == null) { - return; - } - - behavior.setPeekHeight(peekHeight); - } - - private BottomSheetBehavior<?> getBottomSheetBehaviour() { - CoordinatorLayout.LayoutParams layoutParams = (CoordinatorLayout.LayoutParams) ((View) getView().getParent()).getLayoutParams(); - CoordinatorLayout.Behavior<?> behavior = layoutParams.getBehavior(); - if (behavior instanceof BottomSheetBehavior) { - return (BottomSheetBehavior<?>) behavior; - } - - return null; - } - -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/QRCodeFragment.kt b/ring-android/app/src/main/java/cx/ring/fragments/QRCodeFragment.kt new file mode 100644 index 000000000..0b2b028c8 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/fragments/QRCodeFragment.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.fragments + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import cx.ring.R +import cx.ring.databinding.FragQrcodeBinding +import cx.ring.share.ScanFragment +import cx.ring.share.ShareFragment +import cx.ring.utils.DeviceUtils.isTablet + +class QRCodeFragment : BottomSheetDialogFragment() { + private var mBinding: FragQrcodeBinding? = null + private var mStartPageIndex = 0 + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + super.onCreateView(inflater, container, savedInstanceState) + val args = requireArguments() + mStartPageIndex = args.getInt(ARG_START_PAGE_INDEX, 0) + return FragQrcodeBinding.inflate(inflater, container, false).apply { + viewPager.adapter = SectionsPagerAdapter(root.context, childFragmentManager) + tabs.setupWithViewPager(viewPager) + mBinding = this + }.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (mStartPageIndex != 0) { + mBinding?.tabs?.getTabAt(mStartPageIndex)?.select() + } + } + + override fun onDestroyView() { + mBinding = null + super.onDestroyView() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + dialog.setOnShowListener { + if (isTablet(requireContext())) { + dialog.window?.setLayout(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + } + return dialog + } + + internal class SectionsPagerAdapter(private val mContext: Context, fm: FragmentManager) : + FragmentPagerAdapter(fm) { + @StringRes + private val TAB_TITLES = intArrayOf(R.string.tab_code, R.string.tab_scan) + + override fun getItem(position: Int): Fragment { + return when (position) { + 0 -> ShareFragment() + 1 -> ScanFragment() + else -> throw IllegalArgumentException() + } + } + + override fun getPageTitle(position: Int): CharSequence { + return mContext.resources.getString(TAB_TITLES[position]) + } + + override fun getCount(): Int { + return TAB_TITLES.size + } + } + + override fun onResume() { + super.onResume() + addGlobalLayoutListener(requireView()) + } + + private fun addGlobalLayoutListener(view: View) { + view.addOnLayoutChangeListener(object : View.OnLayoutChangeListener { + override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) { + setPeekHeight(v.measuredHeight) + v.removeOnLayoutChangeListener(this) + } + }) + } + + fun setPeekHeight(peekHeight: Int) { + bottomSheetBehaviour?.peekHeight = peekHeight + } + + private val bottomSheetBehaviour: BottomSheetBehavior<*>? + get() { + val layoutParams = (requireView().parent as View).layoutParams as CoordinatorLayout.LayoutParams + val behavior = layoutParams.behavior + return if (behavior is BottomSheetBehavior<*>) { + behavior + } else null + } + + companion object { + val TAG = QRCodeFragment::class.simpleName!! + const val ARG_START_PAGE_INDEX = "start_page" + const val INDEX_CODE = 0 + const val INDEX_SCAN = 1 + + fun newInstance(startPage: Int): QRCodeFragment { + val fragment = QRCodeFragment() + val args = Bundle() + args.putInt(ARG_START_PAGE_INDEX, startPage) + fragment.arguments = args + return fragment + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/SIPAccountCreationFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/SIPAccountCreationFragment.java deleted file mode 100644 index 893e2b02c..000000000 --- a/ring-android/app/src/main/java/cx/ring/fragments/SIPAccountCreationFragment.java +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.fragments; - -import android.app.Activity; -import android.app.ProgressDialog; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.pm.ActivityInfo; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.inputmethod.EditorInfo; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.databinding.FragAccSipCreateBinding; -import cx.ring.mvp.BaseSupportFragment; -import net.jami.mvp.SIPCreationView; -import net.jami.wizard.SIPCreationPresenter; - -public class SIPAccountCreationFragment extends BaseSupportFragment<SIPCreationPresenter> implements SIPCreationView { - public static final String TAG = SIPAccountCreationFragment.class.getSimpleName(); - - private ProgressDialog mProgress = null; - private FragAccSipCreateBinding binding = null; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragAccSipCreateBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - binding.password.setOnEditorActionListener((v, actionId, event) -> { - if (actionId == EditorInfo.IME_ACTION_DONE) { - binding.createSipButton.callOnClick(); - } - return false; - }); - binding.createSipButton.setOnClickListener(v -> createSIPAccount(false)); - } - - /** - * Start the creation process in the presenter - * - * @param bypassWarnings boolean stating if we want to display warning to the user or create the account anyway - */ - private void createSIPAccount(boolean bypassWarnings) { - //orientation is locked during the create of account to avoid the destruction of the thread - getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LOCKED); - - String hostname = binding.hostname.getText().toString(); - String proxy = binding.proxy.getText().toString(); - String username = binding.username.getText().toString(); - String password = binding.password.getText().toString(); - presenter.startCreation(hostname, proxy, username, password, bypassWarnings); - } - - @Override - public void showUsernameError() { - binding.username.setError(getString(R.string.error_field_required)); - binding.username.requestFocus(); - } - - @Override - public void showLoading() { - mProgress = new ProgressDialog(getActivity()); - mProgress.setTitle(R.string.dialog_wait_create); - mProgress.setMessage(getString(R.string.dialog_wait_create_details)); - mProgress.setCancelable(false); - mProgress.setCanceledOnTouchOutside(false); - mProgress.show(); - } - - @Override - public void resetErrors() { - binding.password.setError(null); - } - - @Override - public void showPasswordError() { - binding.password.setError(getString(R.string.error_field_required)); - binding.password.requestFocus(); - } - - @Override - public void showIP2IPWarning() { - showDialog(getActivity().getString(R.string.dialog_warn_ip2ip_account_title), - getActivity().getString(R.string.dialog_warn_ip2ip_account_message), - getActivity().getString(android.R.string.ok), - getActivity().getString(android.R.string.cancel), - (dialog, which) -> { - dialog.dismiss(); - createSIPAccount(true); - }, - null); - } - - @Override - public void showRegistrationError() { - showDialog(getActivity().getString(R.string.account_sip_cannot_be_registered), - getActivity().getString(R.string.account_sip_cannot_be_registered_message), - getActivity().getString(android.R.string.ok), - getActivity().getString(R.string.account_sip_register_anyway), - (dialog, which) -> presenter.removeAccount(), - (dialog, id) -> { - getActivity().setResult(Activity.RESULT_OK, new Intent()); - getActivity().finish(); - }); - } - - @Override - public void showRegistrationNetworkError() { - showDialog(getActivity().getString(R.string.account_no_network_title), - getActivity().getString(R.string.account_no_network_message), - getActivity().getString(android.R.string.ok), - getActivity().getString(R.string.account_sip_register_anyway), - (dialog, which) -> presenter.removeAccount(), - (dialog, id) -> { - getActivity().setResult(Activity.RESULT_OK, new Intent()); - getActivity().finish(); - }); - } - - @Override - public void showRegistrationSuccess() { - showDialog(getActivity().getString(R.string.account_sip_success_title), - getActivity().getString(R.string.account_sip_success_message), - getActivity().getString(android.R.string.ok), - null, - (dialog, which) -> { - getActivity().setResult(Activity.RESULT_OK, new Intent()); - getActivity().finish(); - }, - null); - } - - public void showDialog(final String title, - final String message, - final String positive, - final String negative, - final DialogInterface.OnClickListener listenerPositive, - final DialogInterface.OnClickListener listenerNegative) { - if (mProgress != null && mProgress.isShowing()) { - mProgress.dismiss(); - } - - //orientation is locked during the create of account to avoid the destruction of the thread - getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LOCKED); - - new MaterialAlertDialogBuilder(requireContext()) - .setPositiveButton(positive, listenerPositive) - .setNegativeButton(negative, listenerNegative) - .setTitle(title).setMessage(message) - .setOnDismissListener(dialog -> { - //unlock the screen orientation - getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR); - }) - .show(); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/fragments/SIPAccountCreationFragment.kt b/ring-android/app/src/main/java/cx/ring/fragments/SIPAccountCreationFragment.kt new file mode 100644 index 000000000..8cf94046a --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/fragments/SIPAccountCreationFragment.kt @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.fragments + +import android.app.Activity +import android.app.ProgressDialog +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.ActivityInfo +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import cx.ring.R +import cx.ring.databinding.FragAccSipCreateBinding +import cx.ring.mvp.BaseSupportFragment +import dagger.hilt.android.AndroidEntryPoint +import net.jami.mvp.SIPCreationView +import net.jami.wizard.SIPCreationPresenter + +@AndroidEntryPoint +class SIPAccountCreationFragment : BaseSupportFragment<SIPCreationPresenter, SIPCreationView>(), + SIPCreationView { + private var mProgress: ProgressDialog? = null + private var binding: FragAccSipCreateBinding? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragAccSipCreateBinding.inflate(inflater, container, false) + return binding!!.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding!!.password.setOnEditorActionListener { v: TextView?, actionId: Int, event: KeyEvent? -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + binding!!.createSipButton.callOnClick() + } + false + } + binding!!.createSipButton.setOnClickListener { v: View? -> createSIPAccount(false) } + } + + /** + * Start the creation process in the presenter + * + * @param bypassWarnings boolean stating if we want to display warning to the user or create the account anyway + */ + private fun createSIPAccount(bypassWarnings: Boolean) { + //orientation is locked during the create of account to avoid the destruction of the thread + requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + val hostname = binding!!.hostname.text.toString() + val proxy = binding!!.proxy.text.toString() + val username = binding!!.username.text.toString() + val password = binding!!.password.text.toString() + presenter.startCreation(hostname, proxy, username, password, bypassWarnings) + } + + override fun showUsernameError() { + binding!!.username.error = getString(R.string.error_field_required) + binding!!.username.requestFocus() + } + + override fun showLoading() { + mProgress = ProgressDialog(activity) + mProgress!!.setTitle(R.string.dialog_wait_create) + mProgress!!.setMessage(getString(R.string.dialog_wait_create_details)) + mProgress!!.setCancelable(false) + mProgress!!.setCanceledOnTouchOutside(false) + mProgress!!.show() + } + + override fun resetErrors() { + binding!!.password.error = null + } + + override fun showPasswordError() { + binding!!.password.error = getString(R.string.error_field_required) + binding!!.password.requestFocus() + } + + override fun showIP2IPWarning() { + showDialog( + getString(R.string.dialog_warn_ip2ip_account_title), + getString(R.string.dialog_warn_ip2ip_account_message), + getString(android.R.string.ok), + getString(android.R.string.cancel), + { dialog: DialogInterface, which: Int -> + dialog.dismiss() + createSIPAccount(true) + }, + null + ) + } + + override fun showRegistrationError() { + showDialog(getString(R.string.account_sip_cannot_be_registered), + getString(R.string.account_sip_cannot_be_registered_message), + getString(android.R.string.ok), + getString(R.string.account_sip_register_anyway), + { dialog: DialogInterface?, which: Int -> presenter.removeAccount() } + ) { dialog: DialogInterface?, id: Int -> + val activity: Activity = requireActivity() + activity.setResult(Activity.RESULT_OK, Intent()) + activity.finish() + } + } + + override fun showRegistrationNetworkError() { + showDialog(getString(R.string.account_no_network_title), + getString(R.string.account_no_network_message), + getString(android.R.string.ok), + getString(R.string.account_sip_register_anyway), + { dialog: DialogInterface?, which: Int -> presenter.removeAccount() } + ) { dialog: DialogInterface?, id: Int -> + val activity: Activity = requireActivity() + activity.setResult(Activity.RESULT_OK, Intent()) + activity.finish() + } + } + + override fun showRegistrationSuccess() { + showDialog( + getString(R.string.account_sip_success_title), + getString(R.string.account_sip_success_message), + getString(android.R.string.ok), + null, + { dialog: DialogInterface?, which: Int -> + val activity: Activity = requireActivity() + activity.setResult(Activity.RESULT_OK, Intent()) + activity.finish() + }, + null + ) + } + + fun showDialog( + title: String?, + message: String?, + positive: String?, + negative: String?, + listenerPositive: DialogInterface.OnClickListener?, + listenerNegative: DialogInterface.OnClickListener? + ) { + if (mProgress != null && mProgress!!.isShowing) { + mProgress!!.dismiss() + } + + //orientation is locked during the create of account to avoid the destruction of the thread + requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(positive, listenerPositive) + .setNegativeButton(negative, listenerNegative) + .setTitle(title).setMessage(message) + .setOnDismissListener { dialog: DialogInterface? -> + //unlock the screen orientation + requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR + } + .show() + } + + companion object { + val TAG = SIPAccountCreationFragment::class.simpleName!! + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/SecurityAccountFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/SecurityAccountFragment.java index 3e8fda966..0d4a11e6b 100644 --- a/ring-android/app/src/main/java/cx/ring/fragments/SecurityAccountFragment.java +++ b/ring-android/app/src/main/java/cx/ring/fragments/SecurityAccountFragment.java @@ -52,7 +52,9 @@ import cx.ring.utils.AndroidFileUtils; import net.jami.utils.Tuple; import cx.ring.views.CredentialPreferenceDialog; import cx.ring.views.CredentialsPreference; +import dagger.hilt.android.AndroidEntryPoint; +@AndroidEntryPoint public class SecurityAccountFragment extends BasePreferenceFragment<SecurityAccountPresenter> implements SecurityAccountView { public static final String TAG = SecurityAccountFragment.class.getSimpleName(); @@ -63,18 +65,18 @@ public class SecurityAccountFragment extends BasePreferenceFragment<SecurityAcco private PreferenceCategory credentialsCategory; private PreferenceCategory tlsCategory; - private Preference.OnPreferenceChangeListener editCredentialListener = (preference, newValue) -> { + private final Preference.OnPreferenceChangeListener editCredentialListener = (preference, newValue) -> { // We need the old and new value to correctly edit the list of credentials Pair<AccountCredentials, AccountCredentials> result = (Pair<AccountCredentials, AccountCredentials>) newValue; presenter.credentialEdited(new Tuple<>(result.first, result.second)); return false; }; - private Preference.OnPreferenceChangeListener addCredentialListener = (preference, newValue) -> { + private final Preference.OnPreferenceChangeListener addCredentialListener = (preference, newValue) -> { Pair<AccountCredentials, AccountCredentials> result = (Pair<AccountCredentials, AccountCredentials>) newValue; presenter.credentialAdded(new Tuple<>(result.first, result.second)); return false; }; - private Preference.OnPreferenceClickListener filePickerListener = preference -> { + private final Preference.OnPreferenceClickListener filePickerListener = preference -> { if (preference.getKey().contentEquals(ConfigKey.TLS_CA_LIST_FILE.key())) { performFileSearch(SELECT_CA_LIST_RC); } @@ -86,7 +88,7 @@ public class SecurityAccountFragment extends BasePreferenceFragment<SecurityAcco } return true; }; - private Preference.OnPreferenceChangeListener tlsListener = (preference, newValue) -> { + private final Preference.OnPreferenceChangeListener tlsListener = (preference, newValue) -> { ConfigKey key = ConfigKey.fromString(preference.getKey()); if (preference.getKey().contentEquals(ConfigKey.TLS_ENABLE.key())) { @@ -110,8 +112,6 @@ public class SecurityAccountFragment extends BasePreferenceFragment<SecurityAcco @Override public void onCreatePreferences(Bundle bundle, String s) { - // dependency injection - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); super.onCreatePreferences(bundle, s); addPreferencesFromResource(R.xml.account_security_prefs); diff --git a/ring-android/app/src/main/java/cx/ring/fragments/ShareWithFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/ShareWithFragment.java deleted file mode 100644 index 3671a9289..000000000 --- a/ring-android/app/src/main/java/cx/ring/fragments/ShareWithFragment.java +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.fragments; - -import android.app.Activity; -import android.content.ClipData; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.util.Log; -import android.view.InflateException; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; - -import net.jami.facades.ConversationFacade; -import net.jami.services.ContactService; -import net.jami.smartlist.SmartListViewModel; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import cx.ring.adapters.SmartListAdapter; -import cx.ring.application.JamiApplication; -import cx.ring.client.ConversationActivity; -import cx.ring.databinding.FragSharewithBinding; -import cx.ring.utils.ConversationPath; -import cx.ring.viewholders.SmartListViewHolder; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class ShareWithFragment extends Fragment { - private final static String TAG = ShareWithFragment.class.getSimpleName(); - - private final CompositeDisposable mDisposable = new CompositeDisposable(); - - @Inject - @Singleton - ConversationFacade mConversationFacade; - - @Inject - @Singleton - ContactService mContactService; - - private Intent mPendingIntent = null; - private SmartListAdapter adapter; - - private FragSharewithBinding binding; - - /** - * Mandatory empty constructor for the fragment manager to instantiate the - * fragment (e.g. upon screen orientation changes). - */ - public ShareWithFragment() { - JamiApplication.getInstance().getInjectionComponent().inject(this); - } - - public static ShareWithFragment newInstance() { - return new ShareWithFragment(); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - binding = FragSharewithBinding.inflate(inflater); - - Context context = binding.getRoot().getContext(); - Activity activity = getActivity(); - if (activity instanceof AppCompatActivity) { - AppCompatActivity compatActivity = (AppCompatActivity) activity; - compatActivity.setSupportActionBar(binding.toolbar); - ActionBar ab = compatActivity.getSupportActionBar(); - if (ab != null) - ab.setDisplayHomeAsUpEnabled(true); - } - - if (mPendingIntent != null) { - String type = mPendingIntent.getType(); - ClipData clip = mPendingIntent.getClipData(); - if (type.startsWith("text/")) { - binding.previewText.setText(mPendingIntent.getStringExtra(Intent.EXTRA_TEXT)); - binding.previewText.setVisibility(View.VISIBLE); - } else if (type.startsWith("image/")) { - Uri data = mPendingIntent.getData(); - if (data == null && clip != null && clip.getItemCount() > 0) - data = clip.getItemAt(0).getUri(); - binding.previewImage.setImageURI(data); - binding.previewImage.setVisibility(View.VISIBLE); - } else if (type.startsWith("video/")) { - Uri data = mPendingIntent.getData(); - if (data == null && clip != null && clip.getItemCount() > 0) - data = clip.getItemAt(0).getUri(); - try { - binding.previewVideo.setVideoURI(data); - binding.previewVideo.setVisibility(View.VISIBLE); - } catch (NullPointerException | InflateException | NumberFormatException e) { - Log.e(TAG, e.getMessage()); - } - binding.previewVideo.setOnCompletionListener(mediaPlayer -> binding.previewVideo.start()); - } - } - - adapter = new SmartListAdapter(null, new SmartListViewHolder.SmartListListeners() { - @Override - public void onItemClick(SmartListViewModel smartListViewModel) { - if (mPendingIntent != null) { - Intent intent = mPendingIntent; - mPendingIntent = null; - String type = intent.getType(); - if (type != null && type.startsWith("text/")) { - intent.putExtra(Intent.EXTRA_TEXT, binding.previewText.getText().toString()); - } - intent.putExtras(ConversationPath.toBundle(smartListViewModel.getAccountId(), smartListViewModel.getUri())); - intent.setClass(requireActivity(), ConversationActivity.class); - startActivity(intent); - } - } - - @Override - public void onItemLongClick(SmartListViewModel smartListViewModel) { - - } - }, mDisposable); - binding.shareList.setLayoutManager(new LinearLayoutManager(context)); - binding.shareList.setAdapter(adapter); - return binding.getRoot(); - } - - @Override - public void onStart() { - super.onStart(); - if (mPendingIntent == null) - getActivity().finish(); - mDisposable.add(mConversationFacade - .getCurrentAccountSubject() - .switchMap(a -> a.getConversationsViewModels(false)) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(list -> { - if (adapter != null) - adapter.update(list); - })); - if (binding != null && binding.previewVideo.getVisibility() != View.GONE) { - binding.previewVideo.start(); - } - } - - @Override - public void onStop() { - super.onStop(); - mDisposable.clear(); - } - - @Override - public void onCreate(@Nullable Bundle bundle) { - super.onCreate(bundle); - /*Intent intent = getActivity().getIntent(); - Bundle extra = intent.getExtras(); - if (ConversationPath.fromBundle(extra) != null) { - intent.setClass(getActivity(), ConversationActivity.class); - startActivity(intent); - return; - }*/ - mPendingIntent = getActivity().getIntent(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - mPendingIntent = null; - adapter = null; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/fragments/ShareWithFragment.kt b/ring-android/app/src/main/java/cx/ring/fragments/ShareWithFragment.kt new file mode 100644 index 000000000..c17089713 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/fragments/ShareWithFragment.kt @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.fragments + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.InflateException +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import cx.ring.adapters.SmartListAdapter +import cx.ring.client.ConversationActivity +import cx.ring.databinding.FragSharewithBinding +import cx.ring.utils.ConversationPath +import cx.ring.viewholders.SmartListViewHolder.SmartListListeners +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.services.ConversationFacade +import net.jami.model.Account +import net.jami.services.ContactService +import net.jami.smartlist.SmartListViewModel +import javax.inject.Inject +import javax.inject.Singleton + +@AndroidEntryPoint +class ShareWithFragment : Fragment() { + private val mDisposable = CompositeDisposable() + + @JvmField + @Inject + @Singleton + var mConversationFacade: ConversationFacade? = null + + @JvmField + @Inject + @Singleton + var mContactService: ContactService? = null + private var mPendingIntent: Intent? = null + private var adapter: SmartListAdapter? = null + private var binding: FragSharewithBinding? = null + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragSharewithBinding.inflate(inflater) + val context = binding!!.root.context + val activity: Activity? = activity + if (activity is AppCompatActivity) { + activity.setSupportActionBar(binding!!.toolbar) + val ab = activity.supportActionBar + ab?.setDisplayHomeAsUpEnabled(true) + } + if (mPendingIntent != null) { + val type = mPendingIntent!!.type!! + val clip = mPendingIntent!!.clipData + when { + type.startsWith("text/") -> { + binding!!.previewText.setText(mPendingIntent!!.getStringExtra(Intent.EXTRA_TEXT)) + binding!!.previewText.visibility = View.VISIBLE + } + type.startsWith("image/") -> { + var data = mPendingIntent!!.data + if (data == null && clip != null && clip.itemCount > 0) data = clip.getItemAt(0).uri + binding!!.previewImage.setImageURI(data) + binding!!.previewImage.visibility = View.VISIBLE + } + type.startsWith("video/") -> { + var data = mPendingIntent!!.data + if (data == null && clip != null && clip.itemCount > 0) data = clip.getItemAt(0).uri + try { + binding!!.previewVideo.setVideoURI(data) + binding!!.previewVideo.visibility = View.VISIBLE + } catch (e: NullPointerException) { + Log.e(TAG, e.message!!) + } catch (e: InflateException) { + Log.e(TAG, e.message!!) + } catch (e: NumberFormatException) { + Log.e(TAG, e.message!!) + } + binding!!.previewVideo.setOnCompletionListener { binding!!.previewVideo.start() } + } + } + } + adapter = SmartListAdapter(null, object : SmartListListeners { + override fun onItemClick(smartListViewModel: SmartListViewModel) { + mPendingIntent?.let { intent -> + mPendingIntent = null + val type = intent.type + if (type != null && type.startsWith("text/")) { + intent.putExtra(Intent.EXTRA_TEXT, binding!!.previewText.text.toString()) + } + intent.putExtras( + ConversationPath.toBundle( + smartListViewModel.accountId, + smartListViewModel.uri + ) + ) + intent.setClass(requireActivity(), ConversationActivity::class.java) + startActivity(intent) + } + } + + override fun onItemLongClick(smartListViewModel: SmartListViewModel) {} + }, mDisposable) + binding!!.shareList.layoutManager = LinearLayoutManager(context) + binding!!.shareList.adapter = adapter + return binding!!.root + } + + override fun onStart() { + super.onStart() + if (mPendingIntent == null) requireActivity().finish() + mDisposable.add(mConversationFacade!! + .currentAccountSubject + .switchMap { a: Account -> a.getConversationsViewModels(false) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { list: MutableList<SmartListViewModel> -> + adapter?.update(list) + }) + if (binding != null && binding!!.previewVideo.visibility != View.GONE) { + binding!!.previewVideo.start() + } + } + + override fun onStop() { + super.onStop() + mDisposable.clear() + } + + override fun onCreate(bundle: Bundle?) { + super.onCreate(bundle) + /*Intent intent = getActivity().getIntent(); + Bundle extra = intent.getExtras(); + if (ConversationPath.fromBundle(extra) != null) { + intent.setClass(getActivity(), ConversationActivity.class); + startActivity(intent); + return; + }*/mPendingIntent = requireActivity().intent + } + + override fun onDestroy() { + super.onDestroy() + mPendingIntent = null + adapter = null + } + + companion object { + private val TAG = ShareWithFragment::class.java.simpleName + + /** + * Mandatory empty constructor for the fragment manager to instantiate the + * fragment (e.g. upon screen orientation changes). + */ + /*public ShareWithFragment() { + JamiApplication.getInstance().getInjectionComponent().inject(this); + }*/ + fun newInstance(): ShareWithFragment { + return ShareWithFragment() + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/fragments/SmartListFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/SmartListFragment.java deleted file mode 100644 index d5a5288c8..000000000 --- a/ring-android/app/src/main/java/cx/ring/fragments/SmartListFragment.java +++ /dev/null @@ -1,540 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.fragments; - -import android.app.Activity; -import android.app.SearchManager; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.text.InputType; -import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.inputmethod.EditorInfo; -import android.widget.EditText; -import android.widget.RelativeLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SearchView; -import androidx.appcompat.widget.Toolbar; -import androidx.recyclerview.widget.DefaultItemAnimator; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton; -import com.google.android.material.snackbar.Snackbar; - -import net.jami.model.Conversation; -import net.jami.services.AccountService; -import net.jami.smartlist.SmartListPresenter; -import net.jami.smartlist.SmartListView; -import net.jami.smartlist.SmartListViewModel; - -import java.util.List; - -import javax.inject.Inject; - -import cx.ring.R; -import cx.ring.adapters.SmartListAdapter; -import cx.ring.application.JamiApplication; -import cx.ring.client.CallActivity; -import cx.ring.client.HomeActivity; -import cx.ring.databinding.FragSmartlistBinding; -import cx.ring.mvp.BaseSupportFragment; -import cx.ring.utils.ActionHelper; -import cx.ring.utils.ClipboardHelper; -import cx.ring.utils.ConversationPath; -import cx.ring.utils.DeviceUtils; -import cx.ring.viewholders.SmartListViewHolder; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class SmartListFragment extends BaseSupportFragment<SmartListPresenter> implements SearchView.OnQueryTextListener, - SmartListViewHolder.SmartListListeners, - Conversation.ConversationActionCallback, - SmartListView { - private static final String TAG = SmartListFragment.class.getSimpleName(); - private static final String STATE_LOADING = TAG + ".STATE_LOADING"; - - private static final int SCROLL_DIRECTION_UP = -1; - - @Inject - AccountService mAccountService; - - private SmartListAdapter mSmartListAdapter; - - private SearchView mSearchView = null; - private MenuItem mSearchMenuItem = null; - private MenuItem mDialpadMenuItem = null; - private FragSmartlistBinding binding; - - @Override - public void onCreateOptionsMenu(final Menu menu, MenuInflater inflater) { - menu.clear(); - - inflater.inflate(R.menu.smartlist_menu, menu); - mSearchMenuItem = menu.findItem(R.id.menu_contact_search); - mDialpadMenuItem = menu.findItem(R.id.menu_contact_dial); - mSearchMenuItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { - @Override - public boolean onMenuItemActionCollapse(MenuItem item) { - mDialpadMenuItem.setVisible(false); - binding.newconvFab.show(); - setOverflowMenuVisible(menu, true); - changeSeparatorHeight(false); - binding.qrCode.setVisibility(View.GONE); - //binding.newGroup.setVisibility(View.GONE); - setTabletQRLayout(false); - return true; - } - - @Override - public boolean onMenuItemActionExpand(MenuItem item) { - mDialpadMenuItem.setVisible(true); - binding.newconvFab.hide(); - setOverflowMenuVisible(menu, false); - changeSeparatorHeight(true); - binding.qrCode.setVisibility(View.VISIBLE); - //binding.newGroup.setVisibility(View.VISIBLE); - setTabletQRLayout(true); - return true; - } - }); - - mSearchView = (SearchView) mSearchMenuItem.getActionView(); - mSearchView.setOnQueryTextListener(this); - mSearchView.setQueryHint(getString(R.string.searchbar_hint)); - mSearchView.setLayoutParams(new Toolbar.LayoutParams(Toolbar.LayoutParams.WRAP_CONTENT, Toolbar.LayoutParams.MATCH_PARENT)); - mSearchView.setImeOptions(EditorInfo.IME_ACTION_GO); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - EditText editText = mSearchView.findViewById(R.id.search_src_text); - if (editText != null) { - editText.setAutofillHints(View.AUTOFILL_HINT_USERNAME); - } - } - } - - @Override - public void onStart() { - super.onStart(); - Activity activity = getActivity(); - Intent intent = activity == null ? null : activity.getIntent(); - if (intent != null) - handleIntent(intent); - } - - public void handleIntent(@NonNull Intent intent) { - if (mSearchView != null && intent.getAction() != null) { - switch (intent.getAction()) { - case Intent.ACTION_VIEW: - case Intent.ACTION_CALL: - mSearchView.setQuery(intent.getDataString(), true); - break; - case Intent.ACTION_DIAL: - mSearchMenuItem.expandActionView(); - mSearchView.setQuery(intent.getDataString(), false); - break; - case Intent.ACTION_SEARCH: - mSearchMenuItem.expandActionView(); - mSearchView.setQuery(intent.getStringExtra(SearchManager.QUERY), true); - break; - default: - break; - } - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - int itemId = item.getItemId(); - if (itemId == R.id.menu_contact_search) { - mSearchView.setInputType(EditorInfo.TYPE_CLASS_TEXT - | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - ); - return false; - } else if (itemId == R.id.menu_contact_dial) { - if (mSearchView.getInputType() == EditorInfo.TYPE_CLASS_PHONE) { - mSearchView.setInputType(EditorInfo.TYPE_CLASS_TEXT); - mDialpadMenuItem.setIcon(R.drawable.baseline_dialpad_24); - } else { - mSearchView.setInputType(EditorInfo.TYPE_CLASS_PHONE); - mDialpadMenuItem.setIcon(R.drawable.baseline_keyboard_24); - } - return true; - } else if (itemId == R.id.menu_settings) { - ((HomeActivity) requireActivity()).goToSettings(); - return true; - } else if (itemId == R.id.menu_about) { - ((HomeActivity) requireActivity()).goToAbout(); - return true; - } - return false; - } - - @Override - public boolean onQueryTextSubmit(String query) { - // presenter.newContactClicked(); - return true; - } - - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - // if there's another fragment on top of this one, when a rotation is done, this fragment is destroyed and - // in the process of recreating it, as it is not shown on the top of the screen, the "onCreateView" method is never called, so the mLoader is null - if (binding != null) - outState.putBoolean(STATE_LOADING, binding.loadingIndicator.isShown()); - super.onSaveInstanceState(outState); - } - - @Override - public boolean onQueryTextChange(final String query) { - presenter.queryTextChanged(query); - return true; - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragSmartlistBinding.inflate(inflater, container, false); - ((JamiApplication) requireActivity().getApplication()).getInjectionComponent().inject(this); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - setHasOptionsMenu(true); - super.onViewCreated(view, savedInstanceState); - - binding.qrCode.setOnClickListener(v -> presenter.clickQRSearch()); - //binding.newGroup.setOnClickListener(v -> startNewGroup()); - - binding.confsList.addOnScrollListener(new RecyclerView.OnScrollListener() { - @Override - public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { - boolean canScrollUp = recyclerView.canScrollVertically(SCROLL_DIRECTION_UP); - ExtendedFloatingActionButton btn = binding.newconvFab; - boolean isExtended = btn.isExtended(); - if (dy > 0 && isExtended) { - btn.shrink(); - } else if ((dy < 0 || !canScrollUp) && !isExtended) { - btn.extend(); - } - - HomeActivity activity = (HomeActivity) getActivity(); - if (activity != null) - activity.setToolbarElevation(canScrollUp); - } - }); - - DefaultItemAnimator animator = (DefaultItemAnimator) binding.confsList.getItemAnimator(); - if (animator != null) { - animator.setSupportsChangeAnimations(false); - } - - binding.newconvFab.setOnClickListener(v -> presenter.fabButtonClicked()); - } - - private void startNewGroup() { - ContactPickerFragment fragment = ContactPickerFragment.newInstance(); - fragment.show(getParentFragmentManager(), ContactPickerFragment.TAG); - binding.qrCode.setVisibility(View.GONE); - //binding.newGroup.setVisibility(View.GONE); - setTabletQRLayout(false); - } - - @Override - public void setLoading(final boolean loading) { - binding.loadingIndicator.setVisibility(loading ? View.VISIBLE : View.GONE); - } - - /** - * Handles the visibility of some menus to hide / show the overflow menu - * - * @param menu the menu containing the menuitems we need to access - * @param visible true to display the overflow menu, false otherwise - */ - private void setOverflowMenuVisible(final Menu menu, boolean visible) { - if (null != menu) { - MenuItem overflowMenuItem = menu.findItem(R.id.menu_overflow); - if (null != overflowMenuItem) { - overflowMenuItem.setVisible(visible); - } - } - } - - @Override - public void removeConversation(net.jami.model.Uri conversationUri) { - presenter.removeConversation(conversationUri); - } - - @Override - public void clearConversation(net.jami.model.Uri callContact) { - presenter.clearConversation(callContact); - } - - @Override - public void copyContactNumberToClipboard(String contactNumber) { - ClipboardHelper.copyToClipboard(requireContext(), contactNumber); - String snackbarText = getString(R.string.conversation_action_copied_peer_number_clipboard, - ActionHelper.getShortenedNumber(contactNumber)); - Snackbar.make(binding.listCoordinator, snackbarText, Snackbar.LENGTH_LONG).show(); - } - - public void onFabButtonClicked() { - presenter.fabButtonClicked(); - } - - @Override - public void displayChooseNumberDialog(final CharSequence[] numbers) { - final Context context = requireContext(); - new MaterialAlertDialogBuilder(context) - .setTitle(R.string.choose_number) - .setItems(numbers, (dialog, which) -> { - CharSequence selected = numbers[which]; - Intent intent = new Intent(CallActivity.ACTION_CALL) - .setClass(context, CallActivity.class) - .setData(Uri.parse(selected.toString())); - startActivityForResult(intent, HomeActivity.REQUEST_CODE_CALL); - }) - .show(); - } - - @Override - public void displayNoConversationMessage() { - binding.placeholder.setVisibility(View.VISIBLE); - } - - @Override - public void hideNoConversationMessage() { - binding.placeholder.setVisibility(View.GONE); - } - - @Override - public void displayConversationDialog(final SmartListViewModel smartListViewModel) { - if (smartListViewModel.isSwarm()) { - new MaterialAlertDialogBuilder(requireContext()) - .setItems(R.array.swarm_actions, (dialog, which) -> { - switch (which) { - case 0: - presenter.copyNumber(smartListViewModel); - break; - case 1: - presenter.removeConversation(smartListViewModel); - break; - case 2: - presenter.banContact(smartListViewModel); - break; - } - }) - .show(); - } else { - new MaterialAlertDialogBuilder(requireContext()) - .setItems(R.array.conversation_actions, (dialog, which) -> { - switch (which) { - case ActionHelper.ACTION_COPY: - presenter.copyNumber(smartListViewModel); - break; - case ActionHelper.ACTION_CLEAR: - presenter.clearConversation(smartListViewModel); - break; - case ActionHelper.ACTION_DELETE: - presenter.removeConversation(smartListViewModel); - break; - case ActionHelper.ACTION_BLOCK: - presenter.banContact(smartListViewModel); - break; - } - }) - .show(); - } - } - - @Override - public void displayClearDialog(net.jami.model.Uri uri) { - ActionHelper.launchClearAction(getActivity(), uri, SmartListFragment.this); - } - - @Override - public void displayDeleteDialog(net.jami.model.Uri uri) { - ActionHelper.launchDeleteAction(getActivity(), uri, SmartListFragment.this); - } - - @Override - public void copyNumber(net.jami.model.Uri uri) { - ActionHelper.launchCopyNumberToClipboardFromContact(getActivity(), uri, this); - } - - @Override - public void displayMenuItem() { - if (mSearchMenuItem != null) { - mSearchMenuItem.expandActionView(); - } - } - - @Override - public void hideList() { - binding.confsList.setVisibility(View.GONE); - } - - @Override - public void updateList(@Nullable final List<SmartListViewModel> smartListViewModels, CompositeDisposable parentDisposable) { - if (binding == null) - return; - if (binding.confsList.getAdapter() == null) { - mSmartListAdapter = new SmartListAdapter(smartListViewModels, SmartListFragment.this, parentDisposable); - binding.confsList.setAdapter(mSmartListAdapter); - binding.confsList.setHasFixedSize(true); - LinearLayoutManager llm = new LinearLayoutManager(getActivity()); - llm.setOrientation(RecyclerView.VERTICAL); - binding.confsList.setLayoutManager(llm); - } else { - mSmartListAdapter.update(smartListViewModels); - } - binding.confsList.setVisibility(View.VISIBLE); - } - - @Override - public void update(int position) { - Log.w(TAG, "update " + position + " " + mSmartListAdapter); - if (mSmartListAdapter != null) { - mSmartListAdapter.notifyItemChanged(position); - } - } - - @Override - public void update(SmartListViewModel model) { - if (mSmartListAdapter != null) - mSmartListAdapter.update(model); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == HomeActivity.REQUEST_CODE_QR_CONVERSATION && data != null && resultCode == Activity.RESULT_OK) { - String contactId = data.getStringExtra(ConversationPath.KEY_CONVERSATION_URI); - if (contactId != null) { - presenter.startConversation(net.jami.model.Uri.fromString(contactId)); - } - } - } - - @Override - public void goToConversation(String accountId, net.jami.model.Uri conversationUri) { - Log.w(TAG, "goToConversation " + accountId + " " + conversationUri); - if (mSearchMenuItem != null) { - mSearchMenuItem.collapseActionView(); - } - ((HomeActivity) requireActivity()).startConversation(accountId, conversationUri); - } - - @Override - public void goToCallActivity(String accountId, net.jami.model.Uri conversationUri, String contactId) { - Intent intent = new Intent(CallActivity.ACTION_CALL) - .setClass(requireContext(), CallActivity.class) - .putExtras(ConversationPath.toBundle(accountId, conversationUri)) - .putExtra(CallFragment.KEY_AUDIO_ONLY, false) - .putExtra(Intent.EXTRA_PHONE_NUMBER, contactId); - startActivityForResult(intent, HomeActivity.REQUEST_CODE_CALL); - } - - @Override - public void goToQRFragment() { - QRCodeFragment qrCodeFragment = QRCodeFragment.newInstance(QRCodeFragment.INDEX_SCAN); - qrCodeFragment.show(getParentFragmentManager(), QRCodeFragment.TAG); - binding.qrCode.setVisibility(View.GONE); - //binding.newGroup.setVisibility(View.GONE); - setTabletQRLayout(false); - } - - @Override - public void scrollToTop() { - if (binding != null) - binding.confsList.scrollToPosition(0); - } - - @Override - public void onItemClick(SmartListViewModel smartListViewModel) { - presenter.conversationClicked(smartListViewModel); - } - - @Override - public void onItemLongClick(SmartListViewModel smartListViewModel) { - presenter.conversationLongClicked(smartListViewModel); - } - - private void changeSeparatorHeight(boolean open) { - if (binding == null || binding.separator == null) - return; - - if (DeviceUtils.isTablet(binding.getRoot().getContext())) { - int margin = 0; - - if (open) { - Activity activity = getActivity(); - if (activity != null) { - Toolbar toolbar = activity.findViewById(R.id.main_toolbar); - if (toolbar != null) - margin = toolbar.getHeight(); - } - } - - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.separator.getLayoutParams(); - params.topMargin = margin; - binding.separator.setLayoutParams(params); - } - } - - private void setTabletQRLayout(boolean show) { - Context context = requireContext(); - if (!DeviceUtils.isTablet(context)) - return; - - RelativeLayout.LayoutParams params = - (RelativeLayout.LayoutParams) binding.listCoordinator.getLayoutParams(); - if (show) { - params.addRule(RelativeLayout.BELOW, R.id.qr_code); - params.topMargin = 0; - } else { - params.removeRule(RelativeLayout.BELOW); - TypedValue value = new TypedValue(); - if (context.getTheme().resolveAttribute(android.R.attr.actionBarSize, value, true)) { - params.topMargin = TypedValue.complexToDimensionPixelSize(value.data, context.getResources().getDisplayMetrics()); - } - } - binding.listCoordinator.setLayoutParams(params); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/fragments/SmartListFragment.kt b/ring-android/app/src/main/java/cx/ring/fragments/SmartListFragment.kt new file mode 100644 index 000000000..d797f6f46 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/fragments/SmartListFragment.kt @@ -0,0 +1,424 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.fragments + +import android.app.Activity +import android.app.SearchManager +import android.content.DialogInterface +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.text.InputType +import android.util.Log +import android.util.TypedValue +import android.view.* +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.RelativeLayout +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.Toolbar +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import cx.ring.R +import cx.ring.adapters.SmartListAdapter +import cx.ring.client.CallActivity +import cx.ring.client.HomeActivity +import cx.ring.databinding.FragSmartlistBinding +import cx.ring.mvp.BaseSupportFragment +import cx.ring.utils.ActionHelper +import cx.ring.utils.ClipboardHelper +import cx.ring.utils.ConversationPath +import cx.ring.utils.DeviceUtils +import cx.ring.viewholders.SmartListViewHolder.SmartListListeners +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.model.Conversation.ConversationActionCallback +import net.jami.model.Uri +import net.jami.smartlist.SmartListPresenter +import net.jami.smartlist.SmartListView +import net.jami.smartlist.SmartListViewModel + +@AndroidEntryPoint +class SmartListFragment : BaseSupportFragment<SmartListPresenter, SmartListView>(), + SearchView.OnQueryTextListener, SmartListListeners, ConversationActionCallback, SmartListView { + private var mSmartListAdapter: SmartListAdapter? = null + private var mSearchView: SearchView? = null + private var mSearchMenuItem: MenuItem? = null + private var mDialpadMenuItem: MenuItem? = null + private var binding: FragSmartlistBinding? = null + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + menu.clear() + inflater.inflate(R.menu.smartlist_menu, menu) + val searchMenuItem = menu.findItem(R.id.menu_contact_search) + val dialpadMenuItem = menu.findItem(R.id.menu_contact_dial) + searchMenuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + dialpadMenuItem.isVisible = false + binding!!.newconvFab.show() + setOverflowMenuVisible(menu, true) + changeSeparatorHeight(false) + binding!!.qrCode.visibility = View.GONE + //binding.newGroup.setVisibility(View.GONE); + setTabletQRLayout(false) + return true + } + + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + dialpadMenuItem.isVisible = true + binding!!.newconvFab.hide() + setOverflowMenuVisible(menu, false) + changeSeparatorHeight(true) + binding!!.qrCode.visibility = View.VISIBLE + //binding.newGroup.setVisibility(View.VISIBLE); + setTabletQRLayout(true) + return true + } + }) + val searchView = searchMenuItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.queryHint = getString(R.string.searchbar_hint) + searchView.layoutParams = Toolbar.LayoutParams( + Toolbar.LayoutParams.WRAP_CONTENT, + Toolbar.LayoutParams.MATCH_PARENT + ) + searchView.imeOptions = EditorInfo.IME_ACTION_GO + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val editText = searchView.findViewById<EditText>(R.id.search_src_text) + editText?.setAutofillHints(View.AUTOFILL_HINT_USERNAME) + } + mSearchMenuItem = searchMenuItem + mDialpadMenuItem = dialpadMenuItem + mSearchView = searchView + } + + override fun onStart() { + super.onStart() + activity?.intent?.let { handleIntent(it) } + } + + fun handleIntent(intent: Intent) { + if (mSearchView != null && intent.action != null) { + when (intent.action) { + Intent.ACTION_VIEW, Intent.ACTION_CALL -> mSearchView!!.setQuery(intent.dataString, true) + Intent.ACTION_DIAL -> { + mSearchMenuItem?.expandActionView() + mSearchView?.setQuery(intent.dataString, false) + } + Intent.ACTION_SEARCH -> { + mSearchMenuItem?.expandActionView() + mSearchView?.setQuery(intent.getStringExtra(SearchManager.QUERY), true) + } + else -> {} + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_contact_search -> { + mSearchView!!.inputType = (EditorInfo.TYPE_CLASS_TEXT + or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + ) + return false + } + R.id.menu_contact_dial -> { + if (mSearchView!!.inputType == EditorInfo.TYPE_CLASS_PHONE) { + mSearchView!!.inputType = EditorInfo.TYPE_CLASS_TEXT + mDialpadMenuItem!!.setIcon(R.drawable.baseline_dialpad_24) + } else { + mSearchView!!.inputType = EditorInfo.TYPE_CLASS_PHONE + mDialpadMenuItem!!.setIcon(R.drawable.baseline_keyboard_24) + } + return true + } + R.id.menu_settings -> { + (requireActivity() as HomeActivity).goToSettings() + return true + } + R.id.menu_about -> { + (requireActivity() as HomeActivity).goToAbout() + return true + } + else -> return false + } + } + + override fun onQueryTextSubmit(query: String): Boolean { + // presenter.newContactClicked(); + return true + } + + override fun onSaveInstanceState(outState: Bundle) { + binding?.apply { outState.putBoolean(STATE_LOADING, loadingIndicator.isShown) } + super.onSaveInstanceState(outState) + } + + override fun onQueryTextChange(query: String): Boolean { + presenter.queryTextChanged(query) + return true + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + setHasOptionsMenu(true) + return FragSmartlistBinding.inflate(inflater, container, false).apply { + qrCode.setOnClickListener { presenter.clickQRSearch() } + newconvFab.setOnClickListener { presenter.fabButtonClicked() } + confsList.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val canScrollUp = recyclerView.canScrollVertically(SCROLL_DIRECTION_UP) + val isExtended = newconvFab.isExtended + if (dy > 0 && isExtended) { + newconvFab.shrink() + } else if ((dy < 0 || !canScrollUp) && !isExtended) { + newconvFab.extend() + } + (activity as HomeActivity?)?.setToolbarElevation(canScrollUp) + } + }) + (confsList.itemAnimator as DefaultItemAnimator?)?.supportsChangeAnimations = false + binding = this + }.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private fun startNewGroup() { + val fragment = ContactPickerFragment.newInstance() + fragment.show(parentFragmentManager, ContactPickerFragment.TAG) + binding!!.qrCode.visibility = View.GONE + //binding.newGroup.setVisibility(View.GONE); + setTabletQRLayout(false) + } + + override fun setLoading(loading: Boolean) { + binding!!.loadingIndicator.visibility = if (loading) View.VISIBLE else View.GONE + } + + /** + * Handles the visibility of some menus to hide / show the overflow menu + * + * @param menu the menu containing the menuitems we need to access + * @param visible true to display the overflow menu, false otherwise + */ + private fun setOverflowMenuVisible(menu: Menu?, visible: Boolean) { + menu?.findItem(R.id.menu_overflow)?.isVisible = visible + } + + override fun removeConversation(conversationUri: Uri) { + presenter.removeConversation(conversationUri) + } + + override fun clearConversation(callContact: Uri) { + presenter.clearConversation(callContact) + } + + override fun copyContactNumberToClipboard(contactNumber: String) { + ClipboardHelper.copyToClipboard(requireContext(), contactNumber) + val snackbarText = getString( + R.string.conversation_action_copied_peer_number_clipboard, + ActionHelper.getShortenedNumber(contactNumber) + ) + Snackbar.make(binding!!.listCoordinator, snackbarText, Snackbar.LENGTH_LONG).show() + } + + fun onFabButtonClicked() { + presenter.fabButtonClicked() + } + + override fun displayChooseNumberDialog(numbers: Array<CharSequence>) { + val context = requireContext() + MaterialAlertDialogBuilder(context) + .setTitle(R.string.choose_number) + .setItems(numbers) { _: DialogInterface?, which: Int -> + val selected = numbers[which] + val intent = Intent(CallActivity.ACTION_CALL) + .setClass(context, CallActivity::class.java) + .setData(android.net.Uri.parse(selected.toString())) + startActivityForResult(intent, HomeActivity.REQUEST_CODE_CALL) + } + .show() + } + + override fun displayNoConversationMessage() { + binding!!.placeholder.visibility = View.VISIBLE + } + + override fun hideNoConversationMessage() { + binding!!.placeholder.visibility = View.GONE + } + + override fun displayConversationDialog(smartListViewModel: SmartListViewModel) { + if (smartListViewModel.isSwarm) { + MaterialAlertDialogBuilder(requireContext()) + .setItems(R.array.swarm_actions) { dialog, which -> + when (which) { + 0 -> presenter.copyNumber(smartListViewModel) + 1 -> presenter.removeConversation(smartListViewModel) + 2 -> presenter.banContact(smartListViewModel) + } + } + .show() + } else { + MaterialAlertDialogBuilder(requireContext()) + .setItems(R.array.conversation_actions) { dialog, which -> + when (which) { + ActionHelper.ACTION_COPY -> presenter.copyNumber(smartListViewModel) + ActionHelper.ACTION_CLEAR -> presenter.clearConversation(smartListViewModel) + ActionHelper.ACTION_DELETE -> presenter.removeConversation(smartListViewModel) + ActionHelper.ACTION_BLOCK -> presenter.banContact(smartListViewModel) + } + } + .show() + } + } + + override fun displayClearDialog(uri: Uri) { + ActionHelper.launchClearAction(activity, uri, this@SmartListFragment) + } + + override fun displayDeleteDialog(uri: Uri) { + ActionHelper.launchDeleteAction(activity, uri, this@SmartListFragment) + } + + override fun copyNumber(uri: Uri) { + ActionHelper.launchCopyNumberToClipboardFromContact(activity, uri, this) + } + + override fun displayMenuItem() { + mSearchMenuItem?.expandActionView() + } + + override fun hideList() { + binding!!.confsList.visibility = View.GONE + mSmartListAdapter?.update(null) + } + + override fun updateList(smartListViewModels: MutableList<SmartListViewModel>?, parentDisposable: CompositeDisposable) { + binding?.apply { + if (confsList.adapter == null) { + confsList.adapter = SmartListAdapter(smartListViewModels, this@SmartListFragment, parentDisposable).apply { + mSmartListAdapter = this + } + confsList.setHasFixedSize(true) + confsList.layoutManager = LinearLayoutManager(requireContext()).apply { + orientation = RecyclerView.VERTICAL + } + } else { + mSmartListAdapter?.update(smartListViewModels) + } + confsList.visibility = View.VISIBLE + } + } + + override fun update(position: Int) { + Log.w(TAG, "update $position $mSmartListAdapter") + mSmartListAdapter?.notifyItemChanged(position) + } + + override fun update(model: SmartListViewModel) { + mSmartListAdapter?.update(model) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == HomeActivity.REQUEST_CODE_QR_CONVERSATION && data != null && resultCode == Activity.RESULT_OK) { + val contactId = data.getStringExtra(ConversationPath.KEY_CONVERSATION_URI) + if (contactId != null) { + presenter.startConversation(Uri.fromString(contactId)) + } + } + } + + override fun goToConversation(accountId: String, conversationUri: Uri) { + Log.w(TAG, "goToConversation $accountId $conversationUri") + mSearchMenuItem?.collapseActionView() + (requireActivity() as HomeActivity).startConversation(accountId, conversationUri) + } + + override fun goToCallActivity(accountId: String, conversationUri: Uri, contactId: String) { + val intent = Intent(CallActivity.ACTION_CALL) + .setClass(requireContext(), CallActivity::class.java) + .putExtras(ConversationPath.toBundle(accountId, conversationUri)) + .putExtra(CallFragment.KEY_AUDIO_ONLY, false) + .putExtra(Intent.EXTRA_PHONE_NUMBER, contactId) + startActivityForResult(intent, HomeActivity.REQUEST_CODE_CALL) + } + + override fun goToQRFragment() { + val qrCodeFragment = QRCodeFragment.newInstance(QRCodeFragment.INDEX_SCAN) + qrCodeFragment.show(parentFragmentManager, QRCodeFragment.TAG) + binding!!.qrCode.visibility = View.GONE + //binding.newGroup.setVisibility(View.GONE); + setTabletQRLayout(false) + } + + override fun scrollToTop() { + binding?.apply { confsList.scrollToPosition(0) } + } + + override fun onItemClick(smartListViewModel: SmartListViewModel) { + presenter.conversationClicked(smartListViewModel) + } + + override fun onItemLongClick(smartListViewModel: SmartListViewModel) { + presenter.conversationLongClicked(smartListViewModel) + } + + private fun changeSeparatorHeight(open: Boolean) { + binding?.let { binding -> binding.separator?.let { separator -> + if (DeviceUtils.isTablet(binding.root.context)) { + val params = separator.layoutParams as RelativeLayout.LayoutParams + params.topMargin = if (open) activity?.findViewById<Toolbar>(R.id.main_toolbar)?.height ?: 0 else 0 + separator.layoutParams = params + } + }} + } + + private fun setTabletQRLayout(show: Boolean) { + val context = requireContext() + if (!DeviceUtils.isTablet(context)) return + val params = binding!!.listCoordinator.layoutParams as RelativeLayout.LayoutParams + if (show) { + params.addRule(RelativeLayout.BELOW, R.id.qr_code) + params.topMargin = 0 + } else { + params.removeRule(RelativeLayout.BELOW) + val value = TypedValue() + if (context.theme.resolveAttribute(android.R.attr.actionBarSize, value, true)) { + params.topMargin = TypedValue.complexToDimensionPixelSize(value.data, context.resources.displayMetrics) + } + } + binding!!.listCoordinator.layoutParams = params + } + + companion object { + private val TAG = SmartListFragment::class.simpleName!! + private val STATE_LOADING = "$TAG.STATE_LOADING" + private const val SCROLL_DIRECTION_UP = -1 + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/history/DatabaseHelper.java b/ring-android/app/src/main/java/cx/ring/history/DatabaseHelper.java deleted file mode 100644 index c4ca6d0ed..000000000 --- a/ring-android/app/src/main/java/cx/ring/history/DatabaseHelper.java +++ /dev/null @@ -1,432 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.history; - -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteException; -import android.util.Log; - -import com.j256.ormlite.android.apptools.OrmLiteSqliteOpenHelper; -import com.j256.ormlite.dao.Dao; -import com.j256.ormlite.support.ConnectionSource; -import com.j256.ormlite.table.TableUtils; - -import java.sql.SQLException; -import java.util.ArrayList; - -import javax.inject.Inject; - -import cx.ring.application.JamiApplication; -import net.jami.model.ConversationHistory; -import net.jami.model.DataTransfer; -import net.jami.model.Interaction; -import net.jami.services.HistoryService; - -/* - * Database History Version - * 7 : changing columns names. See https://gerrit-ring.savoirfairelinux.com/#/c/4297 - * 10: Switches to per account database system and implements new interaction and conversations table. - */ - -/** - * Database helper class used to manage the creation and upgrading of your database. This class also usually provides - * the DAOs used by the other classes. - */ -public class DatabaseHelper extends OrmLiteSqliteOpenHelper { - private static final String TAG = DatabaseHelper.class.getSimpleName(); - // any time you make changes to your database objects, you may have to increase the database version - private static final int DATABASE_VERSION = 10; - - private Dao<Interaction, Integer> interactionDataDao = null; - private Dao<ConversationHistory, Integer> conversationDataDao = null; - - @Inject - HistoryService mHistoryService; - - public DatabaseHelper(Context context, String dbDirectory) { - super(context, dbDirectory, null, DATABASE_VERSION); - Log.d(TAG, "Helper initialized for " + dbDirectory); - ((JamiApplication) context.getApplicationContext()).getInjectionComponent().inject(this); - } - - /** - * This is called when the database is first created. Usually you should call createTable statements here to create - * the tables that will store your data. - */ - @Override - public void onCreate(SQLiteDatabase db, ConnectionSource connectionSource) { - try { - db.beginTransaction(); - try { - TableUtils.createTable(connectionSource, ConversationHistory.class); - TableUtils.createTable(connectionSource, Interaction.class); - db.setTransactionSuccessful(); - } catch (SQLException e) { - Log.e(TAG, "Can't create database", e); - throw new RuntimeException(e); - } - } finally { - db.endTransaction(); - } - } - - /** - * This is called when your application is upgraded and it has a higher version number. This allows you to adjust - * the various data to match the new version number. - */ - @Override - public void onUpgrade(SQLiteDatabase db, ConnectionSource connectionSource, int oldVersion, int newVersion) { - Log.i(TAG, "onUpgrade " + oldVersion + " -> " + newVersion); - try { - // if we are under version 10, it must first wait for account splitting to be complete which occurs in history service - if (oldVersion >= 10) - updateDatabase(oldVersion, db, connectionSource); - } catch (SQLException exc) { - exc.printStackTrace(); - clearDatabase(db); - onCreate(db, connectionSource); - } - } - - /** - * Returns the Database Access Object (DAO) for our SimpleData class. It will create it or just give the cached - * value. - */ - - public Dao<Interaction, Integer> getInteractionDataDao() throws SQLException { - if (interactionDataDao == null) { - interactionDataDao = getDao(Interaction.class); - } - return interactionDataDao; - } - - public Dao<ConversationHistory, Integer> getConversationDataDao() throws SQLException { - if (conversationDataDao == null) { - conversationDataDao = getDao(ConversationHistory.class); - } - return conversationDataDao; - } - - /** - * Close the database connections and clear any cached DAOs. - */ - @Override - public void close() { - super.close(); - interactionDataDao = null; - conversationDataDao = null; - } - - /** - * Main method to update the database from an old version to the last - * - * @param fromDatabaseVersion the old version of the database - * @param db the SQLiteDatabase to work with - * @throws SQLiteException database has failed to update to the last version - */ - private void updateDatabase(int fromDatabaseVersion, SQLiteDatabase db, ConnectionSource connectionSource) throws SQLException { - try { - while (fromDatabaseVersion < DATABASE_VERSION) { - switch (fromDatabaseVersion) { - case 6: - updateDatabaseFrom6(db); - break; - case 7: - updateDatabaseFrom7(db); - break; - case 8: - updateDatabaseFrom8(connectionSource); - break; - case 9: - updateDatabaseFrom9(db); - break; - } - fromDatabaseVersion++; - } - Log.d(TAG, "updateDatabase: Database has been updated to the last version."); - } catch (SQLException exc) { - Log.e(TAG, "updateDatabase: Database has failed to update to the last version."); - throw exc; - } - } - - /** - * Executes the migration from the database version 6 to the next - * - * @param db the SQLiteDatabase to work with - * @throws SQLiteException migration from database version 6 to next, failed - */ - private void updateDatabaseFrom6(SQLiteDatabase db) throws SQLiteException { - if (db != null && db.isOpen()) { - try { - Log.d(TAG, "updateDatabaseFrom6: Will begin migration from database version 6 to next."); - db.beginTransaction(); - //~ Create the new historyCall table and int index - db.execSQL("CREATE TABLE IF NOT EXISTS `historycall` (`accountID` VARCHAR , `callID` VARCHAR , " + - "`call_end` BIGINT , `TIMESTAMP_START` BIGINT , `contactID` BIGINT , " + - "`contactKey` VARCHAR , `direction` INTEGER , `missed` SMALLINT , " + - "`number` VARCHAR , `recordPath` VARCHAR ) ;"); - db.execSQL("CREATE INDEX IF NOT EXISTS `historycall_TIMESTAMP_START_idx` ON `historycall` " + - "( `TIMESTAMP_START` );"); - //~ Create the new historyText table and int indexes - db.execSQL("CREATE TABLE IF NOT EXISTS `historytext` (`accountID` VARCHAR , `callID` VARCHAR , " + - "`contactID` BIGINT , `contactKey` VARCHAR , `direction` INTEGER , " + - "`id` BIGINT , `message` VARCHAR , `number` VARCHAR , `read` SMALLINT , " + - "`TIMESTAMP` BIGINT , PRIMARY KEY (`id`) );"); - db.execSQL("CREATE INDEX IF NOT EXISTS `historytext_TIMESTAMP_idx` ON `historytext` ( `TIMESTAMP` );"); - db.execSQL("CREATE INDEX IF NOT EXISTS `historytext_id_idx` ON `historytext` ( `id` );"); - - try (Cursor hasATable = db.rawQuery("SELECT name FROM sqlite_master WHERE type=? AND name=?;", - new String[]{"table", "a"})) { - if (hasATable.getCount() > 0) { - //~ Copying data from the old table "a" - db.execSQL("INSERT INTO `historycall` (TIMESTAMP_START, call_end, number, missed," + - "direction, recordPath, accountID, contactID, contactKey, callID) " + - "SELECT TIMESTAMP_START,b,c,d,e,f,g,h,i,j FROM a;"); - db.execSQL("DROP TABLE IF EXISTS a_TIMESTAMP_START_idx;"); - db.execSQL("DROP TABLE a;"); - } - } - - try (Cursor hasETable = db.rawQuery("SELECT name FROM sqlite_master WHERE type=? AND name=?;", - new String[]{"table", "e"})) { - if (hasETable.getCount() > 0) { - //~ Copying data from the old table "e" - db.execSQL("INSERT INTO historytext (id, TIMESTAMP, number, direction, accountID," + - "contactID, contactKey, callID, message, read) " + - "SELECT id,TIMESTAMP,c,d,e,f,g,h,i,j FROM e;"); - //~ Remove old tables "a" and "e" - db.execSQL("DROP TABLE IF EXISTS e_TIMESTAMP_idx;"); - db.execSQL("DROP TABLE IF EXISTS e_id_idx;"); - db.execSQL("DROP TABLE e;"); - } - } - - db.setTransactionSuccessful(); - db.endTransaction(); - Log.d(TAG, "updateDatabaseFrom6: Migration from database version 6 to next, done."); - } catch (SQLiteException exception) { - Log.e(TAG, "updateDatabaseFrom6: Migration from database version 6 to next, failed."); - throw exception; - } - } - } - - private void updateDatabaseFrom7(SQLiteDatabase db) throws SQLiteException { - if (db != null && db.isOpen()) { - try { - Log.d(TAG, "updateDatabaseFrom7: Will begin migration from database version 7 to next."); - db.beginTransaction(); - db.execSQL("ALTER TABLE historytext ADD COLUMN state VARCHAR DEFAULT ''"); - db.setTransactionSuccessful(); - db.endTransaction(); - Log.d(TAG, "updateDatabaseFrom7: Migration from database version 7 to next, done."); - } catch (SQLiteException exception) { - Log.e(TAG, "updateDatabaseFrom7: Migration from database version 7 to next, failed."); - throw exception; - } - } - } - - private void updateDatabaseFrom8(ConnectionSource connectionSource) throws SQLException { - try { - TableUtils.createTable(connectionSource, DataTransfer.class); - Log.d(TAG, "Migration from database version 8 to next, done."); - } catch (SQLException e) { - Log.e(TAG, "Migration from database version 8 to next, failed.", e); - throw e; - } - } - - /** - * This updates the database to version 10 which includes the switch to interaction and conversation tables. - * It will delete previous tables. - * - * @param db the database to migrate - * @throws SQLiteException - */ - private void updateDatabaseFrom9(SQLiteDatabase db) throws SQLiteException { - if (db != null && db.isOpen()) { - try { - Log.d(TAG, "updateDatabaseFrom9: Will begin migration from database version 9 to next for db: " + db.getPath()); - db.beginTransaction(); - - // removing ring prefix from both call and text database - - // where clause improves performance (not required) - - db.execSQL("UPDATE historytext \n" + - "SET number = replace( number, 'ring:', '' )\n" + - "WHERE number LIKE 'ring:%'"); - - db.execSQL("UPDATE historycall \n" + - "SET number = replace( number, 'ring:', '' )\n" + - "WHERE number LIKE 'ring:%'"); - - // populating conversations table - - db.execSQL("INSERT INTO conversations (participant)\n" + - "SELECT DISTINCT historytext.number\n" + - "FROM historytext \n" + - " LEFT JOIN historycall \n" + - "\t ON historycall.number = historytext.number\n" + - "UNION \n" + - "SELECT DISTINCT historycall.number\n" + - "FROM historycall\n" + - " LEFT JOIN historytext\n" + - "\t ON historytext.number = historycall.number\n" + - "UNION\n" + - "SELECT DISTINCT historydata.peerId\n" + - "FROM historydata\n" + - " LEFT JOIN historytext\n" + - "\t ON historytext.number = historydata.peerId"); - - - - // DATA TRANSFER TABLE - - // Data transfer migration is done first as we maintain the same ID's as in the previous database - - // updating the statuses to the new schema - - db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_CREATED' WHERE dataTransferEventCode='CREATED'"); - db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_ERROR' WHERE dataTransferEventCode='UNSUPPORTED'"); - db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_AWAITING_PEER' WHERE dataTransferEventCode='WAIT_PEER_ACCEPTANCE'"); - db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_AWAITING_HOST' WHERE dataTransferEventCode='WAIT_HOST_ACCEPTANCE'"); - db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_ONGOING' WHERE dataTransferEventCode='ONGOING'"); - db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_FINISHED' WHERE dataTransferEventCode='FINISHED'"); - db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_UNJOINABLE_PEER' WHERE dataTransferEventCode='CLOSED_BY_HOST'"); - db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_UNJOINABLE_PEER' WHERE dataTransferEventCode='CLOSED_BY_PEER'"); - db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_ERROR' WHERE dataTransferEventCode='INVALID_PATHNAME'"); - db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_UNJOINABLE_PEER' WHERE dataTransferEventCode='UNJOINABLE_PEER'"); - - // migration - - db.execSQL("INSERT INTO interactions (id, author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation)\n" + - "SELECT historydata.id, historydata.peerId, null, historydata.displayName, 1, historydata.dataTransferEventCode, historydata.TIMESTAMP, 'DATA_TRANSFER', '{}', conversations.id\n" + - "FROM historydata\n" + - "JOIN conversations ON conversations.participant = historydata.peerId\n" + - "WHERE isOutgoing = 0\n" - ); - - db.execSQL("INSERT INTO interactions (id, author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation)\n" + - "SELECT historydata.id, null, null, historydata.displayName, 1, historydata.dataTransferEventCode, historydata.TIMESTAMP, 'DATA_TRANSFER', '{}', conversations.id\n" + - "FROM historydata\n" + - "JOIN conversations ON conversations.participant = historydata.peerId\n" + - "WHERE isOutgoing = 1\n" - ); - - - // MESSAGE TABLE - - // updating status in text message table - - db.execSQL("UPDATE historytext SET state='SUCCESS' WHERE state='SENT'"); - db.execSQL("UPDATE historytext SET state='SUCCESS' WHERE state='READ'"); - db.execSQL("UPDATE historytext SET state='FAILURE' WHERE state='FAILURE'"); - db.execSQL("UPDATE historytext SET state='INVALID' WHERE state= null"); - - // migration - - // divided into two similar functions, where author needs to be null in case of outgoing message - - db.execSQL("INSERT INTO interactions (author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation)\n" + - "SELECT null, historytext.id, historytext.message, historytext.read, historytext.state, historytext.TIMESTAMP, 'TEXT','{}', conversations.id\n" + - "FROM historytext\n" + - "JOIN conversations ON conversations.participant = historytext.number\n" + - "WHERE direction = 2\n" - ); - - db.execSQL("INSERT INTO interactions (author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation)\n" + - "SELECT historytext.number, historytext.id, historytext.message, historytext.read, historytext.state, historytext.TIMESTAMP, 'TEXT','{}', conversations.id\n" + - "FROM historytext\n" + - "JOIN conversations ON conversations.participant = historytext.number\n" + - "WHERE direction = 1\n" - ); - - - // CALL TABLE - - // setting the timestamp end to the duration string before migration - db.execSQL("UPDATE historycall SET call_end='{\"duration\":' || (historycall.call_end - historycall.TIMESTAMP_START) || '}' WHERE missed = 0"); - db.execSQL("UPDATE historycall SET call_end='{}' WHERE missed = 1"); - - - db.execSQL("INSERT INTO interactions (author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation)\n" + - "SELECT null, historycall.callID, null, 1, 'SUCCEEDED', historycall.TIMESTAMP_START, 'CALL', historycall.call_end, conversations.id\n" + - "FROM historycall\n" + - "JOIN conversations ON conversations.participant = historycall.number\n" + - "WHERE direction = 1\n" - ); - - db.execSQL("INSERT INTO interactions (author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation)\n" + - "SELECT historycall.number, historycall.callID, null, 1, 'SUCCEEDED', historycall.TIMESTAMP_START, 'CALL', historycall.call_end, conversations.id\n" + - "FROM historycall\n" + - "JOIN conversations ON conversations.participant = historycall.number\n" + - "WHERE direction = 0\n" - ); - - // drop old tables - - db.execSQL("DROP TABLE historycall;"); - db.execSQL("DROP TABLE historytext;"); - db.execSQL("DROP TABLE historydata;"); - - db.setTransactionSuccessful(); - db.endTransaction(); - Log.d(TAG, "updateDatabaseFrom9: Migration from database version 9 to next, done."); - } catch (SQLiteException exception) { - Log.e(TAG, "updateDatabaseFrom9: Migration from database version 9 to next, failed.", exception); - } - } - } - - /** - * Removes all the data from the database, ie all the tables. - * - * @param db the SQLiteDatabase to work with - */ - private void clearDatabase(SQLiteDatabase db) { - if (db != null && db.isOpen()) { - Log.d(TAG, "clearDatabase: Will clear database."); - ArrayList<String> tableNames = new ArrayList<>(); - - try (Cursor c = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null)) { - tableNames.ensureCapacity(c.getCount()); - while (c.moveToNext()) - tableNames.add(c.getString(0)); - } - - try { - db.beginTransaction(); - for (String tableName : tableNames) { - db.execSQL("DROP TABLE " + tableName + ";"); - } - db.setTransactionSuccessful(); - db.endTransaction(); - Log.d(TAG, "clearDatabase: Database is cleared"); - } catch (SQLiteException exc) { - exc.printStackTrace(); - } - } - } -} diff --git a/ring-android/app/src/main/java/cx/ring/history/DatabaseHelper.kt b/ring-android/app/src/main/java/cx/ring/history/DatabaseHelper.kt new file mode 100644 index 000000000..39babd204 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/history/DatabaseHelper.kt @@ -0,0 +1,438 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.history + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.util.Log +import com.j256.ormlite.android.apptools.OrmLiteSqliteOpenHelper +import com.j256.ormlite.dao.Dao +import com.j256.ormlite.support.ConnectionSource +import com.j256.ormlite.table.TableUtils +import net.jami.model.ConversationHistory +import net.jami.model.DataTransfer +import net.jami.model.Interaction +import java.sql.SQLException +import java.util.* + +/** + * Database helper class used to manage the creation and upgrading of your database. This class also usually provides + * the DAOs used by the other classes. + */ +class DatabaseHelper(context: Context?, dbDirectory: String) : + OrmLiteSqliteOpenHelper(context, dbDirectory, null, DATABASE_VERSION) { + val interactionDataDao: Dao<Interaction, Int> by lazy { getDao(Interaction::class.java) } + val conversationDataDao: Dao<ConversationHistory, Int> by lazy { getDao(ConversationHistory::class.java) } + + /** + * This is called when the database is first created. Usually you should call createTable statements here to create + * the tables that will store your data. + */ + override fun onCreate(db: SQLiteDatabase, connectionSource: ConnectionSource) { + try { + db.beginTransaction() + try { + TableUtils.createTable(connectionSource, ConversationHistory::class.java) + TableUtils.createTable(connectionSource, Interaction::class.java) + db.setTransactionSuccessful() + } catch (e: SQLException) { + Log.e(TAG, "Can't create database", e) + throw RuntimeException(e) + } + } finally { + db.endTransaction() + } + } + + /** + * This is called when your application is upgraded and it has a higher version number. This allows you to adjust + * the various data to match the new version number. + */ + override fun onUpgrade( + db: SQLiteDatabase, + connectionSource: ConnectionSource, + oldVersion: Int, + newVersion: Int + ) { + Log.i(TAG, "onUpgrade $oldVersion -> $newVersion") + try { + // if we are under version 10, it must first wait for account splitting to be complete which occurs in history service + if (oldVersion >= 10) updateDatabase(oldVersion, db, connectionSource) + } catch (exc: SQLException) { + exc.printStackTrace() + clearDatabase(db) + onCreate(db, connectionSource) + } + } + + /** + * Main method to update the database from an old version to the last + * + * @param fromDatabaseVersion the old version of the database + * @param db the SQLiteDatabase to work with + * @throws SQLiteException database has failed to update to the last version + */ + @Throws(SQLException::class) + private fun updateDatabase( + fromDatabaseVersion: Int, + db: SQLiteDatabase, + connectionSource: ConnectionSource + ) { + var fromVersion = fromDatabaseVersion + try { + while (fromVersion < DATABASE_VERSION) { + when (fromVersion) { + 6 -> updateDatabaseFrom6(db) + 7 -> updateDatabaseFrom7(db) + 8 -> updateDatabaseFrom8(connectionSource) + 9 -> updateDatabaseFrom9(db) + } + fromVersion++ + } + Log.d(TAG, "updateDatabase: Database has been updated to the last version.") + } catch (exc: SQLException) { + Log.e(TAG, "updateDatabase: Database has failed to update to the last version.") + throw exc + } + } + + /** + * Executes the migration from the database version 6 to the next + * + * @param db the SQLiteDatabase to work with + * @throws SQLiteException migration from database version 6 to next, failed + */ + @Throws(SQLiteException::class) + private fun updateDatabaseFrom6(db: SQLiteDatabase?) { + if (db != null && db.isOpen) { + try { + Log.d( + TAG, + "updateDatabaseFrom6: Will begin migration from database version 6 to next." + ) + db.beginTransaction() + //~ Create the new historyCall table and int index + db.execSQL( + "CREATE TABLE IF NOT EXISTS `historycall` (`accountID` VARCHAR , `callID` VARCHAR , " + + "`call_end` BIGINT , `TIMESTAMP_START` BIGINT , `contactID` BIGINT , " + + "`contactKey` VARCHAR , `direction` INTEGER , `missed` SMALLINT , " + + "`number` VARCHAR , `recordPath` VARCHAR ) ;" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `historycall_TIMESTAMP_START_idx` ON `historycall` " + + "( `TIMESTAMP_START` );" + ) + //~ Create the new historyText table and int indexes + db.execSQL( + "CREATE TABLE IF NOT EXISTS `historytext` (`accountID` VARCHAR , `callID` VARCHAR , " + + "`contactID` BIGINT , `contactKey` VARCHAR , `direction` INTEGER , " + + "`id` BIGINT , `message` VARCHAR , `number` VARCHAR , `read` SMALLINT , " + + "`TIMESTAMP` BIGINT , PRIMARY KEY (`id`) );" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `historytext_TIMESTAMP_idx` ON `historytext` ( `TIMESTAMP` );") + db.execSQL("CREATE INDEX IF NOT EXISTS `historytext_id_idx` ON `historytext` ( `id` );") + db.rawQuery( + "SELECT name FROM sqlite_master WHERE type=? AND name=?;", + arrayOf("table", "a") + ).use { hasATable -> + if (hasATable.count > 0) { + //~ Copying data from the old table "a" + db.execSQL( + "INSERT INTO `historycall` (TIMESTAMP_START, call_end, number, missed," + + "direction, recordPath, accountID, contactID, contactKey, callID) " + + "SELECT TIMESTAMP_START,b,c,d,e,f,g,h,i,j FROM a;" + ) + db.execSQL("DROP TABLE IF EXISTS a_TIMESTAMP_START_idx;") + db.execSQL("DROP TABLE a;") + } + } + db.rawQuery( + "SELECT name FROM sqlite_master WHERE type=? AND name=?;", + arrayOf("table", "e") + ).use { hasETable -> + if (hasETable.count > 0) { + //~ Copying data from the old table "e" + db.execSQL( + "INSERT INTO historytext (id, TIMESTAMP, number, direction, accountID," + + "contactID, contactKey, callID, message, read) " + + "SELECT id,TIMESTAMP,c,d,e,f,g,h,i,j FROM e;" + ) + //~ Remove old tables "a" and "e" + db.execSQL("DROP TABLE IF EXISTS e_TIMESTAMP_idx;") + db.execSQL("DROP TABLE IF EXISTS e_id_idx;") + db.execSQL("DROP TABLE e;") + } + } + db.setTransactionSuccessful() + db.endTransaction() + Log.d(TAG, "updateDatabaseFrom6: Migration from database version 6 to next, done.") + } catch (exception: SQLiteException) { + Log.e( + TAG, + "updateDatabaseFrom6: Migration from database version 6 to next, failed." + ) + throw exception + } + } + } + + @Throws(SQLiteException::class) + private fun updateDatabaseFrom7(db: SQLiteDatabase?) { + if (db != null && db.isOpen) { + try { + Log.d( + TAG, + "updateDatabaseFrom7: Will begin migration from database version 7 to next." + ) + db.beginTransaction() + db.execSQL("ALTER TABLE historytext ADD COLUMN state VARCHAR DEFAULT ''") + db.setTransactionSuccessful() + db.endTransaction() + Log.d(TAG, "updateDatabaseFrom7: Migration from database version 7 to next, done.") + } catch (exception: SQLiteException) { + Log.e( + TAG, + "updateDatabaseFrom7: Migration from database version 7 to next, failed." + ) + throw exception + } + } + } + + @Throws(SQLException::class) + private fun updateDatabaseFrom8(connectionSource: ConnectionSource) { + try { + TableUtils.createTable(connectionSource, DataTransfer::class.java) + Log.d(TAG, "Migration from database version 8 to next, done.") + } catch (e: SQLException) { + Log.e(TAG, "Migration from database version 8 to next, failed.", e) + throw e + } + } + + /** + * This updates the database to version 10 which includes the switch to interaction and conversation tables. + * It will delete previous tables. + * + * @param db the database to migrate + * @throws SQLiteException + */ + @Throws(SQLiteException::class) + private fun updateDatabaseFrom9(db: SQLiteDatabase?) { + if (db != null && db.isOpen) { + try { + Log.d( + TAG, + "updateDatabaseFrom9: Will begin migration from database version 9 to next for db: " + db.path + ) + db.beginTransaction() + + // removing ring prefix from both call and text database + + // where clause improves performance (not required) + db.execSQL( + """ + UPDATE historytext + SET number = replace( number, 'ring:', '' ) + WHERE number LIKE 'ring:%' + """.trimIndent() + ) + db.execSQL( + """ + UPDATE historycall + SET number = replace( number, 'ring:', '' ) + WHERE number LIKE 'ring:%' + """.trimIndent() + ) + + // populating conversations table + db.execSQL( + """INSERT INTO conversations (participant) +SELECT DISTINCT historytext.number +FROM historytext + LEFT JOIN historycall + ON historycall.number = historytext.number +UNION +SELECT DISTINCT historycall.number +FROM historycall + LEFT JOIN historytext + ON historytext.number = historycall.number +UNION +SELECT DISTINCT historydata.peerId +FROM historydata + LEFT JOIN historytext + ON historytext.number = historydata.peerId""" + ) + + + // DATA TRANSFER TABLE + + // Data transfer migration is done first as we maintain the same ID's as in the previous database + + // updating the statuses to the new schema + db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_CREATED' WHERE dataTransferEventCode='CREATED'") + db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_ERROR' WHERE dataTransferEventCode='UNSUPPORTED'") + db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_AWAITING_PEER' WHERE dataTransferEventCode='WAIT_PEER_ACCEPTANCE'") + db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_AWAITING_HOST' WHERE dataTransferEventCode='WAIT_HOST_ACCEPTANCE'") + db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_ONGOING' WHERE dataTransferEventCode='ONGOING'") + db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_FINISHED' WHERE dataTransferEventCode='FINISHED'") + db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_UNJOINABLE_PEER' WHERE dataTransferEventCode='CLOSED_BY_HOST'") + db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_UNJOINABLE_PEER' WHERE dataTransferEventCode='CLOSED_BY_PEER'") + db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_ERROR' WHERE dataTransferEventCode='INVALID_PATHNAME'") + db.execSQL("UPDATE historydata SET dataTransferEventCode='TRANSFER_UNJOINABLE_PEER' WHERE dataTransferEventCode='UNJOINABLE_PEER'") + + // migration + db.execSQL( + """ + INSERT INTO interactions (id, author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation) + SELECT historydata.id, historydata.peerId, null, historydata.displayName, 1, historydata.dataTransferEventCode, historydata.TIMESTAMP, 'DATA_TRANSFER', '{}', conversations.id + FROM historydata + JOIN conversations ON conversations.participant = historydata.peerId + WHERE isOutgoing = 0 + + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO interactions (id, author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation) + SELECT historydata.id, null, null, historydata.displayName, 1, historydata.dataTransferEventCode, historydata.TIMESTAMP, 'DATA_TRANSFER', '{}', conversations.id + FROM historydata + JOIN conversations ON conversations.participant = historydata.peerId + WHERE isOutgoing = 1 + + """.trimIndent() + ) + + + // MESSAGE TABLE + + // updating status in text message table + db.execSQL("UPDATE historytext SET state='SUCCESS' WHERE state='SENT'") + db.execSQL("UPDATE historytext SET state='SUCCESS' WHERE state='READ'") + db.execSQL("UPDATE historytext SET state='FAILURE' WHERE state='FAILURE'") + db.execSQL("UPDATE historytext SET state='INVALID' WHERE state= null") + + // migration + + // divided into two similar functions, where author needs to be null in case of outgoing message + db.execSQL( + """ + INSERT INTO interactions (author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation) + SELECT null, historytext.id, historytext.message, historytext.read, historytext.state, historytext.TIMESTAMP, 'TEXT','{}', conversations.id + FROM historytext + JOIN conversations ON conversations.participant = historytext.number + WHERE direction = 2 + + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO interactions (author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation) + SELECT historytext.number, historytext.id, historytext.message, historytext.read, historytext.state, historytext.TIMESTAMP, 'TEXT','{}', conversations.id + FROM historytext + JOIN conversations ON conversations.participant = historytext.number + WHERE direction = 1 + + """.trimIndent() + ) + + + // CALL TABLE + + // setting the timestamp end to the duration string before migration + db.execSQL("UPDATE historycall SET call_end='{\"duration\":' || (historycall.call_end - historycall.TIMESTAMP_START) || '}' WHERE missed = 0") + db.execSQL("UPDATE historycall SET call_end='{}' WHERE missed = 1") + db.execSQL( + """ + INSERT INTO interactions (author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation) + SELECT null, historycall.callID, null, 1, 'SUCCEEDED', historycall.TIMESTAMP_START, 'CALL', historycall.call_end, conversations.id + FROM historycall + JOIN conversations ON conversations.participant = historycall.number + WHERE direction = 1 + + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO interactions (author ,daemon_id, body, is_read, status, timestamp, type, extra_data, conversation) + SELECT historycall.number, historycall.callID, null, 1, 'SUCCEEDED', historycall.TIMESTAMP_START, 'CALL', historycall.call_end, conversations.id + FROM historycall + JOIN conversations ON conversations.participant = historycall.number + WHERE direction = 0 + + """.trimIndent() + ) + + // drop old tables + db.execSQL("DROP TABLE historycall;") + db.execSQL("DROP TABLE historytext;") + db.execSQL("DROP TABLE historydata;") + db.setTransactionSuccessful() + db.endTransaction() + Log.d(TAG, "updateDatabaseFrom9: Migration from database version 9 to next, done.") + } catch (exception: SQLiteException) { + Log.e( + TAG, + "updateDatabaseFrom9: Migration from database version 9 to next, failed.", + exception + ) + } + } + } + + /** + * Removes all the data from the database, ie all the tables. + * + * @param db the SQLiteDatabase to work with + */ + private fun clearDatabase(db: SQLiteDatabase?) { + if (db != null && db.isOpen) { + Log.d(TAG, "clearDatabase: Will clear database.") + val tableNames = ArrayList<String>() + db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null).use { c -> + tableNames.ensureCapacity(c.count) + while (c.moveToNext()) tableNames.add(c.getString(0)) + } + try { + db.beginTransaction() + for (tableName in tableNames) { + db.execSQL("DROP TABLE $tableName;") + } + db.setTransactionSuccessful() + db.endTransaction() + Log.d(TAG, "clearDatabase: Database is cleared") + } catch (exc: SQLiteException) { + exc.printStackTrace() + } + } + } + + companion object { + private val TAG = DatabaseHelper::class.java.simpleName + + // any time you make changes to your database objects, you may have to increase the database version + private const val DATABASE_VERSION = 10 + } + + init { + Log.d(TAG, "Helper initialized for $dbDirectory") + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/linkpreview/LinkListener.kt b/ring-android/app/src/main/java/cx/ring/linkpreview/LinkListener.kt new file mode 100644 index 000000000..0c36095d5 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/linkpreview/LinkListener.kt @@ -0,0 +1,15 @@ +package cx.ring.linkpreview +interface LinkListener { + + /** + * Called when there was an error in loading the image from url, recommended to hide the view + */ + fun onError() + + /** + * Called when image from url is loaded successfully + * + * @param link to url image + */ + fun onSuccess(link: PreviewData) +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/linkpreview/LinkPreview.kt b/ring-android/app/src/main/java/cx/ring/linkpreview/LinkPreview.kt new file mode 100644 index 000000000..a67f7e821 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/linkpreview/LinkPreview.kt @@ -0,0 +1,30 @@ +package cx.ring.linkpreview + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.jsoup.Jsoup +import org.jsoup.nodes.Document + +object LinkPreview { + + fun loadPreviewData(url: String) : Single<PreviewData> { + return Single.fromCallable { + val doc = Jsoup.connect(url) + .userAgent("Mozilla") + .get() + val imageElements = doc.select("meta[property=og:image]") + if (imageElements.size > 0) { + var it = 0 + var chosen: String? = "" + while ((chosen == null || chosen.isEmpty()) && it < imageElements.size) { + chosen = imageElements[it].attr("content") + it += 1 + } + PreviewData(doc.title(), chosen ?: "", url) + } else { + PreviewData("", "", "") + } + }.subscribeOn(Schedulers.io()) + } + +} diff --git a/ring-android/app/src/main/java/cx/ring/linkpreview/PreviewData.kt b/ring-android/app/src/main/java/cx/ring/linkpreview/PreviewData.kt new file mode 100644 index 000000000..a54edae73 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/linkpreview/PreviewData.kt @@ -0,0 +1,12 @@ +package cx.ring.linkpreview + +data class PreviewData(val title: String, val imageUrl: String, val baseUrl: String) { + + fun isEmpty(): Boolean = title.isEmpty() && imageUrl.isEmpty() && baseUrl.isEmpty() + + fun isNotEmpty(): Boolean = !isEmpty() + + companion object { + val EMPTY_DATA = PreviewData("", "", "") + } +} diff --git a/ring-android/app/src/main/java/cx/ring/mvp/BaseFragment.java b/ring-android/app/src/main/java/cx/ring/mvp/BaseFragment.java deleted file mode 100644 index 5ac47b770..000000000 --- a/ring-android/app/src/main/java/cx/ring/mvp/BaseFragment.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.mvp; - -import android.app.Fragment; -import android.os.Bundle; - -import android.view.View; -import android.widget.Toast; - -import javax.inject.Inject; - -import cx.ring.R; -import net.jami.model.Error; -import net.jami.mvp.BaseView; -import net.jami.mvp.RootPresenter; - -public abstract class BaseFragment<T extends RootPresenter> extends Fragment implements BaseView { - - protected static final String TAG = BaseFragment.class.getSimpleName(); - - @Inject - protected T presenter; - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - //Be sure to do the injection in onCreateView method - presenter.bindView(this); - initPresenter(presenter); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - presenter.unbindView(); - } - - public void displayErrorToast(Error error) { - String errorString; - switch (error) { - case NO_INPUT: - errorString = getString(R.string.call_error_no_camera_no_microphone); - break; - case INVALID_FILE: - errorString = getString(R.string.invalid_file); - break; - case NOT_ABLE_TO_WRITE_FILE: - errorString = getString(R.string.not_able_to_write_file); - break; - case NO_SPACE_LEFT: - errorString = getString(R.string.no_space_left_on_device); - break; - default: - errorString = getString(R.string.generic_error); - break; - } - - Toast.makeText(getActivity(), errorString, Toast.LENGTH_LONG).show(); - } - - protected void initPresenter(T presenter) { - } -} diff --git a/ring-android/app/src/main/java/cx/ring/mvp/BasePreferenceFragment.java b/ring-android/app/src/main/java/cx/ring/mvp/BasePreferenceFragment.java index 2cb836583..60e3749d0 100644 --- a/ring-android/app/src/main/java/cx/ring/mvp/BasePreferenceFragment.java +++ b/ring-android/app/src/main/java/cx/ring/mvp/BasePreferenceFragment.java @@ -27,6 +27,8 @@ import net.jami.mvp.RootPresenter; import javax.inject.Inject; +import dagger.hilt.android.AndroidEntryPoint; + public abstract class BasePreferenceFragment<T extends RootPresenter> extends PreferenceFragmentCompat { @Inject protected T presenter; diff --git a/ring-android/app/src/main/java/cx/ring/mvp/BaseSupportFragment.java b/ring-android/app/src/main/java/cx/ring/mvp/BaseSupportFragment.java deleted file mode 100644 index f22e87151..000000000 --- a/ring-android/app/src/main/java/cx/ring/mvp/BaseSupportFragment.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.mvp; - -import android.os.Bundle; -import android.view.View; -import android.widget.Toast; - -import javax.inject.Inject; - -import androidx.annotation.IdRes; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import cx.ring.R; -import net.jami.model.Error; -import net.jami.mvp.BaseView; -import net.jami.mvp.RootPresenter; - -public abstract class BaseSupportFragment<T extends RootPresenter> extends Fragment implements BaseView { - - protected static final String TAG = BaseSupportFragment.class.getSimpleName(); - - @Inject - protected T presenter; - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - //Be sure to do the injection in onCreateView method - if (presenter != null) { - presenter.bindView(this); - initPresenter(presenter); - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - if (presenter != null) - presenter.unbindView(); - } - - public void displayErrorToast(Error error) { - String errorString; - switch (error) { - case NO_INPUT: - errorString = getString(R.string.call_error_no_camera_no_microphone); - break; - case INVALID_FILE: - errorString = getString(R.string.invalid_file); - break; - case NOT_ABLE_TO_WRITE_FILE: - errorString = getString(R.string.not_able_to_write_file); - break; - case NO_SPACE_LEFT: - errorString = getString(R.string.no_space_left_on_device); - break; - default: - errorString = getString(R.string.generic_error); - break; - } - - Toast.makeText(requireContext(), errorString, Toast.LENGTH_LONG).show(); - } - - protected void initPresenter(T presenter) { - } - - protected void replaceFragmentWithSlide(Fragment fragment, @IdRes int content) { - getFragmentManager() - .beginTransaction() - .setCustomAnimations(R.anim.slide_in_right, - R.anim.slide_out_left, R.anim.slide_in_left, R.anim.slide_out_right) - .replace(content, fragment, TAG) - .addToBackStack(TAG) - .commit(); - } - - protected void replaceFragment(Fragment fragment, @IdRes int content) { - getFragmentManager() - .beginTransaction() - .replace(content, fragment, TAG) - .addToBackStack(TAG) - .commit(); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/mvp/BaseSupportFragment.kt b/ring-android/app/src/main/java/cx/ring/mvp/BaseSupportFragment.kt new file mode 100644 index 000000000..752b8b5b0 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/mvp/BaseSupportFragment.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.mvp + +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import cx.ring.R +import net.jami.model.Error +import net.jami.mvp.BaseView +import net.jami.mvp.RootPresenter +import javax.inject.Inject + +abstract class BaseSupportFragment<T : RootPresenter<V>, V> : Fragment(), BaseView { + @Inject lateinit + var presenter: T + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + //Be sure to do the injection in onCreateView method + presenter.bindView(this as V) + initPresenter(presenter) + } + + override fun onDestroyView() { + super.onDestroyView() + presenter.unbindView() + } + + override fun onDestroy() { + super.onDestroy() + presenter.onDestroy() + } + + override fun displayErrorToast(error: Error) { + val errorString: String = when (error) { + Error.NO_INPUT -> getString(R.string.call_error_no_camera_no_microphone) + Error.INVALID_FILE -> getString(R.string.invalid_file) + Error.NOT_ABLE_TO_WRITE_FILE -> getString(R.string.not_able_to_write_file) + Error.NO_SPACE_LEFT -> getString(R.string.no_space_left_on_device) + else -> getString(R.string.generic_error) + } + Toast.makeText(requireContext(), errorString, Toast.LENGTH_LONG).show() + } + + protected open fun initPresenter(presenter: T) {} + + protected fun replaceFragmentWithSlide(fragment: Fragment?, @IdRes content: Int) { + parentFragmentManager + .beginTransaction() + .setCustomAnimations( + R.anim.slide_in_right, + R.anim.slide_out_left, R.anim.slide_in_left, R.anim.slide_out_right + ) + .replace(content, fragment!!, TAG) + .addToBackStack(TAG) + .commit() + } + + protected fun replaceFragment(fragment: Fragment?, @IdRes content: Int) { + parentFragmentManager + .beginTransaction() + .replace(content, fragment!!, TAG) + .addToBackStack(TAG) + .commit() + } + + companion object { + protected val TAG = BaseSupportFragment::class.simpleName + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/plugins/PluginUtils.java b/ring-android/app/src/main/java/cx/ring/plugins/PluginUtils.java index f3c52e06b..f5eb793cd 100644 --- a/ring-android/app/src/main/java/cx/ring/plugins/PluginUtils.java +++ b/ring-android/app/src/main/java/cx/ring/plugins/PluginUtils.java @@ -35,15 +35,9 @@ public class PluginUtils { for (String pluginPath : pluginsPaths) { File pluginFolder = new File(pluginPath); if(pluginFolder.isDirectory()) { - //We use the absolute path of a plugin as a preference name for uniqueness - boolean enabled = false; - - if (loadedPluginsPaths.contains(pluginPath)) { - enabled = true; - } pluginsList.add(new PluginDetails( pluginFolder.getName(), - pluginFolder.getAbsolutePath(), enabled)); + pluginFolder.getAbsolutePath(), loadedPluginsPaths.contains(pluginPath))); } } return pluginsList; diff --git a/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPicker.java b/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPicker.java index d95578597..28e9917d6 100644 --- a/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPicker.java +++ b/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPicker.java @@ -2,44 +2,40 @@ package cx.ring.plugins.RecyclerPicker; import android.content.Context; import android.graphics.drawable.Drawable; +import android.util.DisplayMetrics; +import android.util.TypedValue; import android.view.View; +import android.view.WindowManager; import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import java.util.List; -public class RecyclerPicker implements RecyclerPickerAdapter.ItemClickListener{ - private RecyclerView mRecyclerView; - private int mItemLayoutResource; - private RecyclerPickerAdapter mAdapter; - private RecyclerPickerLayoutManager mLayoutManager; - private int mOrientation; - private RecyclerPickerLayoutManager.ItemSelectedListener mItemSelectedListener; +public class RecyclerPicker implements RecyclerPickerAdapter.ItemClickListener { + private final RecyclerView mRecyclerView; + private final RecyclerPickerAdapter mAdapter; + private final RecyclerPickerLayoutManager mLayoutManager; + private final RecyclerPickerLayoutManager.ItemSelectedListener mItemSelectedListener; private int paddingLeft; private int paddingRight; - public RecyclerPicker(RecyclerView recyclerView, + public RecyclerPicker(@NonNull RecyclerView recyclerView, @LayoutRes int recyclerItemLayout, int orientation, RecyclerPickerLayoutManager.ItemSelectedListener listener) { mRecyclerView = recyclerView; - mItemLayoutResource = recyclerItemLayout; - mOrientation = orientation; mItemSelectedListener = listener; - init(); - } - - private void init() { // use this setting to improve performance if you know that changes // in content do not change the layout size of the RecyclerView mRecyclerView.setHasFixedSize(true); // use a linear layout manager - mLayoutManager = new RecyclerPickerLayoutManager(mRecyclerView.getContext(), mOrientation,false, + mLayoutManager = new RecyclerPickerLayoutManager(mRecyclerView.getContext(), orientation,false, mItemSelectedListener); mRecyclerView.setLayoutManager(mLayoutManager); // specify an adapter (see also next example) - mAdapter = new RecyclerPickerAdapter(mRecyclerView.getContext(), mItemLayoutResource, this); + mAdapter = new RecyclerPickerAdapter(mRecyclerView.getContext(), recyclerItemLayout, this); mRecyclerView.setAdapter(mAdapter); setRecyclerViewPadding(); } @@ -61,14 +57,14 @@ public class RecyclerPicker implements RecyclerPickerAdapter.ItemClickListener{ } public void setFirstLastElementsWidths(int first, int last){ - paddingLeft = RecyclerPickerUtils.getScreenWidth(mRecyclerView.getContext())/2 - RecyclerPickerUtils.dpToPx(mRecyclerView.getContext(), first/2); - paddingRight = RecyclerPickerUtils.getScreenWidth(mRecyclerView.getContext())/2 - RecyclerPickerUtils.dpToPx(mRecyclerView.getContext(), last/2); + paddingLeft = getScreenWidth(mRecyclerView.getContext())/2 - dpToPx(mRecyclerView.getContext(), first/2); + paddingRight = getScreenWidth(mRecyclerView.getContext())/2 - dpToPx(mRecyclerView.getContext(), last/2); updateRecyclerViewPadding(); } private void setRecyclerViewPadding() { - paddingLeft = RecyclerPickerUtils.getScreenWidth(mRecyclerView.getContext())/2; - paddingRight = RecyclerPickerUtils.getScreenWidth(mRecyclerView.getContext())/2; + paddingLeft = getScreenWidth(mRecyclerView.getContext())/2; + paddingRight = getScreenWidth(mRecyclerView.getContext())/2; updateRecyclerViewPadding(); } @@ -79,4 +75,18 @@ public class RecyclerPicker implements RecyclerPickerAdapter.ItemClickListener{ public void scrollToPosition(int position){ mLayoutManager.scrollToPositionWithOffset(position, 0); } + + private static int getScreenWidth(Context context) { + WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics dm = new DisplayMetrics(); + if (windowManager != null) { + windowManager.getDefaultDisplay().getMetrics(dm); + } + return dm.widthPixels; + } + + private static int dpToPx(Context context, int value){ + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, (float) value, + context.getResources().getDisplayMetrics()); + } } diff --git a/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerAdapter.java b/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerAdapter.java index bc68a09e9..a27025c0d 100644 --- a/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerAdapter.java +++ b/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerAdapter.java @@ -17,13 +17,13 @@ import cx.ring.R; public class RecyclerPickerAdapter extends RecyclerView.Adapter<RecyclerPickerAdapter.ItemViewHolder> { private List<Drawable> mList; - private ItemClickListener mItemClickListener; - private int mItemLayoutResource; + private final ItemClickListener mItemClickListener; + private final int mItemLayoutResource; private final LayoutInflater mInflater; public RecyclerPickerAdapter(Context ctx, @LayoutRes int recyclerItemLayout, ItemClickListener itemClickListener) { - this.mItemLayoutResource = recyclerItemLayout; - this.mItemClickListener = itemClickListener; + mItemLayoutResource = recyclerItemLayout; + mItemClickListener = itemClickListener; mInflater = LayoutInflater.from(ctx); } @@ -31,7 +31,7 @@ public class RecyclerPickerAdapter extends RecyclerView.Adapter<RecyclerPickerAd @Override public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = mInflater.inflate(mItemLayoutResource, parent, false); - view.setOnClickListener(v -> mItemClickListener.onItemClicked(v)); + view.setOnClickListener(mItemClickListener::onItemClicked); return new ItemViewHolder(view); } @@ -46,11 +46,7 @@ public class RecyclerPickerAdapter extends RecyclerView.Adapter<RecyclerPickerAd // Return the size of your dataset (invoked by the layout manager) @Override public int getItemCount() { - if(mList != null) { - return mList.size(); - } else { - return 0; - } + return mList != null ? mList.size() : 0; } public void updateData(List<Drawable> newlist) { @@ -60,8 +56,8 @@ public class RecyclerPickerAdapter extends RecyclerView.Adapter<RecyclerPickerAd public static class ItemViewHolder extends RecyclerView.ViewHolder{ - private ImageView itemImageView; - private View view; + private final ImageView itemImageView; + private final View view; public ItemViewHolder(@NonNull View itemView) { super(itemView); this.view = itemView; diff --git a/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerLayoutManager.java b/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerLayoutManager.java index c93c1ffa8..60d66b076 100644 --- a/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerLayoutManager.java +++ b/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerLayoutManager.java @@ -1,7 +1,6 @@ package cx.ring.plugins.RecyclerPicker; import android.content.Context; -import android.util.Log; import android.view.View; import androidx.recyclerview.widget.LinearLayoutManager; @@ -10,22 +9,20 @@ import androidx.recyclerview.widget.RecyclerView; public class RecyclerPickerLayoutManager extends LinearLayoutManager { private RecyclerView recyclerView; - private LinearSnapHelper snapHelper; - private ItemSelectedListener listener; + private final ItemSelectedListener listener; public RecyclerPickerLayoutManager(Context context, int orientation, boolean reverseLayout, ItemSelectedListener listener) { super(context, orientation, reverseLayout); this.listener = listener; } - @Override public void onAttachedToWindow(RecyclerView view) { super.onAttachedToWindow(view); recyclerView = view; // Smart snapping - snapHelper = new LinearSnapHelper(); + LinearSnapHelper snapHelper = new LinearSnapHelper(); snapHelper.attachToRecyclerView(recyclerView); } @@ -73,8 +70,7 @@ public class RecyclerPickerLayoutManager extends LinearLayoutManager { } private int getRecyclerViewCenterX() { - Log.i("ZZZ", "recyclerView width: " + recyclerView.getWidth() + " Right-Left: " + (recyclerView.getRight()-recyclerView.getLeft())); - return (recyclerView.getWidth())/2 + recyclerView.getLeft(); + return recyclerView.getWidth()/2 + recyclerView.getLeft(); } private void scaleDownView() { diff --git a/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerUtils.java b/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerUtils.java deleted file mode 100644 index 67671ee79..000000000 --- a/ring-android/app/src/main/java/cx/ring/plugins/RecyclerPicker/RecyclerPickerUtils.java +++ /dev/null @@ -1,23 +0,0 @@ -package cx.ring.plugins.RecyclerPicker; - -import android.content.Context; -import android.util.DisplayMetrics; -import android.util.TypedValue; -import android.view.WindowManager; - -public class RecyclerPickerUtils { - - public static int getScreenWidth(Context context) { - WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); - DisplayMetrics dm = new DisplayMetrics(); - if(windowManager != null) { - windowManager.getDefaultDisplay().getMetrics(dm); - } - return dm.widthPixels; - } - - public static int dpToPx(Context context, int value){ - return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, (float) value, - context.getResources().getDisplayMetrics()); - } -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/service/BootReceiver.java b/ring-android/app/src/main/java/cx/ring/service/BootReceiver.java index be69411d9..40cc0153b 100644 --- a/ring-android/app/src/main/java/cx/ring/service/BootReceiver.java +++ b/ring-android/app/src/main/java/cx/ring/service/BootReceiver.java @@ -28,8 +28,11 @@ import androidx.core.content.ContextCompat; import javax.inject.Inject; import cx.ring.application.JamiApplication; +import dagger.hilt.android.AndroidEntryPoint; + import net.jami.services.PreferencesService; +@AndroidEntryPoint public class BootReceiver extends BroadcastReceiver { private static final String TAG = BootReceiver.class.getSimpleName(); @@ -48,7 +51,7 @@ public class BootReceiver extends BroadcastReceiver { Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) { try { - ((JamiApplication) context.getApplicationContext()).getInjectionComponent().inject(this); + //((JamiApplication) context.getApplicationContext()).getInjectionComponent().inject(this); if (mPreferencesService.getSettings().isAllowOnStartup()) { try { ContextCompat.startForegroundService(context, new Intent(SyncService.ACTION_START) diff --git a/ring-android/app/src/main/java/cx/ring/service/CallNotificationService.java b/ring-android/app/src/main/java/cx/ring/service/CallNotificationService.java index f78049766..522754d08 100644 --- a/ring-android/app/src/main/java/cx/ring/service/CallNotificationService.java +++ b/ring-android/app/src/main/java/cx/ring/service/CallNotificationService.java @@ -31,11 +31,12 @@ import androidx.annotation.Nullable; import javax.inject.Inject; -import cx.ring.application.JamiApplication; import cx.ring.services.NotificationServiceImpl; +import dagger.hilt.android.AndroidEntryPoint; import net.jami.services.NotificationService; +@AndroidEntryPoint public class CallNotificationService extends Service { public static final String ACTION_START = "START"; public static final String ACTION_STOP = "STOP"; @@ -43,12 +44,6 @@ public class CallNotificationService extends Service { @Inject NotificationService mNotificationService; - @Override - public void onCreate() { - ((JamiApplication) getApplication()).getInjectionComponent().inject(this); - super.onCreate(); - } - @Override public int onStartCommand(Intent intent, int flags, int startId) { super.onStartCommand(intent, flags, startId); diff --git a/ring-android/app/src/main/java/cx/ring/service/DRingService.java b/ring-android/app/src/main/java/cx/ring/service/DRingService.java deleted file mode 100644 index 7961014f7..000000000 --- a/ring-android/app/src/main/java/cx/ring/service/DRingService.java +++ /dev/null @@ -1,799 +0,0 @@ -/* - * Copyright (C) 2010-2012 Regis Montoya (aka r3gis - www.r3gis.fr) - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Regis Montoya <r3gis.3R@gmail.com> - * Author: Emeric Vigier <emeric.vigier@savoirfairelinux.com> - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.service; - -import android.app.Notification; -import android.app.Service; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.database.ContentObserver; -import android.net.ConnectivityManager; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.os.PowerManager; -import android.os.RemoteException; -import android.provider.ContactsContract; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.core.app.RemoteInput; -import androidx.legacy.content.WakefulBroadcastReceiver; - -import java.io.File; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import cx.ring.BuildConfig; -import cx.ring.application.JamiApplication; -import cx.ring.client.CallActivity; -import cx.ring.client.ConversationActivity; -import net.jami.facades.ConversationFacade; -import net.jami.model.Codec; -import net.jami.model.Settings; -import net.jami.model.Uri; -import net.jami.services.AccountService; -import net.jami.services.CallService; -import net.jami.services.ContactService; -import net.jami.services.DaemonService; -import net.jami.services.DeviceRuntimeService; -import net.jami.services.HardwareService; -import net.jami.services.HistoryService; -import net.jami.services.NotificationService; -import net.jami.services.PreferencesService; -import cx.ring.tv.call.TVCallActivity; -import cx.ring.utils.ConversationPath; -import cx.ring.utils.DeviceUtils; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class DRingService extends Service { - private static final String TAG = DRingService.class.getSimpleName(); - - public static final String ACTION_TRUST_REQUEST_ACCEPT = BuildConfig.APPLICATION_ID + ".action.TRUST_REQUEST_ACCEPT"; - public static final String ACTION_TRUST_REQUEST_REFUSE = BuildConfig.APPLICATION_ID + ".action.TRUST_REQUEST_REFUSE"; - public static final String ACTION_TRUST_REQUEST_BLOCK = BuildConfig.APPLICATION_ID + ".action.TRUST_REQUEST_BLOCK"; - - static public final String ACTION_CALL_ACCEPT = BuildConfig.APPLICATION_ID + ".action.CALL_ACCEPT"; - static public final String ACTION_CALL_HOLD_ACCEPT = BuildConfig.APPLICATION_ID + ".action.CALL_HOLD_ACCEPT"; - static public final String ACTION_CALL_END_ACCEPT = BuildConfig.APPLICATION_ID + ".action.CALL_END_ACCEPT"; - static public final String ACTION_CALL_REFUSE = BuildConfig.APPLICATION_ID + ".action.CALL_REFUSE"; - static public final String ACTION_CALL_END = BuildConfig.APPLICATION_ID + ".action.CALL_END"; - static public final String ACTION_CALL_VIEW = BuildConfig.APPLICATION_ID + ".action.CALL_VIEW"; - - static public final String ACTION_CONV_READ = BuildConfig.APPLICATION_ID + ".action.CONV_READ"; - static public final String ACTION_CONV_DISMISS = BuildConfig.APPLICATION_ID + ".action.CONV_DISMISS"; - static public final String ACTION_CONV_ACCEPT = BuildConfig.APPLICATION_ID + ".action.CONV_ACCEPT"; - static public final String ACTION_CONV_REPLY_INLINE = BuildConfig.APPLICATION_ID + ".action.CONV_REPLY"; - - static public final String ACTION_FILE_ACCEPT = BuildConfig.APPLICATION_ID + ".action.FILE_ACCEPT"; - static public final String ACTION_FILE_CANCEL = BuildConfig.APPLICATION_ID + ".action.FILE_CANCEL"; - static public final String KEY_MESSAGE_ID = "messageId"; - static public final String KEY_TRANSFER_ID = "transferId"; - static public final String KEY_TEXT_REPLY = "textReply"; - - private static final int NOTIFICATION_ID = 1; - - private final ContactsContentObserver contactContentObserver = new ContactsContentObserver(); - @Inject - @Singleton - protected DaemonService mDaemonService; - @Inject - @Singleton - protected CallService mCallService; - @Inject - @Singleton - protected AccountService mAccountService; - @Inject - @Singleton - protected HardwareService mHardwareService; - @Inject - @Singleton - protected HistoryService mHistoryService; - @Inject - @Singleton - protected DeviceRuntimeService mDeviceRuntimeService; - @Inject - @Singleton - protected NotificationService mNotificationService; - @Inject - @Singleton - protected ContactService mContactService; - @Inject - @Singleton - protected PreferencesService mPreferencesService; - @Inject - @Singleton - protected ConversationFacade mConversationFacade; - - private final Handler mHandler = new Handler(); - private final CompositeDisposable mDisposableBag = new CompositeDisposable(); - private final Runnable mConnectivityChecker = this::updateConnectivityState; - public static boolean isRunning = false; - - protected final IDRingService.Stub mBinder = new IDRingService.Stub() { - - @Override - public String placeCall(final String account, final String number, final boolean video) { - return mConversationFacade.placeCall(account, Uri.fromString(number), video).blockingGet().getDaemonIdString(); - } - - @Override - public void refuse(final String callID) { - mCallService.refuse(callID); - } - - @Override - public void accept(final String callID) { - mCallService.accept(callID); - } - - @Override - public void hangUp(final String callID) { - mCallService.hangUp(callID); - } - - @Override - public void hold(final String callID) { - mCallService.hold(callID); - } - - @Override - public void unhold(final String callID) { - mCallService.unhold(callID); - } - - public void sendProfile(final String callId, final String accountId) { - mAccountService.sendProfile(callId, accountId); - } - - @Override - public boolean isStarted() throws RemoteException { - return mDaemonService.isStarted(); - } - - @Override - public Map<String, String> getCallDetails(final String callID) throws RemoteException { - return mCallService.getCallDetails(callID); - } - - @Override - public void setAudioPlugin(final String audioPlugin) { - mCallService.setAudioPlugin(audioPlugin); - } - - @Override - public String getCurrentAudioOutputPlugin() { - return mCallService.getCurrentAudioOutputPlugin(); - } - - @Override - public List<String> getAccountList() { - return mAccountService.getAccountList().blockingGet(); - } - - @Override - public void setAccountOrder(final String order) { - String[] accountIds = order.split(File.separator); - mAccountService.setAccountOrder(Arrays.asList(accountIds)); - } - - @Override - public Map<String, String> getAccountDetails(final String accountID) { - return mAccountService.getAccountDetails(accountID); - } - - @SuppressWarnings("unchecked") - // Hashmap runtime cast - @Override - public void setAccountDetails(final String accountId, final Map map) { - mAccountService.setAccountDetails(accountId, map); - } - - @Override - public void setAccountActive(final String accountId, final boolean active) { - mAccountService.setAccountActive(accountId, active); - } - - @Override - public void setAccountsActive(final boolean active) { - mAccountService.setAccountsActive(active); - } - - @Override - public Map<String, String> getVolatileAccountDetails(final String accountId) { - return mAccountService.getVolatileAccountDetails(accountId); - } - - @Override - public Map<String, String> getAccountTemplate(final String accountType) throws RemoteException { - return mAccountService.getAccountTemplate(accountType).blockingGet(); - } - - @SuppressWarnings("unchecked") - // Hashmap runtime cast - @Override - public String addAccount(final Map map) { - return mAccountService.addAccount((Map<String, String>) map).blockingFirst().getAccountID(); - } - - @Override - public void removeAccount(final String accountId) { - mAccountService.removeAccount(accountId); - } - - @Override - public void exportOnRing(final String accountId, final String password) { - mAccountService.exportOnRing(accountId, password); - } - - public Map<String, String> getKnownRingDevices(final String accountId) { - return mAccountService.getKnownRingDevices(accountId); - } - - /************************* - * Transfer related API - *************************/ - - @Override - public void transfer(final String callID, final String to) throws RemoteException { - mCallService.transfer(callID, to); - } - - @Override - public void attendedTransfer(final String transferID, final String targetID) throws RemoteException { - mCallService.attendedTransfer(transferID, targetID); - } - - /************************* - * Conference related API - *************************/ - - @Override - public void removeConference(final String confID) throws RemoteException { - mCallService.removeConference(confID); - } - - @Override - public void joinParticipant(final String selCallID, final String dragCallID) throws RemoteException { - mCallService.joinParticipant(selCallID, dragCallID); - } - - @Override - public void addParticipant(final String callID, final String confID) throws RemoteException { - mCallService.addParticipant(callID, confID); - } - - @Override - public void addMainParticipant(final String confID) throws RemoteException { - mCallService.addMainParticipant(confID); - } - - @Override - public void detachParticipant(final String callID) throws RemoteException { - mCallService.detachParticipant(callID); - } - - @Override - public void joinConference(final String selConfID, final String dragConfID) throws RemoteException { - mCallService.joinConference(selConfID, dragConfID); - } - - @Override - public void hangUpConference(final String confID) throws RemoteException { - mCallService.hangUpConference(confID); - } - - @Override - public void holdConference(final String confID) throws RemoteException { - mCallService.holdConference(confID); - } - - @Override - public void unholdConference(final String confID) throws RemoteException { - mCallService.unholdConference(confID); - } - - @Override - public boolean isConferenceParticipant(final String callID) throws RemoteException { - return mCallService.isConferenceParticipant(callID); - } - - @Override - public Map<String, ArrayList<String>> getConferenceList() throws RemoteException { - return mCallService.getConferenceList(); - } - - @Override - public List<String> getParticipantList(final String confID) throws RemoteException { - return mCallService.getParticipantList(confID); - } - - @Override - public String getConferenceId(String callID) throws RemoteException { - return mCallService.getConferenceId(callID); - } - - @Override - public String getConferenceDetails(final String callID) throws RemoteException { - return mCallService.getConferenceState(callID); - } - - @Override - public String getRecordPath() throws RemoteException { - return mCallService.getRecordPath(); - } - - @Override - public void setRecordPath(final String path) throws RemoteException { - mCallService.setRecordPath(path); - } - - @Override - public boolean toggleRecordingCall(final String id) throws RemoteException { - return mCallService.toggleRecordingCall(id); - } - - @Override - public boolean startRecordedFilePlayback(final String filepath) throws RemoteException { - return mCallService.startRecordedFilePlayback(filepath); - } - - @Override - public void stopRecordedFilePlayback(final String filepath) throws RemoteException { - mCallService.stopRecordedFilePlayback(); - } - - @Override - public void sendTextMessage(final String callID, final String msg) throws RemoteException { - mCallService.sendTextMessage(callID, msg); - } - - @Override - public long sendAccountTextMessage(final String accountID, final String to, final String msg) { - return mCallService.sendAccountTextMessage(accountID, to, msg).blockingGet(); - } - - @Override - public List<Codec> getCodecList(final String accountID) throws RemoteException { - return mAccountService.getCodecList(accountID).blockingGet(); - } - - @Override - public Map<String, String> validateCertificatePath(final String accountID, final String certificatePath, final String privateKeyPath, final String privateKeyPass) throws RemoteException { - return mAccountService.validateCertificatePath(accountID, certificatePath, privateKeyPath, privateKeyPass); - } - - @Override - public Map<String, String> validateCertificate(final String accountID, final String certificate) throws RemoteException { - return mAccountService.validateCertificate(accountID, certificate); - } - - @Override - public Map<String, String> getCertificateDetailsPath(final String certificatePath) throws RemoteException { - return mAccountService.getCertificateDetailsPath(certificatePath); - } - - @Override - public Map<String, String> getCertificateDetails(final String certificateRaw) throws RemoteException { - return mAccountService.getCertificateDetails(certificateRaw); - } - - @Override - public void setActiveCodecList(final List codecs, final String accountID) throws RemoteException { - mAccountService.setActiveCodecList(accountID, codecs); - } - - @Override - public void playDtmf(final String key) throws RemoteException { - - } - - @Override - public Map<String, String> getConference(final String id) throws RemoteException { - return mCallService.getConferenceDetails(id); - } - - @Override - public void setMuted(final boolean mute) throws RemoteException { - mCallService.setMuted(mute); - } - - @Override - public boolean isCaptureMuted() throws RemoteException { - return mCallService.isCaptureMuted(); - } - - @Override - public List<String> getTlsSupportedMethods() { - return mAccountService.getTlsSupportedMethods(); - } - - @Override - public List getCredentials(final String accountID) throws RemoteException { - return mAccountService.getCredentials(accountID); - } - - @Override - public void setCredentials(final String accountID, final List creds) throws RemoteException { - mAccountService.setCredentials(accountID, creds); - } - - @Override - public void registerAllAccounts() throws RemoteException { - mAccountService.registerAllAccounts(); - } - - @Override - @Deprecated - public void videoSurfaceAdded(String id) { - - } - - @Override - @Deprecated - public void videoSurfaceRemoved(String id) { - - } - - @Override - @Deprecated - public void videoPreviewSurfaceAdded() { - - } - - @Override - @Deprecated - public void videoPreviewSurfaceRemoved() { - - } - - @Override - @Deprecated - public void switchInput(final String id, final boolean front) { - } - - @Override - @Deprecated - public void setPreviewSettings() { - - } - - @Override - public void connectivityChanged() { - mHardwareService.connectivityChanged(mPreferencesService.hasNetworkConnected()); - } - - @Override - public void lookupName(final String account, final String nameserver, final String name) { - mAccountService.lookupName(account, nameserver, name); - } - - @Override - public void lookupAddress(final String account, final String nameserver, final String address) { - mAccountService.lookupAddress(account, nameserver, address); - } - - @Override - public void registerName(final String account, final String password, final String name) { - mAccountService.registerName(account, password, name); - } - }; - - private final BroadcastReceiver receiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - if (action == null) { - Log.w(TAG, "onReceive: received a null action on broadcast receiver"); - return; - } - Log.d(TAG, "receiver.onReceive: " + action); - switch (action) { - case ConnectivityManager.CONNECTIVITY_ACTION: { - updateConnectivityState(); - break; - } - case PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED: { - mConnectivityChecker.run(); - mHandler.postDelayed(mConnectivityChecker, 100); - } - } - } - }; - @Override - public void onCreate() { - Log.i(TAG, "onCreated"); - super.onCreate(); - - // dependency injection - JamiApplication.getInstance().getInjectionComponent().inject(this); - isRunning = true; - - if (mDeviceRuntimeService.hasContactPermission()) { - getContentResolver().registerContentObserver(ContactsContract.Contacts.CONTENT_URI, true, contactContentObserver); - } - - IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - intentFilter.addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED); - } - registerReceiver(receiver, intentFilter); - updateConnectivityState(); - - mDisposableBag.add(mPreferencesService.getSettingsSubject().subscribe(s -> { - showSystemNotification(s); - })); - - JamiApplication.getInstance().bindDaemon(); - JamiApplication.getInstance().bootstrapDaemon(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - Log.i(TAG, "onDestroy()"); - unregisterReceiver(receiver); - getContentResolver().unregisterContentObserver(contactContentObserver); - - mHardwareService.unregisterCameraDetectionCallback(); - mDisposableBag.clear(); - isRunning = false; - } - - private void showSystemNotification(Settings settings) { - if (settings.isAllowPersistentNotification()) { - startForeground(NOTIFICATION_ID, (Notification) mNotificationService.getServiceNotification()); - } else { - stopForeground(true); - } - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - // Log.i(TAG, "onStartCommand " + (intent == null ? "null" : intent.getAction()) + " " + flags + " " + startId); - if (intent != null) { - parseIntent(intent); - WakefulBroadcastReceiver.completeWakefulIntent(intent); - } - return START_STICKY; /* started and stopped explicitly */ - } - - @Override - public IBinder onBind(Intent arg0) { - Log.i(TAG, "onBound"); - return mBinder; - } - - /* ************************************ - * - * Implement public interface for the service - * - * ********************************* - */ - - private void updateConnectivityState() { - if (mDaemonService.isStarted()) { - boolean isConnected = mPreferencesService.hasNetworkConnected(); - mAccountService.setAccountsActive(isConnected); - // Execute connectivityChanged to reload UPnP - // and reconnect active accounts if necessary. - mHardwareService.connectivityChanged(isConnected); - } - } - - private void parseIntent(@NonNull Intent intent) { - String action = intent.getAction(); - if (action == null) { - return; - } - Bundle extras = intent.getExtras(); - switch (action) { - case ACTION_TRUST_REQUEST_ACCEPT: - case ACTION_TRUST_REQUEST_REFUSE: - case ACTION_TRUST_REQUEST_BLOCK: - handleTrustRequestAction(intent.getData(), action); - break; - case ACTION_CALL_ACCEPT: - case ACTION_CALL_HOLD_ACCEPT: - case ACTION_CALL_END_ACCEPT: - case ACTION_CALL_REFUSE: - case ACTION_CALL_END: - case ACTION_CALL_VIEW: - if (extras != null) { - handleCallAction(action, extras); - } - break; - case ACTION_CONV_READ: - case ACTION_CONV_ACCEPT: - case ACTION_CONV_DISMISS: - case ACTION_CONV_REPLY_INLINE: - handleConvAction(intent, action); - break; - case ACTION_FILE_ACCEPT: - case ACTION_FILE_CANCEL: - if (extras != null) { - handleFileAction(intent.getData(), action, extras); - } - break; - default: - break; - } - } - - private void handleFileAction(android.net.Uri uri, String action, Bundle extras) { - String messageId = extras.getString(KEY_MESSAGE_ID); - String id = extras.getString(KEY_TRANSFER_ID); - ConversationPath path = ConversationPath.fromUri(uri); - if (action.equals(ACTION_FILE_ACCEPT)) { - mNotificationService.removeTransferNotification(path.getAccountId(), path.getConversationUri(), id); - mAccountService.acceptFileTransfer(path.getAccountId(), path.getConversationUri(), messageId, id); - } else if (action.equals(ACTION_FILE_CANCEL)) { - mConversationFacade.cancelFileTransfer(path.getAccountId(), path.getConversationUri(), messageId, id); - } - } - - private void handleTrustRequestAction(android.net.Uri uri, String action) { - ConversationPath path = ConversationPath.fromUri(uri); - if (path != null) { - mNotificationService.cancelTrustRequestNotification(path.getAccountId()); - switch (action) { - case ACTION_TRUST_REQUEST_ACCEPT: - mConversationFacade.acceptRequest(path.getAccountId(), path.getConversationUri()); - break; - case ACTION_TRUST_REQUEST_REFUSE: - mConversationFacade.discardRequest(path.getAccountId(), path.getConversationUri()); - break; - case ACTION_TRUST_REQUEST_BLOCK: - mConversationFacade.discardRequest(path.getAccountId(), path.getConversationUri()); - mAccountService.removeContact(path.getAccountId(), path.getConversationUri().getRawRingId(), true); - break; - } - } - } - - private void handleCallAction(String action, Bundle extras) { - String callId = extras.getString(NotificationService.KEY_CALL_ID); - - if (callId == null || callId.isEmpty()) { - return; - } - - switch (action) { - case ACTION_CALL_ACCEPT: - mNotificationService.cancelCallNotification(); - startActivity(new Intent(ACTION_CALL_ACCEPT) - .putExtras(extras) - .setClass(getApplicationContext(), CallActivity.class) - .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK)); - break; - case ACTION_CALL_HOLD_ACCEPT: - String holdId = extras.getString(NotificationService.KEY_HOLD_ID); - mNotificationService.cancelCallNotification(); - mCallService.hold(holdId); - startActivity(new Intent(ACTION_CALL_ACCEPT) - .putExtras(extras) - .setClass(getApplicationContext(), CallActivity.class) - .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK)); - break; - case ACTION_CALL_END_ACCEPT: - String endId = extras.getString(NotificationService.KEY_END_ID); - mNotificationService.cancelCallNotification(); - mCallService.hangUp(endId); - startActivity(new Intent(ACTION_CALL_ACCEPT) - .putExtras(extras) - .setClass(getApplicationContext(), CallActivity.class) - .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK)); - break; - case ACTION_CALL_REFUSE: - mCallService.refuse(callId); - mHardwareService.closeAudioState(); - break; - case ACTION_CALL_END: - mCallService.hangUp(callId); - mHardwareService.closeAudioState(); - break; - case ACTION_CALL_VIEW: - mNotificationService.cancelCallNotification(); - if (DeviceUtils.isTv(this)) { - startActivity(new Intent(Intent.ACTION_VIEW) - .putExtras(extras) - .setClass(getApplicationContext(), TVCallActivity.class) - .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK)); - - } else { - startActivity(new Intent(Intent.ACTION_VIEW) - .putExtras(extras) - .setClass(getApplicationContext(), CallActivity.class) - .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK)); - } - break; - } - } - - private void handleConvAction(Intent intent, String action) { - ConversationPath path = ConversationPath.fromIntent(intent); - if (path == null || path.getConversationId().isEmpty()) { - return; - } - - switch (action) { - case ACTION_CONV_READ: - mConversationFacade.readMessages(path.getAccountId(), path.getConversationUri()); - break; - case ACTION_CONV_DISMISS: - break; - case ACTION_CONV_REPLY_INLINE: { - Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); - if (remoteInput != null) { - CharSequence reply = remoteInput.getCharSequence(KEY_TEXT_REPLY); - if (!TextUtils.isEmpty(reply)) { - Uri uri = path.getConversationUri(); - String message = reply.toString(); - mConversationFacade.startConversation(path.getAccountId(), uri) - .flatMapCompletable(c -> mConversationFacade.sendTextMessage(c, uri, message) - .doOnComplete(() -> mNotificationService.showTextNotification(path.getAccountId(), c))) - .subscribe(); - } - } - break; - } - case ACTION_CONV_ACCEPT: - startActivity(new Intent(Intent.ACTION_VIEW, path.toUri(), getApplicationContext(), ConversationActivity.class) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - break; - default: - break; - } - } - - public void refreshContacts() { - if (mAccountService.getCurrentAccount() == null) { - return; - } - mContactService.loadContacts(mAccountService.hasRingAccount(), mAccountService.hasSipAccount(), mAccountService.getCurrentAccount()); - } - - private static class ContactsContentObserver extends ContentObserver { - - ContactsContentObserver() { - super(null); - } - - @Override - public void onChange(boolean selfChange, android.net.Uri uri) { - super.onChange(selfChange, uri); - //mContactService.loadContacts(mAccountService.hasRingAccount(), mAccountService.hasSipAccount(), mAccountService.getCurrentAccount()); - } - } -} diff --git a/ring-android/app/src/main/java/cx/ring/service/DRingService.kt b/ring-android/app/src/main/java/cx/ring/service/DRingService.kt new file mode 100644 index 000000000..46afd2f13 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/service/DRingService.kt @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2010-2012 Regis Montoya (aka r3gis - www.r3gis.fr) + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Regis Montoya <r3gis.3R@gmail.com> + * Author: Emeric Vigier <emeric.vigier@savoirfairelinux.com> + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.service + +import android.app.Notification +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.database.ContentObserver +import android.net.ConnectivityManager +import android.net.Uri +import android.os.* +import android.provider.ContactsContract +import android.text.TextUtils +import android.util.Log +import androidx.core.app.RemoteInput +import androidx.legacy.content.WakefulBroadcastReceiver +import cx.ring.BuildConfig +import cx.ring.application.JamiApplication +import cx.ring.client.CallActivity +import cx.ring.client.ConversationActivity +import cx.ring.tv.call.TVCallActivity +import cx.ring.utils.ConversationPath.Companion.fromIntent +import cx.ring.utils.ConversationPath.Companion.fromUri +import cx.ring.utils.DeviceUtils.isTv +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.services.ConversationFacade +import net.jami.model.Conversation +import net.jami.model.Settings +import net.jami.services.* +import javax.inject.Inject +import javax.inject.Singleton + +@AndroidEntryPoint +class DRingService : Service() { + private val contactContentObserver = ContactsContentObserver() + + @Inject + @Singleton + lateinit var mDaemonService: DaemonService + + @Inject + @Singleton + lateinit var mCallService: CallService + + @Inject + @Singleton + lateinit var mAccountService: AccountService + + @Inject + @Singleton + lateinit var mHardwareService: HardwareService + + @Inject + @Singleton + lateinit var mHistoryService: HistoryService + + @Inject + @Singleton + lateinit var mDeviceRuntimeService: DeviceRuntimeService + + @Inject + @Singleton + lateinit var mNotificationService: NotificationService + + @Inject + @Singleton + lateinit var mContactService: ContactService + + @Inject + @Singleton + lateinit var mPreferencesService: PreferencesService + + @Inject + @Singleton + lateinit var mConversationFacade: ConversationFacade + + private val mHandler = Handler(Looper.myLooper()!!) + private val mDisposableBag = CompositeDisposable() + private val mConnectivityChecker = Runnable { updateConnectivityState() } + + private val receiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + if (action == null) { + Log.w(TAG, "onReceive: received a null action on broadcast receiver") + return + } + Log.d(TAG, "receiver.onReceive: $action") + when (action) { + ConnectivityManager.CONNECTIVITY_ACTION -> { + updateConnectivityState() + } + PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> { + mConnectivityChecker.run() + mHandler.postDelayed(mConnectivityChecker, 100) + } + } + } + } + + override fun onCreate() { + super.onCreate() + Log.i(TAG, "onCreate") + isRunning = true + if (mDeviceRuntimeService.hasContactPermission()) { + contentResolver.registerContentObserver(ContactsContract.Contacts.CONTENT_URI, true, contactContentObserver) + } + val intentFilter = IntentFilter() + intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + intentFilter.addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) + } + registerReceiver(receiver, intentFilter) + updateConnectivityState() + mDisposableBag.add(mPreferencesService.settingsSubject.subscribe { settings: Settings -> + showSystemNotification(settings) + }) + JamiApplication.instance!!.apply { + bindDaemon() + bootstrapDaemon() + } + } + + override fun onDestroy() { + super.onDestroy() + Log.i(TAG, "onDestroy()") + unregisterReceiver(receiver) + contentResolver.unregisterContentObserver(contactContentObserver) + mHardwareService.unregisterCameraDetectionCallback() + mDisposableBag.clear() + isRunning = false + } + + private fun showSystemNotification(settings: Settings) { + if (settings.isAllowPersistentNotification) { + startForeground(NOTIFICATION_ID, mNotificationService.serviceNotification as Notification) + } else { + stopForeground(true) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Log.i(TAG, "onStartCommand " + (intent == null ? "null" : intent.getAction()) + " " + flags + " " + startId); + if (intent != null) { + parseIntent(intent) + WakefulBroadcastReceiver.completeWakefulIntent(intent) + } + return START_STICKY /* started and stopped explicitly */ + } + + private val binder: IBinder = Binder() + override fun onBind(intent: Intent): IBinder? { + return binder + } + + /* ************************************ + * + * Implement public interface for the service + * + * ********************************* + */ + private fun updateConnectivityState() { + if (mDaemonService.isStarted) { + val isConnected = mPreferencesService.hasNetworkConnected() + mAccountService.setAccountsActive(isConnected) + // Execute connectivityChanged to reload UPnP + // and reconnect active accounts if necessary. + mHardwareService.connectivityChanged(isConnected) + } + } + + private fun parseIntent(intent: Intent) { + val action = intent.action ?: return + val extras = intent.extras + when (action) { + ACTION_TRUST_REQUEST_ACCEPT, ACTION_TRUST_REQUEST_REFUSE, ACTION_TRUST_REQUEST_BLOCK -> + handleTrustRequestAction(intent.data, action) + ACTION_CALL_ACCEPT, ACTION_CALL_HOLD_ACCEPT, ACTION_CALL_END_ACCEPT, ACTION_CALL_REFUSE, ACTION_CALL_END, ACTION_CALL_VIEW -> extras?.let { + handleCallAction(action, it) + } + ACTION_CONV_READ, ACTION_CONV_ACCEPT, ACTION_CONV_DISMISS, ACTION_CONV_REPLY_INLINE -> + handleConvAction(intent, action) + ACTION_FILE_ACCEPT, ACTION_FILE_CANCEL -> extras?.let { + handleFileAction(intent.data, action, it) + } + } + } + + private fun handleFileAction(uri: Uri?, action: String, extras: Bundle) { + Log.w(TAG, "handleFileAction $extras") + val messageId = extras.getString(KEY_MESSAGE_ID) + val id = extras.getString(KEY_TRANSFER_ID)!! + val path = fromUri(uri)!! + if (action == ACTION_FILE_ACCEPT) { + mNotificationService.removeTransferNotification(path.accountId, path.conversationUri, id) + mAccountService.acceptFileTransfer(path.accountId, path.conversationUri, messageId, id) + } else if (action == ACTION_FILE_CANCEL) { + mConversationFacade.cancelFileTransfer(path.accountId, path.conversationUri, messageId, id) + } + } + + private fun handleTrustRequestAction(uri: Uri?, action: String) { + fromUri(uri)?.let { path -> + mNotificationService.cancelTrustRequestNotification(path.accountId) + when (action) { + ACTION_TRUST_REQUEST_ACCEPT -> mConversationFacade.acceptRequest(path.accountId, path.conversationUri) + ACTION_TRUST_REQUEST_REFUSE -> mConversationFacade.discardRequest(path.accountId, path.conversationUri) + ACTION_TRUST_REQUEST_BLOCK -> { + mConversationFacade.discardRequest(path.accountId, path.conversationUri) + mAccountService.removeContact(path.accountId, path.conversationUri.rawRingId, true) + } + } + } + } + + private fun handleCallAction(action: String, extras: Bundle) { + val callId = extras.getString(NotificationService.KEY_CALL_ID) + if (callId == null || callId.isEmpty()) { + return + } + when (action) { + ACTION_CALL_ACCEPT -> { + mNotificationService.cancelCallNotification() + startActivity(Intent(ACTION_CALL_ACCEPT) + .putExtras(extras) + .setClass(applicationContext, CallActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)) + } + ACTION_CALL_HOLD_ACCEPT -> { + val holdId = extras.getString(NotificationService.KEY_HOLD_ID)!! + mNotificationService.cancelCallNotification() + mCallService.hold(holdId) + startActivity(Intent(ACTION_CALL_ACCEPT) + .putExtras(extras) + .setClass(applicationContext, CallActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)) + } + ACTION_CALL_END_ACCEPT -> { + val endId = extras.getString(NotificationService.KEY_END_ID)!! + mNotificationService.cancelCallNotification() + mCallService.hangUp(endId) + startActivity(Intent(ACTION_CALL_ACCEPT) + .putExtras(extras) + .setClass(applicationContext, CallActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)) + } + ACTION_CALL_REFUSE -> { + mCallService.refuse(callId) + mHardwareService.closeAudioState() + } + ACTION_CALL_END -> { + mCallService.hangUp(callId) + mHardwareService.closeAudioState() + } + ACTION_CALL_VIEW -> { + mNotificationService.cancelCallNotification() + if (isTv(this)) { + startActivity( + Intent(Intent.ACTION_VIEW) + .putExtras(extras) + .setClass(applicationContext, TVCallActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } else { + startActivity( + Intent(Intent.ACTION_VIEW) + .putExtras(extras) + .setClass(applicationContext, CallActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } + } + } + + private fun handleConvAction(intent: Intent, action: String) { + val path = fromIntent(intent) + if (path == null || path.conversationId.isEmpty()) { + return + } + when (action) { + ACTION_CONV_READ -> mConversationFacade.readMessages(path.accountId, path.conversationUri) + ACTION_CONV_DISMISS -> { + } + ACTION_CONV_REPLY_INLINE -> { + val remoteInput = RemoteInput.getResultsFromIntent(intent) + if (remoteInput != null) { + val reply = remoteInput.getCharSequence(KEY_TEXT_REPLY) + if (!TextUtils.isEmpty(reply)) { + val uri = path.conversationUri + val message = reply.toString() + mConversationFacade.startConversation(path.accountId, uri) + .flatMapCompletable { c: Conversation -> + mConversationFacade.sendTextMessage(c, uri, message) + .doOnComplete { mNotificationService.showTextNotification(path.accountId, c)} + } + .subscribe() + } + } + } + ACTION_CONV_ACCEPT -> startActivity(Intent(Intent.ACTION_VIEW, path.toUri(), applicationContext, ConversationActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + else -> { + } + } + } + + fun refreshContacts() { + if (mAccountService.currentAccount == null) { + return + } + mContactService.loadContacts( + mAccountService.hasRingAccount(), + mAccountService.hasSipAccount(), + mAccountService.currentAccount + ) + } + + private class ContactsContentObserver internal constructor() : ContentObserver(null) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + super.onChange(selfChange, uri) + //mContactService.loadContacts(mAccountService.hasRingAccount(), mAccountService.hasSipAccount(), mAccountService.getCurrentAccount()); + } + } + + companion object { + private val TAG = DRingService::class.java.simpleName + const val ACTION_TRUST_REQUEST_ACCEPT = BuildConfig.APPLICATION_ID + ".action.TRUST_REQUEST_ACCEPT" + const val ACTION_TRUST_REQUEST_REFUSE = BuildConfig.APPLICATION_ID + ".action.TRUST_REQUEST_REFUSE" + const val ACTION_TRUST_REQUEST_BLOCK = BuildConfig.APPLICATION_ID + ".action.TRUST_REQUEST_BLOCK" + const val ACTION_CALL_ACCEPT = BuildConfig.APPLICATION_ID + ".action.CALL_ACCEPT" + const val ACTION_CALL_HOLD_ACCEPT = BuildConfig.APPLICATION_ID + ".action.CALL_HOLD_ACCEPT" + const val ACTION_CALL_END_ACCEPT = BuildConfig.APPLICATION_ID + ".action.CALL_END_ACCEPT" + const val ACTION_CALL_REFUSE = BuildConfig.APPLICATION_ID + ".action.CALL_REFUSE" + const val ACTION_CALL_END = BuildConfig.APPLICATION_ID + ".action.CALL_END" + const val ACTION_CALL_VIEW = BuildConfig.APPLICATION_ID + ".action.CALL_VIEW" + const val ACTION_CONV_READ = BuildConfig.APPLICATION_ID + ".action.CONV_READ" + const val ACTION_CONV_DISMISS = BuildConfig.APPLICATION_ID + ".action.CONV_DISMISS" + const val ACTION_CONV_ACCEPT = BuildConfig.APPLICATION_ID + ".action.CONV_ACCEPT" + const val ACTION_CONV_REPLY_INLINE = BuildConfig.APPLICATION_ID + ".action.CONV_REPLY" + const val ACTION_FILE_ACCEPT = BuildConfig.APPLICATION_ID + ".action.FILE_ACCEPT" + const val ACTION_FILE_CANCEL = BuildConfig.APPLICATION_ID + ".action.FILE_CANCEL" + const val KEY_MESSAGE_ID = "messageId" + const val KEY_TRANSFER_ID = "transferId" + const val KEY_TEXT_REPLY = "textReply" + private const val NOTIFICATION_ID = 1 + var isRunning = false + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/service/IDRingService.aidl b/ring-android/app/src/main/java/cx/ring/service/IDRingService.aidl deleted file mode 100644 index 80ceb957b..000000000 --- a/ring-android/app/src/main/java/cx/ring/service/IDRingService.aidl +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ - -package cx.ring.service; - -interface IDRingService { - - boolean isStarted(); - - Map getCallDetails(in String callID); - String placeCall(in String account, in String number, in boolean hasVideo); - void refuse(in String callID); - void accept(in String callID); - void hangUp(in String callID); - void hold(in String callID); - void unhold(in String callID); - - void lookupName(in String account, in String nameserver, in String name); - void lookupAddress(in String account, in String nameserver, in String address); - void registerName(in String account, in String password, in String name); - - List getAccountList(); - String addAccount(in Map accountDetails); - void removeAccount(in String accoundId); - void setAccountOrder(in String order); - Map getAccountDetails(in String accountID); - Map getVolatileAccountDetails(in String accountID); - Map getAccountTemplate(in String accountType); - void registerAllAccounts(); - void setAccountDetails(in String accountId, in Map accountDetails); - void setAccountActive(in String accountId, in boolean active); - void setAccountsActive(in boolean active); - List getCredentials(in String accountID); - void setCredentials(in String accountID, in List creds); - void setAudioPlugin(in String callID); - String getCurrentAudioOutputPlugin(); - List getCodecList(in String accountID); - void setActiveCodecList(in List codecs, in String accountID); - void exportOnRing(in String accountID, in String password); - Map getKnownRingDevices(in String accountID); - - Map validateCertificatePath(in String accountID, in String certificatePath, in String privateKeyPath, in String privateKeyPass); - Map validateCertificate(in String accountID, in String certificateId); - Map getCertificateDetailsPath(in String certificatePath); - Map getCertificateDetails(in String certificate); - - /* Recording */ - void setRecordPath(in String path); - String getRecordPath(); - boolean toggleRecordingCall(in String id); - boolean startRecordedFilePlayback(in String filepath); - void stopRecordedFilePlayback(in String filepath); - - /* Mute */ - void setMuted(boolean mute); - boolean isCaptureMuted(); - - /* Security */ - List getTlsSupportedMethods(); - - /* DTMF */ - void playDtmf(in String key); - - /* IM */ - void sendTextMessage(in String callID, in String message); - long sendAccountTextMessage(in String accountid, in String to, in String msg); - void sendProfile(in String callID, in String accountID); - - void transfer(in String callID, in String to); - void attendedTransfer(in String transferID, in String targetID); - - /* Video */ - void setPreviewSettings(); - void switchInput(in String call, in boolean front); - void videoSurfaceAdded(in String call); - void videoSurfaceRemoved(in String call); - void videoPreviewSurfaceAdded(); - void videoPreviewSurfaceRemoved(); - - /* Conference related methods */ - - void removeConference(in String confID); - void joinParticipant(in String sel_callID, in String drag_callID); - - void addParticipant(in String callID, in String confID); - void addMainParticipant(in String confID); - void detachParticipant(in String callID); - void joinConference(in String sel_confID, in String drag_confID); - void hangUpConference(in String confID); - void holdConference(in String confID); - void unholdConference(in String confID); - boolean isConferenceParticipant(in String callID); - Map getConferenceList(); - List getParticipantList(in String confID); - String getConferenceId(in String callID); - String getConferenceDetails(in String callID); - - Map getConference(in String id); - - void connectivityChanged(); -} diff --git a/ring-android/app/src/main/java/cx/ring/service/LocationSharingService.java b/ring-android/app/src/main/java/cx/ring/service/LocationSharingService.java deleted file mode 100644 index 7c583ec98..000000000 --- a/ring-android/app/src/main/java/cx/ring/service/LocationSharingService.java +++ /dev/null @@ -1,406 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.service; - -import android.Manifest; -import android.app.Notification; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.pm.ServiceInfo; -import android.location.Criteria; -import android.location.Location; -import android.location.LocationListener; -import android.location.LocationManager; -import android.os.Binder; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.os.SystemClock; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.core.app.ActivityCompat; -import androidx.core.app.NotificationCompat; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.Date; -import java.util.HashMap; -import java.util.Iterator; -import java.util.Map; -import java.util.Random; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.client.ConversationActivity; -import net.jami.daemon.Blob; -import net.jami.daemon.JamiService; -import net.jami.daemon.StringMap; -import net.jami.facades.ConversationFacade; -import cx.ring.fragments.ConversationFragment; - -import net.jami.services.AccountService; -import net.jami.services.CallService; - -import cx.ring.services.NotificationServiceImpl; -import cx.ring.utils.ConversationPath; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public class LocationSharingService extends Service implements LocationListener { - private static final String TAG = "LocationSharingService"; - - public static final int NOTIF_SYNC_SERVICE_ID = 931801; - - public static final String ACTION_START = "startSharing"; - public static final String ACTION_STOP = "stopSharing"; - public static final String EXTRA_SHARING_DURATION = "locationShareDuration"; - - public static final String PREFERENCES_LOCATION = "location"; - public static final String PREFERENCES_KEY_POS_LONG = "lastPosLongitude"; - public static final String PREFERENCES_KEY_POS_LAT = "lastPosLatitude"; - public static final int SHARE_DURATION_SEC = 60 * 5; - - @Inject - ConversationFacade mConversationFacade; - - private final Random mRandom = new Random(); - private final IBinder binder = new LocalBinder(); - private boolean started = false; - - private LocationManager mLocationManager; - private NotificationManager mNotificationManager; - private SharedPreferences mPreferences; - private Handler mHandler; - - private final Subject<Location> mMyLocationSubject = BehaviorSubject.create(); - private final Map<ConversationPath, Date> contactLocationShare = new HashMap<>(); - private final Subject<Set<ConversationPath>> mContactSharingSubject = BehaviorSubject.createDefault(contactLocationShare.keySet()); - - private final CompositeDisposable mDisposableBag = new CompositeDisposable(); - - public LocationSharingService() { - } - - public Observable<Location> getMyLocation() { - return mMyLocationSubject; - } - - public Observable<Set<ConversationPath>> getContactSharing() { - return mContactSharingSubject; - } - - public Observable<Long> getContactSharingExpiration(ConversationPath path) { - return Observable.timer(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) - .startWithItem(0L) - .repeat() - .map(i -> contactLocationShare.get(path).getTime() - SystemClock.elapsedRealtime()) - .onErrorComplete(); - } - - @Override - public void onCreate() { - ((JamiApplication) getApplication()).getInjectionComponent().inject(this); - - mLocationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE); - mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - mPreferences = getSharedPreferences(PREFERENCES_LOCATION, Context.MODE_PRIVATE); - mHandler = new Handler(getMainLooper()); - String posLongitude = mPreferences.getString(PREFERENCES_KEY_POS_LONG, null); - String posLatitude = mPreferences.getString(PREFERENCES_KEY_POS_LAT, null); - if (posLatitude != null && posLongitude != null) { - try { - Location location = new Location("cache"); - location.setLatitude(Double.parseDouble(posLatitude)); - location.setLongitude(Double.parseDouble(posLongitude)); - mMyLocationSubject.onNext(location); - } catch (Exception e) { - Log.w(TAG, "Can't load last location", e); - } - } - if (mLocationManager != null) { - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED - || ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) { - try { - Criteria c = new Criteria(); - c.setAccuracy(Criteria.ACCURACY_FINE); - mLocationManager.requestLocationUpdates(0, 0.f, c, this, null); - } catch (Exception e) { - Log.e(TAG, "Can't start location tracking", e); - } - } - } - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Log.w(TAG, "onStartCommand " + intent); - String action = intent.getAction(); - ConversationPath path = ConversationPath.fromIntent(intent); - long now = SystemClock.elapsedRealtime(); - - if (ACTION_START.equals(action)) { - int duration = intent.getIntExtra(EXTRA_SHARING_DURATION, SHARE_DURATION_SEC); - long expiration = now + (duration * 1000L); - if (contactLocationShare.put(path, new Date(expiration)) == null) { - mContactSharingSubject.onNext(contactLocationShare.keySet()); - } - mHandler.postAtTime(this::refreshSharing, expiration); - - if (!started) { - started = true; - mDisposableBag.add(getNotification(now) - .subscribe(notification -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - startForeground(NOTIF_SYNC_SERVICE_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION); - else - startForeground(NOTIF_SYNC_SERVICE_ID, notification); - - mHandler.postAtTime(this::refreshNotificationTimer, now + 30 * 1000); - JamiApplication.getInstance().startDaemon(); - })); - mDisposableBag.add(mMyLocationSubject - .throttleLatest(10, TimeUnit.SECONDS) - .map(location -> { - JSONObject out = new JSONObject(); - out.put("type", AccountService.Location.Type.position.toString()); - out.put("lat", location.getLatitude()); - out.put("long", location.getLongitude()); - out.put("alt", location.getAltitude()); - out.put("time",location.getElapsedRealtimeNanos()/1000000L); - float bearing = location.getBearing(); - if (bearing != 0.f) - out.put("bearing", bearing); - float speed = location.getSpeed(); - if (speed != 0.f) - out.put("speed", speed); - return out; - }) - .subscribe(location -> { - Log.w(TAG, "location send " + location + " to " + contactLocationShare.size()); - StringMap msgs = new StringMap(); - msgs.setRaw(net.jami.services.CallService.MIME_GEOLOCATION, Blob.fromString(location.toString())); - for (ConversationPath p : contactLocationShare.keySet()) { - JamiService.sendAccountTextMessage(p.getAccountId(), p.getConversationId(), msgs); - } - })); - } else { - mDisposableBag.add(getNotification(now) - .subscribe(notification -> mNotificationManager.notify(NOTIF_SYNC_SERVICE_ID, notification))); - } - } - else if (ACTION_STOP.equals(action)) { - if (path == null) - contactLocationShare.clear(); - else { - contactLocationShare.remove(path); - - JSONObject jsonObject = new JSONObject(); - try { - jsonObject.put("type", net.jami.services.AccountService.Location.Type.stop.toString()); - jsonObject.put("time", Long.MAX_VALUE); - } catch (JSONException e) { - e.printStackTrace(); - } - - Log.w(TAG, "location send " + jsonObject + " to " + contactLocationShare.size()); - StringMap msgs = new StringMap(); - msgs.setRaw(CallService.MIME_GEOLOCATION, Blob.fromString(jsonObject.toString())); - JamiService.sendAccountTextMessage(path.getAccountId(), path.getConversationId(), msgs); - } - - mContactSharingSubject.onNext(contactLocationShare.keySet()); - - if (contactLocationShare.isEmpty()) { - Log.w(TAG, "stopping sharing " + intent); - mDisposableBag.clear(); - stopForeground(true); - stopSelf(); - started = false; - } else { - mDisposableBag.add(getNotification(now) - .subscribe(notification -> mNotificationManager.notify(NOTIF_SYNC_SERVICE_ID, notification))); - } - } - return START_NOT_STICKY; - } - - @Override - public void onDestroy() { - Log.w(TAG, "onDestroy"); - if (mLocationManager != null) { - mLocationManager.removeUpdates(this); - } - mMyLocationSubject.onComplete(); - mContactSharingSubject.onComplete(); - mDisposableBag.dispose(); - } - - @Override - public IBinder onBind(Intent intent) { - return binder; - } - - @Override - public boolean onUnbind(Intent intent) { - return true; - } - - @Override - public void onLocationChanged(Location location) { - // Log.w(TAG, "onLocationChanged " + location.toString()); - mMyLocationSubject.onNext(location); - mPreferences.edit() - .putString(PREFERENCES_KEY_POS_LAT, Double.toString(location.getLatitude())) - .putString(PREFERENCES_KEY_POS_LONG, Double.toString(location.getLongitude())) - .apply(); - } - - @Override - public void onStatusChanged(String provider, int status, Bundle extras) {} - - @Override - public void onProviderEnabled(String provider) { - } - - @Override - public void onProviderDisabled(String provider) { - } - - @NonNull - private Single<Notification> getNotification(long now) { - int contactCount = contactLocationShare.size(); - ConversationPath firsPath = contactLocationShare.keySet().iterator().next(); - Date largest = null; - for (Date d : contactLocationShare.values()) - if (largest == null || d.after(largest)) - largest = d; - final long largestDate = largest == null ? now : largest.getTime(); - // Log.w(TAG, "getNotification " + firsPath.getContactId()); - - return mConversationFacade.getAccountSubject(firsPath.getAccountId()) - .map(account -> account.getContactFromCache(firsPath.getConversationUri())) - .map(contact -> { - String title; - final Intent stopIntent = new Intent(ACTION_STOP).setClass(getApplicationContext(), LocationSharingService.class); - final Intent contentIntent = new Intent(Intent.ACTION_VIEW, firsPath.toUri(), getApplicationContext(), ConversationActivity.class) - .putExtra(ConversationFragment.EXTRA_SHOW_MAP, true) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - if (contactCount == 1) { - stopIntent.setData(firsPath.toUri()); - title = getString(R.string.notif_location_title, contact.getDisplayName()); - } else { - title = getString(R.string.notif_location_multi_title, contactCount); - } - String subtitle = getString(R.string.notif_location_remaining, (int)Math.ceil((largestDate - now)/(double)(1000 * 60))); - - return new NotificationCompat.Builder(this, NotificationServiceImpl.NOTIF_CHANNEL_SYNC) - .setContentTitle(title) - .setContentText(subtitle) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) - .setAutoCancel(false) - .setOngoing(false) - .setVibrate(null) - .setColorized(true) - .setColor(getResources().getColor(R.color.color_primary_dark)) - .setSmallIcon(R.drawable.ic_ring_logo_white) - .setCategory(NotificationCompat.CATEGORY_PROGRESS) - .setOnlyAlertOnce(true) - .setDeleteIntent(PendingIntent.getService(getApplicationContext(), mRandom.nextInt(), stopIntent, 0)) - .setContentIntent(PendingIntent.getActivity(getApplicationContext(), mRandom.nextInt(), contentIntent, 0)) - .addAction(R.drawable.baseline_location_disabled_24, - getText(R.string.notif_location_action_stop), - PendingIntent.getService( - getApplicationContext(), - 0, - stopIntent, - PendingIntent.FLAG_ONE_SHOT)) - .build(); - }) - .subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()); - } - - private void refreshSharing() { - if (!started) - return; - - boolean changed = false; - final Date now = new Date(SystemClock.uptimeMillis()); - Iterator<Map.Entry<ConversationPath, Date>> it = contactLocationShare.entrySet().iterator(); - while (it.hasNext()) { - Map.Entry<ConversationPath, Date> e = it.next(); - if (e.getValue().before(now)) { - changed = true; - it.remove(); - } - } - - if (changed) - mContactSharingSubject.onNext(contactLocationShare.keySet()); - - if (contactLocationShare.isEmpty()) { - mDisposableBag.clear(); - stopForeground(true); - stopSelf(); - started = false; - } else if (changed) { - mDisposableBag.add(getNotification(now.getTime()) - .subscribe(notification -> mNotificationManager.notify(NOTIF_SYNC_SERVICE_ID, notification))); - } - } - - private void refreshNotificationTimer() { - if (!started) - return; - long now = SystemClock.uptimeMillis(); - mDisposableBag.add(getNotification(now) - .subscribe(notification -> mNotificationManager.notify(NOTIF_SYNC_SERVICE_ID, notification))); - mHandler.postAtTime(this::refreshNotificationTimer, now + (30 * 1000)); - } - - public boolean isSharing(ConversationPath path) { - return contactLocationShare.get(path) != null; - } - - public class LocalBinder extends Binder { - public LocationSharingService getService() { - return LocationSharingService.this; - } - } -} diff --git a/ring-android/app/src/main/java/cx/ring/service/LocationSharingService.kt b/ring-android/app/src/main/java/cx/ring/service/LocationSharingService.kt new file mode 100644 index 000000000..a9f9b5170 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/service/LocationSharingService.kt @@ -0,0 +1,366 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.service + +import android.Manifest +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.location.Criteria +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.* +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import cx.ring.R +import cx.ring.application.JamiApplication +import cx.ring.client.ConversationActivity +import cx.ring.fragments.ConversationFragment +import cx.ring.services.NotificationServiceImpl +import cx.ring.utils.ConversationPath +import cx.ring.utils.ConversationPath.Companion.fromIntent +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.daemon.Blob +import net.jami.daemon.JamiService +import net.jami.daemon.StringMap +import net.jami.services.ConversationFacade +import net.jami.services.AccountService +import net.jami.services.CallService +import org.json.JSONException +import org.json.JSONObject +import java.util.* +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.math.ceil + +@AndroidEntryPoint +class LocationSharingService : Service(), LocationListener { + @Inject + lateinit var mConversationFacade: ConversationFacade + + private val mRandom = Random() + private val binder: IBinder = LocalBinder() + private var started = false + private var mLocationManager: LocationManager? = null + private lateinit var mNotificationManager: NotificationManager + private lateinit var mPreferences: SharedPreferences + private lateinit var mHandler: Handler + private val mMyLocationSubject: Subject<Location> = BehaviorSubject.create() + private val contactLocationShare: MutableMap<ConversationPath, Date> = HashMap() + private val mContactSharingSubject: Subject<Set<ConversationPath>> = + BehaviorSubject.createDefault(contactLocationShare.keys) + private val mDisposableBag = CompositeDisposable() + + val myLocation: Observable<Location> + get() = mMyLocationSubject + val contactSharing: Observable<Set<ConversationPath>> + get() = mContactSharingSubject + + fun getContactSharingExpiration(path: ConversationPath?): Observable<Long> { + return Observable.timer(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) + .startWithItem(0L) + .repeat() + .map { contactLocationShare[path]!!.time - SystemClock.elapsedRealtime() } + .onErrorComplete() + } + + override fun onCreate() { + super.onCreate() + mLocationManager = getSystemService(LOCATION_SERVICE) as LocationManager + mNotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + mHandler = Handler(mainLooper) + mPreferences = getSharedPreferences(PREFERENCES_LOCATION, MODE_PRIVATE) + val posLongitude = mPreferences.getString(PREFERENCES_KEY_POS_LONG, null) + val posLatitude = mPreferences.getString(PREFERENCES_KEY_POS_LAT, null) + if (posLatitude != null && posLongitude != null) { + try { + val location = Location("cache") + location.latitude = posLatitude.toDouble() + location.longitude = posLongitude.toDouble() + mMyLocationSubject.onNext(location) + } catch (e: Exception) { + Log.w(TAG, "Can't load last location", e) + } + } + mLocationManager?.let { locationManager -> + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + || ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + ) { + try { + val c = Criteria() + c.accuracy = Criteria.ACCURACY_FINE + locationManager.requestLocationUpdates(0, 0f, c, this, null) + } catch (e: Exception) { + Log.e(TAG, "Can't start location tracking", e) + } + } + } + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + Log.w(TAG, "onStartCommand $intent") + val action = intent.action + val path = fromIntent(intent) + val now = SystemClock.elapsedRealtime() + if (ACTION_START == action) { + val duration = intent.getIntExtra(EXTRA_SHARING_DURATION, SHARE_DURATION_SEC) + val expiration = now + duration * 1000L + if (contactLocationShare.put(path!!, Date(expiration)) == null) { + mContactSharingSubject.onNext(contactLocationShare.keys) + } + mHandler.postAtTime({ refreshSharing() }, expiration) + if (!started) { + started = true + mDisposableBag.add(getNotification(now) + .subscribe { notification -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + startForeground(NOTIF_SYNC_SERVICE_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) + else + startForeground(NOTIF_SYNC_SERVICE_ID, notification) + mHandler.postAtTime({ refreshNotificationTimer() }, now + 30 * 1000) + JamiApplication.instance?.startDaemon() + }) + mDisposableBag.add(mMyLocationSubject + .throttleLatest(10, TimeUnit.SECONDS) + .map { location -> + val out = JSONObject() + out.put("type", AccountService.Location.Type.Position.toString()) + out.put("lat", location.latitude) + out.put("long", location.longitude) + out.put("alt", location.altitude) + out.put("time", location.elapsedRealtimeNanos / 1000000L) + val bearing = location.bearing + if (bearing != 0f) out.put("bearing", bearing.toDouble()) + val speed = location.speed + if (speed != 0f) out.put("speed", speed.toDouble()) + out + } + .subscribe { location: JSONObject -> + Log.w(TAG, "location send " + location + " to " + contactLocationShare.size) + val msgs = StringMap() + msgs.setRaw(CallService.MIME_GEOLOCATION, Blob.fromString(location.toString())) + for (p in contactLocationShare.keys) + JamiService.sendAccountTextMessage(p.accountId, p.conversationId, msgs) + }) + } else { + mDisposableBag.add(getNotification(now) + .subscribe { notification -> mNotificationManager.notify(NOTIF_SYNC_SERVICE_ID, notification) }) + } + } else if (ACTION_STOP == action) { + if (path == null) contactLocationShare.clear() else { + contactLocationShare.remove(path) + val jsonObject = JSONObject() + try { + jsonObject.put("type", AccountService.Location.Type.Stop.toString()) + jsonObject.put("time", Long.MAX_VALUE) + } catch (e: JSONException) { + e.printStackTrace() + } + Log.w(TAG, "location send " + jsonObject + " to " + contactLocationShare.size) + val msgs = StringMap() + msgs.setRaw(CallService.MIME_GEOLOCATION, Blob.fromString(jsonObject.toString())) + JamiService.sendAccountTextMessage(path.accountId, path.conversationId, msgs) + } + mContactSharingSubject.onNext(contactLocationShare.keys) + if (contactLocationShare.isEmpty()) { + Log.w(TAG, "stopping sharing $intent") + mDisposableBag.clear() + stopForeground(true) + stopSelf() + started = false + } else { + mDisposableBag.add(getNotification(now) + .subscribe { notification -> mNotificationManager.notify(NOTIF_SYNC_SERVICE_ID, notification) }) + } + } + return START_NOT_STICKY + } + + override fun onDestroy() { + Log.w(TAG, "onDestroy") + mLocationManager?.removeUpdates(this) + mMyLocationSubject.onComplete() + mContactSharingSubject.onComplete() + mDisposableBag.dispose() + } + + override fun onBind(intent: Intent): IBinder { + return binder + } + + override fun onUnbind(intent: Intent): Boolean { + return true + } + + override fun onLocationChanged(location: Location) { + // Log.w(TAG, "onLocationChanged " + location.toString()); + mMyLocationSubject.onNext(location) + mPreferences.edit() + .putString(PREFERENCES_KEY_POS_LAT, location.latitude.toString()) + .putString(PREFERENCES_KEY_POS_LONG, location.longitude.toString()) + .apply() + } + + override fun onStatusChanged(provider: String, status: Int, extras: Bundle) {} + override fun onProviderEnabled(provider: String) {} + override fun onProviderDisabled(provider: String) {} + private fun getNotification(now: Long): Single<Notification> { + val contactCount = contactLocationShare.size + val firsPath = contactLocationShare.keys.iterator().next() + var largest: Date? = null + for (d in contactLocationShare.values) + if (largest == null || d.after(largest)) + largest = d + val largestDate = largest?.time ?: now + // Log.w(TAG, "getNotification " + firsPath.getContactId()); + return mConversationFacade.getAccountSubject(firsPath.accountId) + .map { account -> account.getContactFromCache(firsPath.conversationUri) } + .map { contact -> + val title: String + val stopIntent = Intent(ACTION_STOP).setClass(applicationContext, LocationSharingService::class.java) + val contentIntent = Intent( + Intent.ACTION_VIEW, + firsPath.toUri(), + applicationContext, + ConversationActivity::class.java + ) + .putExtra(ConversationFragment.EXTRA_SHOW_MAP, true) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (contactCount == 1) { + stopIntent.data = firsPath.toUri() + title = getString(R.string.notif_location_title, contact.displayName) + } else { + title = getString(R.string.notif_location_multi_title, contactCount) + } + val subtitle = getString(R.string.notif_location_remaining, + ceil((largestDate - now) / (1000 * 60).toDouble()).toInt()) + NotificationCompat.Builder(this, NotificationServiceImpl.NOTIF_CHANNEL_SYNC) + .setContentTitle(title) + .setContentText(subtitle) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setAutoCancel(false) + .setOngoing(false) + .setVibrate(null) + .setColorized(true) + .setColor(resources.getColor(R.color.color_primary_dark)) + .setSmallIcon(R.drawable.ic_ring_logo_white) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setOnlyAlertOnce(true) + .setDeleteIntent( + PendingIntent.getService( + applicationContext, + mRandom.nextInt(), + stopIntent, + 0 + ) + ) + .setContentIntent( + PendingIntent.getActivity( + applicationContext, + mRandom.nextInt(), + contentIntent, + 0 + ) + ) + .addAction( + R.drawable.baseline_location_disabled_24, + getText(R.string.notif_location_action_stop), + PendingIntent.getService( + applicationContext, + 0, + stopIntent, + PendingIntent.FLAG_ONE_SHOT + ) + ) + .build() + } + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + } + + private fun refreshSharing() { + if (!started) return + var changed = false + val now = Date(SystemClock.uptimeMillis()) + val it: MutableIterator<Map.Entry<ConversationPath?, Date?>> = + contactLocationShare.entries.iterator() + while (it.hasNext()) { + val e = it.next() + if (e.value!!.before(now)) { + changed = true + it.remove() + } + } + if (changed) mContactSharingSubject.onNext(contactLocationShare.keys) + if (contactLocationShare.isEmpty()) { + mDisposableBag.clear() + stopForeground(true) + stopSelf() + started = false + } else if (changed) { + mDisposableBag.add(getNotification(now.time) + .subscribe { notification -> mNotificationManager.notify(NOTIF_SYNC_SERVICE_ID, notification) }) + } + } + + private fun refreshNotificationTimer() { + if (!started) return + val now = SystemClock.uptimeMillis() + mDisposableBag.add(getNotification(now) + .subscribe { notification -> mNotificationManager.notify(NOTIF_SYNC_SERVICE_ID, notification) }) + mHandler.postAtTime({ refreshNotificationTimer() }, now + 30 * 1000) + } + + fun isSharing(path: ConversationPath?): Boolean { + return contactLocationShare[path] != null + } + + inner class LocalBinder : Binder() { + val service: LocationSharingService + get() = this@LocationSharingService + } + + companion object { + private const val TAG = "LocationSharingService" + const val NOTIF_SYNC_SERVICE_ID = 931801 + const val ACTION_START = "startSharing" + const val ACTION_STOP = "stopSharing" + const val EXTRA_SHARING_DURATION = "locationShareDuration" + const val PREFERENCES_LOCATION = "location" + const val PREFERENCES_KEY_POS_LONG = "lastPosLongitude" + const val PREFERENCES_KEY_POS_LAT = "lastPosLatitude" + const val SHARE_DURATION_SEC = 60 * 5 + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/service/SyncService.java b/ring-android/app/src/main/java/cx/ring/service/SyncService.java index 9b0ac420d..48d2dab7c 100644 --- a/ring-android/app/src/main/java/cx/ring/service/SyncService.java +++ b/ring-android/app/src/main/java/cx/ring/service/SyncService.java @@ -42,7 +42,9 @@ import cx.ring.R; import cx.ring.application.JamiApplication; import cx.ring.client.HomeActivity; import cx.ring.services.NotificationServiceImpl; +import dagger.hilt.android.AndroidEntryPoint; +@AndroidEntryPoint public class SyncService extends Service { public static final int NOTIF_SYNC_SERVICE_ID = 1004; @@ -58,12 +60,6 @@ public class SyncService extends Service { @Inject NotificationService mNotificationService; - @Override - public void onCreate() { - super.onCreate(); - ((JamiApplication) getApplication()).getInjectionComponent().inject(this); - } - @Override public int onStartCommand(Intent intent, int flags, int startId) { String action = intent.getAction(); diff --git a/ring-android/app/src/main/java/cx/ring/services/ContactServiceImpl.java b/ring-android/app/src/main/java/cx/ring/services/ContactServiceImpl.java deleted file mode 100644 index cf9cccf02..000000000 --- a/ring-android/app/src/main/java/cx/ring/services/ContactServiceImpl.java +++ /dev/null @@ -1,463 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.services; - -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.Context; -import android.database.Cursor; -import android.graphics.drawable.Drawable; -import android.provider.ContactsContract; -import android.util.Base64; -import android.util.Log; -import android.util.LongSparseArray; - -import androidx.annotation.NonNull; - -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.model.Phone; -import net.jami.model.Uri; -import net.jami.services.ContactService; -import net.jami.utils.Tuple; -import net.jami.utils.VCardUtils; - -import java.util.HashMap; -import java.util.Map; - -import javax.inject.Inject; - -import cx.ring.utils.AndroidFileUtils; -import cx.ring.views.AvatarFactory; -import ezvcard.VCard; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class ContactServiceImpl extends ContactService { - - private static final String TAG = ContactServiceImpl.class.getSimpleName(); - - private static final String[] CONTACTS_SUMMARY_PROJECTION = new String[]{ - ContactsContract.Contacts._ID, - ContactsContract.Contacts.LOOKUP_KEY, - ContactsContract.Contacts.DISPLAY_NAME, - ContactsContract.Contacts.PHOTO_ID, - ContactsContract.Contacts.STARRED - }; - - private static final String[] CONTACTS_DATA_PROJECTION = new String[]{ - ContactsContract.Data.CONTACT_ID, - ContactsContract.Data.MIMETYPE, - ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS, - ContactsContract.CommonDataKinds.SipAddress.TYPE, - ContactsContract.CommonDataKinds.SipAddress.LABEL, - ContactsContract.CommonDataKinds.Im.PROTOCOL, - ContactsContract.CommonDataKinds.Im.CUSTOM_PROTOCOL - }; - - private static final String[] CONTACT_PROJECTION = { - ContactsContract.Contacts._ID, - ContactsContract.Contacts.LOOKUP_KEY, - ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, - ContactsContract.Contacts.PHOTO_ID, - ContactsContract.Contacts.STARRED - }; - - private static final String[] CONTACTS_PHONES_PROJECTION = { - ContactsContract.CommonDataKinds.Phone.NUMBER, - ContactsContract.CommonDataKinds.Phone.TYPE, - ContactsContract.CommonDataKinds.Phone.LABEL - }; - - private static final String[] CONTACTS_SIP_PROJECTION = { - ContactsContract.Data.MIMETYPE, - ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS, - ContactsContract.CommonDataKinds.SipAddress.TYPE, - ContactsContract.CommonDataKinds.SipAddress.LABEL - }; - - private static final String[] DATA_PROJECTION = { - ContactsContract.Data._ID, - ContactsContract.RawContacts.CONTACT_ID, - ContactsContract.Data.LOOKUP_KEY, - ContactsContract.Data.DISPLAY_NAME_PRIMARY, - ContactsContract.Data.PHOTO_ID, - ContactsContract.Data.PHOTO_THUMBNAIL_URI, - ContactsContract.Data.STARRED - }; - - private static final String[] PHONELOOKUP_PROJECTION = { - ContactsContract.PhoneLookup._ID, - ContactsContract.PhoneLookup.LOOKUP_KEY, - ContactsContract.PhoneLookup.PHOTO_ID, - ContactsContract.Contacts.DISPLAY_NAME_PRIMARY - }; - - private static final String ID_SELECTION = ContactsContract.CommonDataKinds.Phone.CONTACT_ID + "=?"; - - @Inject - Context mContext; - - @Override - public Map<Long, Contact> loadContactsFromSystem(boolean loadRingContacts, boolean loadSipContacts) { - - Map<Long, Contact> systemContacts = new HashMap<>(); - ContentResolver contentResolver = mContext.getContentResolver(); - StringBuilder contactsIds = new StringBuilder(); - LongSparseArray<Contact> cache; - - Cursor contactCursor = contentResolver.query(ContactsContract.Data.CONTENT_URI, CONTACTS_DATA_PROJECTION, - ContactsContract.Data.MIMETYPE + "=? OR " + ContactsContract.Data.MIMETYPE + "=? OR " + ContactsContract.Data.MIMETYPE + "=?", - new String[]{ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE}, null); - - if (contactCursor != null) { - cache = new LongSparseArray<>(contactCursor.getCount()); - contactsIds.ensureCapacity(contactCursor.getCount() * 4); - - final int indexId = contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID); - final int indexMime = contactCursor.getColumnIndex(ContactsContract.Data.MIMETYPE); - final int indexNumber = contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS); - final int indexType = contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.TYPE); - final int indexLabel = contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.LABEL); - - while (contactCursor.moveToNext()) { - long contactId = contactCursor.getLong(indexId); - - String contactNumber = contactCursor.getString(indexNumber); - int contactType = contactCursor.getInt(indexType); - String contactLabel = contactCursor.getString(indexLabel); - Uri uri = Uri.fromString(contactNumber); - - Contact contact = cache.get(contactId); - boolean isNewContact = false; - if (contact == null) { - contact = new Contact(uri); - contact.setSystemId(contactId); - isNewContact = true; - contact.setFromSystem(true); - } - - if (uri.isSingleIp() || (uri.isHexId() && loadRingContacts) || loadSipContacts) { - switch (contactCursor.getString(indexMime)) { - case ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE: - contact.addPhoneNumber(uri, contactType, contactLabel); - break; - case ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE: - contact.addNumber(uri, contactType, contactLabel, Phone.NumberType.SIP); - break; - case ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE: - if (uri.isHexId()) { - contact.addNumber(uri, contactType, contactLabel, Phone.NumberType.UNKNOWN); - } - break; - } - } - - if (isNewContact && !contact.getPhones().isEmpty()) { - cache.put(contactId, contact); - if (contactsIds.length() > 0) { - contactsIds.append(","); - } - contactsIds.append(contactId); - } - } - contactCursor.close(); - } else { - cache = new LongSparseArray<>(); - } - - contactCursor = contentResolver.query(ContactsContract.Contacts.CONTENT_URI, CONTACTS_SUMMARY_PROJECTION, - ContactsContract.Contacts._ID + " in (" + contactsIds.toString() + ")", null, - ContactsContract.Contacts.DISPLAY_NAME + " COLLATE LOCALIZED ASC"); - - if (contactCursor != null) { - final int indexId = contactCursor.getColumnIndex(ContactsContract.Contacts._ID); - final int indexKey = contactCursor.getColumnIndex(ContactsContract.Data.LOOKUP_KEY); - final int indexName = contactCursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME); - final int indexPhoto = contactCursor.getColumnIndex(ContactsContract.Contacts.PHOTO_ID); - - while (contactCursor.moveToNext()) { - long contactId = contactCursor.getLong(indexId); - Contact contact = cache.get(contactId); - if (contact == null) - Log.w(TAG, "Can't find contact with ID " + contactId); - else { - contact.setSystemContactInfo(contactId, contactCursor.getString(indexKey), contactCursor.getString(indexName), contactCursor.getLong(indexPhoto)); - systemContacts.put(contactId, contact); - } - } - contactCursor.close(); - } - - return systemContacts; - } - - @Override - protected Contact findContactByIdFromSystem(Long id, String key) { - Contact contact = null; - ContentResolver contentResolver = mContext.getContentResolver(); - - try { - android.net.Uri contentUri; - if (key != null) { - contentUri = ContactsContract.Contacts.lookupContact( - contentResolver, - ContactsContract.Contacts.getLookupUri(id, key)); - } else { - contentUri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id); - } - - Cursor result = null; - if (contentUri != null) { - result = contentResolver.query(contentUri, CONTACT_PROJECTION, null, null, null); - } - - if (result == null) { - return null; - } - - if (result.moveToFirst()) { - int indexId = result.getColumnIndex(ContactsContract.Data._ID); - int indexKey = result.getColumnIndex(ContactsContract.Data.LOOKUP_KEY); - int indexName = result.getColumnIndex(ContactsContract.Data.DISPLAY_NAME); - int indexPhoto = result.getColumnIndex(ContactsContract.Data.PHOTO_ID); - int indexStared = result.getColumnIndex(ContactsContract.Contacts.STARRED); - - long contactId = result.getLong(indexId); - - Log.d(TAG, "Contact name: " + result.getString(indexName) + " id:" + contactId + " key:" + result.getString(indexKey)); - - contact = new Contact(Uri.fromString(contentUri.toString())); - contact.setSystemContactInfo(contactId, result.getString(indexKey), result.getString(indexName), result.getLong(indexPhoto)); - - if (result.getInt(indexStared) != 0) { - contact.setStared(); - } - - fillContactDetails(contact); - } - - result.close(); - } catch (Exception e) { - Log.d(TAG, "findContactByIdFromSystem: Error while searching for contact id=" + id, e); - } - - if (contact == null) { - Log.d(TAG, "findContactByIdFromSystem: findById " + id + " can't find contact."); - } - - return contact; - } - - private void fillContactDetails(@NonNull Contact contact) { - ContentResolver contentResolver = mContext.getContentResolver(); - - try { - Cursor cursorPhones = contentResolver.query( - ContactsContract.CommonDataKinds.Phone.CONTENT_URI, - CONTACTS_PHONES_PROJECTION, ID_SELECTION, - new String[]{String.valueOf(contact.getId())}, null); - - if (cursorPhones != null) { - final int indexNumber = cursorPhones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER); - final int indexType = cursorPhones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE); - final int indexLabel = cursorPhones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.LABEL); - - while (cursorPhones.moveToNext()) { - contact.addNumber(cursorPhones.getString(indexNumber), cursorPhones.getInt(indexType), cursorPhones.getString(indexLabel), Phone.NumberType.TEL); - Log.d(TAG, "Phone:" + cursorPhones.getString(cursorPhones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER))); - } - - cursorPhones.close(); - } - - android.net.Uri baseUri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, contact.getId()); - android.net.Uri targetUri = android.net.Uri.withAppendedPath(baseUri, ContactsContract.Contacts.Data.CONTENT_DIRECTORY); - - Cursor cursorSip = contentResolver.query( - targetUri, - CONTACTS_SIP_PROJECTION, - ContactsContract.Data.MIMETYPE + "=? OR " + ContactsContract.Data.MIMETYPE + " =?", - new String[]{ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE}, null); - - if (cursorSip != null) { - final int indexMime = cursorSip.getColumnIndex(ContactsContract.Data.MIMETYPE); - final int indexSip = cursorSip.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS); - final int indexType = cursorSip.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.TYPE); - final int indexLabel = cursorSip.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.LABEL); - - while (cursorSip.moveToNext()) { - String contactMime = cursorSip.getString(indexMime); - String contactNumber = cursorSip.getString(indexSip); - - if (!contactMime.contentEquals(ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE) || Uri.fromString(contactNumber).isHexId() || "ring".equalsIgnoreCase(cursorSip.getString(indexLabel))) { - contact.addNumber(contactNumber, cursorSip.getInt(indexType), cursorSip.getString(indexLabel), Phone.NumberType.SIP); - } - Log.d(TAG, "SIP phone:" + contactNumber + " " + contactMime + " "); - } - cursorSip.close(); - } - } catch (Exception e) { - Log.d(TAG, "fillContactDetails: Error while retrieving detail contact info", e); - } - } - - public Contact findContactBySipNumberFromSystem(String number) { - Contact contact = null; - ContentResolver contentResolver = mContext.getContentResolver(); - - try { - Cursor result = contentResolver.query(ContactsContract.Data.CONTENT_URI, - DATA_PROJECTION, - ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS + "=?" + " AND (" + ContactsContract.Data.MIMETYPE + "=? OR " + ContactsContract.Data.MIMETYPE + "=?)", - new String[]{number, ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE}, null); - - if (result == null) { - Log.d(TAG, "findContactBySipNumberFromSystem: " + number + " can't find contact."); - return Contact.buildSIP(Uri.fromString(number)); - } - - int indexId = result.getColumnIndex(ContactsContract.RawContacts.CONTACT_ID); - int indexKey = result.getColumnIndex(ContactsContract.Data.LOOKUP_KEY); - int indexName = result.getColumnIndex(ContactsContract.Data.DISPLAY_NAME); - int indexPhoto = result.getColumnIndex(ContactsContract.Data.PHOTO_ID); - int indexStared = result.getColumnIndex(ContactsContract.Contacts.STARRED); - - if (result.moveToFirst()) { - long contactId = result.getLong(indexId); - contact = new Contact(Uri.fromString(number)); - contact.setSystemContactInfo(contactId, result.getString(indexKey), result.getString(indexName), result.getLong(indexPhoto)); - - if (result.getInt(indexStared) != 0) { - contact.setStared(); - } - - fillContactDetails(contact); - } - result.close(); - - if (contact == null || contact.getPhones() == null || contact.getPhones().isEmpty()) { - return null; - } - } catch (Exception e) { - Log.d(TAG, "findContactBySipNumberFromSystem: Error while searching for contact number=" + number, e); - } - - return contact; - } - - public Contact findContactByNumberFromSystem(String number) { - Contact contact = null; - ContentResolver contentResolver = mContext.getContentResolver(); - - try { - android.net.Uri uri = android.net.Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, android.net.Uri.encode(number)); - Cursor result = contentResolver.query(uri, PHONELOOKUP_PROJECTION, null, null, null); - if (result == null) { - Log.d(TAG, "findContactByNumberFromSystem: " + number + " can't find contact."); - return findContactBySipNumberFromSystem(number); - } - if (result.moveToFirst()) { - int indexId = result.getColumnIndex(ContactsContract.Contacts._ID); - int indexKey = result.getColumnIndex(ContactsContract.Data.LOOKUP_KEY); - int indexName = result.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME); - int indexPhoto = result.getColumnIndex(ContactsContract.Contacts.PHOTO_ID); - contact = new Contact(Uri.fromString(number)); - contact.setSystemContactInfo(result.getLong(indexId), result.getString(indexKey), result.getString(indexName), result.getLong(indexPhoto)); - fillContactDetails(contact); - Log.d(TAG, "findContactByNumberFromSystem: " + number + " found " + contact.getDisplayName()); - } - result.close(); - } catch (Exception e) { - Log.d(TAG, "findContactByNumber: Error while searching for contact number=" + number, e); - } - - if (contact == null) { - Log.d(TAG, "findContactByNumberFromSystem: " + number + " can't find contact."); - contact = findContactBySipNumberFromSystem(number); - } - - if (contact != null) - contact.setFromSystem(true); - return contact; - } - - @Override - public Completable loadContactData(Contact contact, String accountId) { - if (!contact.detailsLoaded) { - Single<Tuple<String, Object>> profile = contact.isFromSystem() ? loadSystemContactData(contact) : loadVCardContactData(contact, accountId); - return profile - .doOnSuccess(p -> contact.setProfile(p.first, p.second)) - .doOnError(e -> contact.setProfile(null, null)) - .ignoreElement() - .onErrorComplete(); - } - return Completable.complete(); - } - - @Override - public void saveVCardContactData(Contact contact, String accountId, VCard vcard) { - if (vcard != null) { - Tuple<String, Object> profileData = VCardServiceImpl.readData(vcard); - contact.setProfile(profileData.first, profileData.second); - String filename = contact.getPrimaryNumber() + ".vcf"; - VCardUtils.savePeerProfileToDisk(vcard, accountId - , filename, mContext.getFilesDir()); - AvatarFactory.clearCache(); - } - } - - @Override - public Single<VCard> saveVCardContact(String accountId, String uri, String displayName, String picture) - { - return Single.fromCallable(() -> { - VCard vcard = VCardUtils.writeData(uri, displayName, Base64.decode(picture, Base64.DEFAULT)); - String filename = uri + ".vcf"; - VCardUtils.savePeerProfileToDisk(vcard, accountId, filename, mContext.getFilesDir()); - return vcard; - }); - } - - private Single<Tuple<String, Object>> loadVCardContactData(Contact contact, String accountId) { - String id = contact.getPrimaryNumber(); - if (id != null) { - return Single.fromCallable(() -> VCardUtils.loadPeerProfileFromDisk(mContext.getFilesDir(), id + ".vcf", accountId)) - .map(VCardServiceImpl::readData) - .subscribeOn(Schedulers.computation()); - } - return Single.error(new IllegalArgumentException()); - } - - private Single<Tuple<String, Object>> loadSystemContactData(Contact contact) { - String contactName = contact.getDisplayName(); - android.net.Uri photoURI = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, contact.getId()); - return AndroidFileUtils - .loadBitmap(mContext, android.net.Uri.withAppendedPath(photoURI, ContactsContract.Contacts.Photo.DISPLAY_PHOTO)) - .map(bitmap -> new Tuple<String, Object>(contactName, bitmap)) - .onErrorReturn(e -> new Tuple<>(contactName, null)) - .subscribeOn(Schedulers.io()); - } - - public Single<Drawable> loadConversationAvatar(@NonNull Context context, @NonNull Conversation conversation) { - return getLoadedContact(conversation.getAccountId(), conversation.getContacts(), false) - .flatMap(contacts -> AvatarFactory.getAvatar(context, conversation, false)); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/services/ContactServiceImpl.kt b/ring-android/app/src/main/java/cx/ring/services/ContactServiceImpl.kt new file mode 100644 index 000000000..d567f54b6 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/services/ContactServiceImpl.kt @@ -0,0 +1,500 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.services + +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import android.provider.ContactsContract +import android.util.Base64 +import android.util.Log +import android.util.LongSparseArray +import cx.ring.utils.AndroidFileUtils +import cx.ring.views.AvatarFactory +import ezvcard.VCard +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.model.Contact +import net.jami.model.Conversation +import net.jami.model.Phone +import net.jami.services.AccountService +import net.jami.services.ContactService +import net.jami.services.DeviceRuntimeService +import net.jami.services.PreferencesService +import net.jami.utils.Tuple +import net.jami.utils.VCardUtils + +class ContactServiceImpl(val mContext: Context, preferenceService: PreferencesService, + deviceRuntimeService : DeviceRuntimeService, + accountService: AccountService +) : ContactService(preferenceService, deviceRuntimeService, accountService) { + override fun loadContactsFromSystem( + loadRingContacts: Boolean, + loadSipContacts: Boolean + ): Map<Long, Contact> { + val systemContacts: MutableMap<Long, Contact> = HashMap() + val contentResolver = mContext.contentResolver + val contactsIds = StringBuilder() + val cache: LongSparseArray<Contact> + var contactCursor = contentResolver.query( + ContactsContract.Data.CONTENT_URI, + CONTACTS_DATA_PROJECTION, + ContactsContract.Data.MIMETYPE + "=? OR " + ContactsContract.Data.MIMETYPE + "=? OR " + ContactsContract.Data.MIMETYPE + "=?", + arrayOf( + ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE, + ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE, + ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE + ), + null + ) + if (contactCursor != null) { + cache = LongSparseArray(contactCursor.count) + contactsIds.ensureCapacity(contactCursor.count * 4) + val indexId = + contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + val indexMime = contactCursor.getColumnIndex(ContactsContract.Data.MIMETYPE) + val indexNumber = + contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS) + val indexType = + contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.TYPE) + val indexLabel = + contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.LABEL) + while (contactCursor.moveToNext()) { + val contactId = contactCursor.getLong(indexId) + val contactNumber = contactCursor.getString(indexNumber) + val contactType = contactCursor.getInt(indexType) + val contactLabel = contactCursor.getString(indexLabel) + val uri = net.jami.model.Uri.fromString(contactNumber) + var contact = cache[contactId] + var isNewContact = false + if (contact == null) { + contact = Contact(uri) + contact.setSystemId(contactId) + isNewContact = true + contact.isFromSystem = true + } + if (uri.isSingleIp || uri.isHexId && loadRingContacts || loadSipContacts) { + when (contactCursor.getString(indexMime)) { + ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> contact.addPhoneNumber( + uri, + contactType, + contactLabel + ) + ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> contact.addNumber( + uri, + contactType, + contactLabel, + Phone.NumberType.SIP + ) + ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE -> if (uri.isHexId) { + contact.addNumber( + uri, + contactType, + contactLabel, + Phone.NumberType.UNKNOWN + ) + } + } + } + if (isNewContact && contact.phones.isNotEmpty()) { + cache.put(contactId, contact) + if (contactsIds.isNotEmpty()) { + contactsIds.append(",") + } + contactsIds.append(contactId) + } + } + contactCursor.close() + } else { + cache = LongSparseArray() + } + contactCursor = contentResolver.query( + ContactsContract.Contacts.CONTENT_URI, CONTACTS_SUMMARY_PROJECTION, + ContactsContract.Contacts._ID + " in (" + contactsIds.toString() + ")", null, + ContactsContract.Contacts.DISPLAY_NAME + " COLLATE LOCALIZED ASC" + ) + if (contactCursor != null) { + val indexId = contactCursor.getColumnIndex(ContactsContract.Contacts._ID) + val indexKey = contactCursor.getColumnIndex(ContactsContract.Data.LOOKUP_KEY) + val indexName = contactCursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME) + val indexPhoto = contactCursor.getColumnIndex(ContactsContract.Contacts.PHOTO_ID) + while (contactCursor.moveToNext()) { + val contactId = contactCursor.getLong(indexId) + val contact = cache[contactId] + if (contact == null) Log.w(TAG, "Can't find contact with ID $contactId") else { + contact.setSystemContactInfo( + contactId, + contactCursor.getString(indexKey), + contactCursor.getString(indexName), + contactCursor.getLong(indexPhoto) + ) + systemContacts[contactId] = contact + } + } + contactCursor.close() + } + return systemContacts + } + + override fun findContactByIdFromSystem(id: Long, key: String): Contact? { + var contact: Contact? = null + val contentResolver = mContext.contentResolver + try { + val contentUri: Uri? = if (key != null) { + ContactsContract.Contacts.lookupContact( + contentResolver, + ContactsContract.Contacts.getLookupUri(id, key) + ) + } else { + ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id) + } + var result: Cursor? = null + if (contentUri != null) { + result = contentResolver.query(contentUri, CONTACT_PROJECTION, null, null, null) + } + if (result == null) { + return null + } + if (result.moveToFirst()) { + val indexId = result.getColumnIndex(ContactsContract.Data._ID) + val indexKey = result.getColumnIndex(ContactsContract.Data.LOOKUP_KEY) + val indexName = result.getColumnIndex(ContactsContract.Data.DISPLAY_NAME) + val indexPhoto = result.getColumnIndex(ContactsContract.Data.PHOTO_ID) + val indexStared = result.getColumnIndex(ContactsContract.Contacts.STARRED) + val contactId = result.getLong(indexId) + Log.d( + TAG, + "Contact name: " + result.getString(indexName) + " id:" + contactId + " key:" + result.getString( + indexKey + ) + ) + contact = Contact(net.jami.model.Uri.fromString(contentUri.toString())) + contact.setSystemContactInfo( + contactId, + result.getString(indexKey), + result.getString(indexName), + result.getLong(indexPhoto) + ) + if (result.getInt(indexStared) != 0) { + contact.setStared() + } + fillContactDetails(contact) + } + result.close() + } catch (e: Exception) { + Log.d(TAG, "findContactByIdFromSystem: Error while searching for contact id=$id", e) + } + if (contact == null) { + Log.d(TAG, "findContactByIdFromSystem: findById $id can't find contact.") + } + return contact!! + } + + private fun fillContactDetails(contact: Contact) { + val contentResolver = mContext.contentResolver + try { + val cursorPhones = contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + CONTACTS_PHONES_PROJECTION, ID_SELECTION, arrayOf(contact.id.toString()), null + ) + if (cursorPhones != null) { + val indexNumber = + cursorPhones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + val indexType = + cursorPhones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE) + val indexLabel = + cursorPhones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.LABEL) + while (cursorPhones.moveToNext()) { + contact.addNumber( + cursorPhones.getString(indexNumber), + cursorPhones.getInt(indexType), + cursorPhones.getString(indexLabel), + Phone.NumberType.TEL + ) + Log.d( + TAG, + "Phone:" + cursorPhones.getString( + cursorPhones.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + ) + ) + } + cursorPhones.close() + } + val baseUri = + ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, contact.id) + val targetUri = + Uri.withAppendedPath(baseUri, ContactsContract.Contacts.Data.CONTENT_DIRECTORY) + val cursorSip = contentResolver.query( + targetUri, + CONTACTS_SIP_PROJECTION, + ContactsContract.Data.MIMETYPE + "=? OR " + ContactsContract.Data.MIMETYPE + " =?", + arrayOf( + ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE, + ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE + ), + null + ) + if (cursorSip != null) { + val indexMime = cursorSip.getColumnIndex(ContactsContract.Data.MIMETYPE) + val indexSip = + cursorSip.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS) + val indexType = + cursorSip.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.TYPE) + val indexLabel = + cursorSip.getColumnIndex(ContactsContract.CommonDataKinds.SipAddress.LABEL) + while (cursorSip.moveToNext()) { + val contactMime = cursorSip.getString(indexMime) + val contactNumber = cursorSip.getString(indexSip) + if (!contactMime.contentEquals(ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE) || net.jami.model.Uri.fromString( + contactNumber + ).isHexId || "ring".equals( + cursorSip.getString(indexLabel), + ignoreCase = true + ) + ) { + contact.addNumber( + contactNumber, + cursorSip.getInt(indexType), + cursorSip.getString(indexLabel), + Phone.NumberType.SIP + ) + } + Log.d(TAG, "SIP phone:$contactNumber $contactMime ") + } + cursorSip.close() + } + } catch (e: Exception) { + Log.d(TAG, "fillContactDetails: Error while retrieving detail contact info", e) + } + } + + public override fun findContactBySipNumberFromSystem(number: String): Contact? { + var contact: Contact? = null + val contentResolver = mContext.contentResolver + try { + val result = contentResolver.query( + ContactsContract.Data.CONTENT_URI, + DATA_PROJECTION, + ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS + "=?" + " AND (" + ContactsContract.Data.MIMETYPE + "=? OR " + ContactsContract.Data.MIMETYPE + "=?)", + arrayOf( + number, + ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE, + ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE + ), + null + ) + if (result == null) { + Log.d(TAG, "findContactBySipNumberFromSystem: $number can't find contact.") + return Contact.buildSIP(net.jami.model.Uri.fromString(number)) + } + val indexId = result.getColumnIndex(ContactsContract.RawContacts.CONTACT_ID) + val indexKey = result.getColumnIndex(ContactsContract.Data.LOOKUP_KEY) + val indexName = result.getColumnIndex(ContactsContract.Data.DISPLAY_NAME) + val indexPhoto = result.getColumnIndex(ContactsContract.Data.PHOTO_ID) + val indexStared = result.getColumnIndex(ContactsContract.Contacts.STARRED) + if (result.moveToFirst()) { + val contactId = result.getLong(indexId) + contact = Contact(net.jami.model.Uri.fromString(number)) + contact.setSystemContactInfo( + contactId, + result.getString(indexKey), + result.getString(indexName), + result.getLong(indexPhoto) + ) + if (result.getInt(indexStared) != 0) { + contact.setStared() + } + fillContactDetails(contact) + } + result.close() + if (contact == null || contact.phones == null || contact.phones.isEmpty()) { + return null + } + } catch (e: Exception) { + Log.d( + TAG, + "findContactBySipNumberFromSystem: Error while searching for contact number=$number", + e + ) + } + return contact!! + } + + public override fun findContactByNumberFromSystem(number: String): Contact? { + var contact: Contact? = null + val contentResolver = mContext.contentResolver + try { + val uri = Uri.withAppendedPath( + ContactsContract.PhoneLookup.CONTENT_FILTER_URI, + Uri.encode(number) + ) + val result = contentResolver.query(uri, PHONELOOKUP_PROJECTION, null, null, null) + if (result == null) { + Log.d(TAG, "findContactByNumberFromSystem: $number can't find contact.") + return findContactBySipNumberFromSystem(number) + } + if (result.moveToFirst()) { + val indexId = result.getColumnIndex(ContactsContract.Contacts._ID) + val indexKey = result.getColumnIndex(ContactsContract.Data.LOOKUP_KEY) + val indexName = result.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME) + val indexPhoto = result.getColumnIndex(ContactsContract.Contacts.PHOTO_ID) + contact = Contact(net.jami.model.Uri.fromString(number)) + contact.setSystemContactInfo( + result.getLong(indexId), + result.getString(indexKey), + result.getString(indexName), + result.getLong(indexPhoto) + ) + fillContactDetails(contact) + Log.d( + TAG, + "findContactByNumberFromSystem: " + number + " found " + contact.displayName + ) + } + result.close() + } catch (e: Exception) { + Log.d(TAG, "findContactByNumber: Error while searching for contact number=$number", e) + } + if (contact == null) { + Log.d(TAG, "findContactByNumberFromSystem: $number can't find contact.") + contact = findContactBySipNumberFromSystem(number) + } + if (contact != null) contact.isFromSystem = true + return contact + } + + override fun loadContactData(contact: Contact, accountId: String): Completable { + if (!contact.detailsLoaded) { + val profile: Single<Tuple<String?, Any?>> = + if (contact.isFromSystem) loadSystemContactData(contact) + else loadVCardContactData(contact, accountId) + return profile + .doOnSuccess { p: Tuple<String?, Any?> -> contact.setProfile(p.first, p.second) } + .doOnError { e: Throwable? -> contact.setProfile(null, null) } + .ignoreElement() + .onErrorComplete() + } + return Completable.complete() + } + + override fun saveVCardContactData(contact: Contact, accountId: String, vcard: VCard) { + if (vcard != null) { + val profileData = VCardServiceImpl.readData(vcard) + contact.setProfile(profileData.first, profileData.second) + val filename = contact.primaryNumber + ".vcf" + VCardUtils.savePeerProfileToDisk( + vcard, accountId, filename, mContext.filesDir + ) + AvatarFactory.clearCache() + } + } + + override fun saveVCardContact(accountId: String, uri: String, displayName: String, picture: String): Single<VCard> { + return Single.fromCallable { + val vcard = VCardUtils.writeData(uri, displayName, Base64.decode(picture, Base64.DEFAULT)) + val filename = "$uri.vcf" + VCardUtils.savePeerProfileToDisk(vcard, accountId, filename, mContext.filesDir) + vcard + } + } + + private fun loadVCardContactData(contact: Contact, accountId: String): Single<Tuple<String?, Any?>> { + val id = contact.primaryNumber + return Single.fromCallable<VCard> { VCardUtils.loadPeerProfileFromDisk(mContext.filesDir, "$id.vcf", accountId) } + .map { vcard: VCard -> VCardServiceImpl.readData(vcard) } + .subscribeOn(Schedulers.computation()) + } + + private fun loadSystemContactData(contact: Contact): Single<Tuple<String?, Any?>> { + val contactName = contact.displayName + val photoURI = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, contact.id) + return AndroidFileUtils + .loadBitmap( + mContext, + Uri.withAppendedPath(photoURI, ContactsContract.Contacts.Photo.DISPLAY_PHOTO) + ) + .map { bitmap: Bitmap? -> Tuple<String?, Any?>(contactName, bitmap) } + .onErrorReturn { e: Throwable? -> Tuple(contactName, null) } + .subscribeOn(Schedulers.io()) + } + + fun loadConversationAvatar(context: Context, conversation: Conversation): Single<Drawable> { + return getLoadedContact(conversation.accountId, conversation.contacts, false) + .flatMap { AvatarFactory.getAvatar(context, conversation, false) } + } + + companion object { + private val TAG = ContactServiceImpl::class.java.simpleName + private val CONTACTS_SUMMARY_PROJECTION = arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Contacts.LOOKUP_KEY, + ContactsContract.Contacts.DISPLAY_NAME, + ContactsContract.Contacts.PHOTO_ID, + ContactsContract.Contacts.STARRED + ) + private val CONTACTS_DATA_PROJECTION = arrayOf( + ContactsContract.Data.CONTACT_ID, + ContactsContract.Data.MIMETYPE, + ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS, + ContactsContract.CommonDataKinds.SipAddress.TYPE, + ContactsContract.CommonDataKinds.SipAddress.LABEL, + ContactsContract.CommonDataKinds.Im.PROTOCOL, + ContactsContract.CommonDataKinds.Im.CUSTOM_PROTOCOL + ) + private val CONTACT_PROJECTION = arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Contacts.LOOKUP_KEY, + ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, + ContactsContract.Contacts.PHOTO_ID, + ContactsContract.Contacts.STARRED + ) + private val CONTACTS_PHONES_PROJECTION = arrayOf( + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.TYPE, + ContactsContract.CommonDataKinds.Phone.LABEL + ) + private val CONTACTS_SIP_PROJECTION = arrayOf( + ContactsContract.Data.MIMETYPE, + ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS, + ContactsContract.CommonDataKinds.SipAddress.TYPE, + ContactsContract.CommonDataKinds.SipAddress.LABEL + ) + private val DATA_PROJECTION = arrayOf( + ContactsContract.Data._ID, + ContactsContract.RawContacts.CONTACT_ID, + ContactsContract.Data.LOOKUP_KEY, + ContactsContract.Data.DISPLAY_NAME_PRIMARY, + ContactsContract.Data.PHOTO_ID, + ContactsContract.Data.PHOTO_THUMBNAIL_URI, + ContactsContract.Data.STARRED + ) + private val PHONELOOKUP_PROJECTION = arrayOf( + ContactsContract.PhoneLookup._ID, + ContactsContract.PhoneLookup.LOOKUP_KEY, + ContactsContract.PhoneLookup.PHOTO_ID, + ContactsContract.Contacts.DISPLAY_NAME_PRIMARY + ) + private const val ID_SELECTION = ContactsContract.CommonDataKinds.Phone.CONTACT_ID + "=?" + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/services/DataTransferService.java b/ring-android/app/src/main/java/cx/ring/services/DataTransferService.java index f8974fe85..148b540d8 100644 --- a/ring-android/app/src/main/java/cx/ring/services/DataTransferService.java +++ b/ring-android/app/src/main/java/cx/ring/services/DataTransferService.java @@ -32,13 +32,14 @@ import androidx.core.app.NotificationManagerCompat; import javax.inject.Inject; -import cx.ring.application.JamiApplication; +import dagger.hilt.android.AndroidEntryPoint; import net.jami.services.NotificationService; import java.util.HashSet; import java.util.Set; +@AndroidEntryPoint public class DataTransferService extends Service { private final String TAG = DataTransferService.class.getSimpleName(); public static final String ACTION_START = "startTransfer"; @@ -47,6 +48,7 @@ public class DataTransferService extends Service { @Inject NotificationService mNotificationService; + private NotificationManagerCompat notificationManager; private boolean started = false; @@ -101,7 +103,6 @@ public class DataTransferService extends Service { @Override public void onCreate() { Log.d(TAG, "OnCreate(), DataTransferService has been initialized"); - ((JamiApplication) getApplication()).getInjectionComponent().inject(this); notificationManager = NotificationManagerCompat.from(this); super.onCreate(); } diff --git a/ring-android/app/src/main/java/cx/ring/services/DeviceRuntimeServiceImpl.java b/ring-android/app/src/main/java/cx/ring/services/DeviceRuntimeServiceImpl.java deleted file mode 100644 index bdad5c9f8..000000000 --- a/ring-android/app/src/main/java/cx/ring/services/DeviceRuntimeServiceImpl.java +++ /dev/null @@ -1,255 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.services; - -import android.Manifest; -import android.content.Context; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.media.AudioManager; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.os.Build; -import android.provider.ContactsContract; -import android.system.ErrnoException; -import android.system.Os; -import android.util.Log; - -import androidx.core.content.ContextCompat; - -import net.jami.daemon.IntVect; -import net.jami.daemon.StringVect; -import net.jami.services.DeviceRuntimeService; -import net.jami.utils.FileUtils; -import net.jami.utils.StringUtils; - -import java.io.File; -import java.util.concurrent.ScheduledExecutorService; - -import javax.inject.Inject; -import javax.inject.Named; - -import cx.ring.application.JamiApplication; -import cx.ring.utils.AndroidFileUtils; -import cx.ring.utils.NetworkUtils; - -public class DeviceRuntimeServiceImpl extends DeviceRuntimeService { - - private static final String TAG = DeviceRuntimeServiceImpl.class.getSimpleName(); - private static final String[] PROFILE_PROJECTION = new String[]{ContactsContract.Profile._ID, - ContactsContract.Profile.DISPLAY_NAME_PRIMARY, - ContactsContract.Profile.PHOTO_ID}; - @Inject - protected Context mContext; - @Inject - @Named("DaemonExecutor") - ScheduledExecutorService mExecutor; - - private void copyAssets() { - File pluginsPath = new File(mContext.getFilesDir(), "plugins"); - Log.w(TAG, "Plugins: " + pluginsPath.getAbsolutePath()); - // Overwrite existing plugins folder in order to use newer plugins - AndroidFileUtils.copyAssetFolder(mContext.getAssets(), "plugins", pluginsPath); - } - - @Override - public void loadNativeLibrary() { - mExecutor.execute(() -> { - try { - System.loadLibrary("ring"); - } catch (Exception e) { - Log.e(TAG, "Could not load Jami library", e); - android.os.Process.killProcess(android.os.Process.myPid()); - System.exit(0); - } - }); - } - - @Override - public File provideFilesDir() { - return mContext.getFilesDir(); - } - - @Override - public File getFilePath(String filename) { - return AndroidFileUtils.getFilePath(mContext, filename); - } - - @Override - public File getConversationPath(String conversationId, String name) { - return AndroidFileUtils.getConversationPath(mContext, conversationId, name); - } - - @Override - public File getConversationPath(String accountId, String conversationId, String name) { - return AndroidFileUtils.getConversationPath(mContext, accountId, conversationId, name); - } - - @Override - public File getTemporaryPath(String conversationId, String name) { - return AndroidFileUtils.getTempPath(mContext, conversationId, name); - } - - @Override - public File getConversationDir(String conversationId) { - return AndroidFileUtils.getConversationDir(mContext, conversationId); - } - - @Override - public File getCacheDir() { - return mContext.getCacheDir(); - } - - @Override - public String getPushToken() { - return JamiApplication.getInstance().getPushToken(); - } - - private boolean isNetworkConnectedForType(int connectivityManagerType) { - NetworkInfo info = NetworkUtils.getNetworkInfo(mContext); - return (info != null && info.isConnected() && info.getType() == connectivityManagerType); - } - - @Override - public boolean isConnectedBluetooth() { - return isNetworkConnectedForType(ConnectivityManager.TYPE_BLUETOOTH); - } - - @Override - public boolean isConnectedWifi() { - return isNetworkConnectedForType(ConnectivityManager.TYPE_WIFI); - } - - @Override - public boolean isConnectedMobile() { - return isNetworkConnectedForType(ConnectivityManager.TYPE_MOBILE); - } - - @Override - public boolean isConnectedEthernet() { - return isNetworkConnectedForType(ConnectivityManager.TYPE_ETHERNET); - } - - @Override - public boolean hasVideoPermission() { - return checkPermission(Manifest.permission.CAMERA); - } - - @Override - public boolean hasAudioPermission() { - return checkPermission(Manifest.permission.RECORD_AUDIO); - } - - @Override - public boolean hasContactPermission() { - return checkPermission(Manifest.permission.READ_CONTACTS); - } - - @Override - public boolean hasCallLogPermission() { - return checkPermission(Manifest.permission.WRITE_CALL_LOG); - } - - @Override - public boolean hasWriteExternalStoragePermission() { - return checkPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE); - } - - @Override - public boolean hasGalleryPermission() { - return checkPermission(Manifest.permission.READ_EXTERNAL_STORAGE); - } - - @Override - public String getProfileName() { - Cursor mProfileCursor = mContext.getContentResolver().query(ContactsContract.Profile.CONTENT_URI, PROFILE_PROJECTION, null, null, null); - if (mProfileCursor != null) { - if (mProfileCursor.moveToFirst()) { - String profileName = mProfileCursor.getString(mProfileCursor.getColumnIndex(ContactsContract.Profile.DISPLAY_NAME_PRIMARY)); - mProfileCursor.close(); - return profileName; - } - mProfileCursor.close(); - } - return null; - } - - @Override - public boolean hardLinkOrCopy(File source, File dest) { - try { - Os.link(source.getAbsolutePath(), dest.getAbsolutePath()); - } catch (ErrnoException e) { - Log.w(TAG, "Can't create hardlink, copying instead: " + e.getMessage()); - return FileUtils.copyFile(source, dest); - } - return true; - } - - private boolean checkPermission(String permission) { - return ContextCompat.checkSelfPermission(mContext, permission) == PackageManager.PERMISSION_GRANTED; - } - - @Override - public void getHardwareAudioFormat(IntVect ret) { - int sampleRate = 44100; - int bufferSize = 64; - try { - AudioManager am = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); - sampleRate = Integer.parseInt(am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)); - bufferSize = Integer.parseInt(am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)); - } catch (Exception e) { - Log.w(getClass().getName(), "Failed to read native OpenSL config", e); - } - ret.add(sampleRate); - ret.add(bufferSize); - Log.d(TAG, "getHardwareAudioFormat: " + sampleRate + " " + bufferSize); - } - - @Override - public void getAppDataPath(String name, StringVect ret) { - if (name == null || ret == null) { - return; - } - - switch (name) { - case "files": - ret.add(mContext.getFilesDir().getAbsolutePath()); - break; - case "cache": - ret.add(mContext.getCacheDir().getAbsolutePath()); - break; - default: - ret.add(mContext.getDir(name, Context.MODE_PRIVATE).getAbsolutePath()); - break; - } - } - - @Override - public void getDeviceName(StringVect ret) { - String manufacturer = Build.MANUFACTURER; - String model = Build.MODEL; - if (model.startsWith(manufacturer)) { - ret.add(StringUtils.capitalize(model)); - } else { - ret.add(StringUtils.capitalize(manufacturer) + " " + model); - } - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/services/DeviceRuntimeServiceImpl.kt b/ring-android/app/src/main/java/cx/ring/services/DeviceRuntimeServiceImpl.kt new file mode 100644 index 000000000..06bfafac0 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/services/DeviceRuntimeServiceImpl.kt @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.services + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioManager +import android.net.ConnectivityManager +import android.os.Build +import android.os.Process +import android.provider.ContactsContract +import android.system.ErrnoException +import android.system.Os +import android.util.Log +import androidx.core.content.ContextCompat +import cx.ring.application.JamiApplication.Companion.instance +import cx.ring.utils.AndroidFileUtils.copyAssetFolder +import cx.ring.utils.AndroidFileUtils.getConversationDir +import cx.ring.utils.AndroidFileUtils.getConversationPath +import cx.ring.utils.AndroidFileUtils.getFilePath +import cx.ring.utils.AndroidFileUtils.getTempPath +import cx.ring.utils.NetworkUtils +import net.jami.daemon.IntVect +import net.jami.daemon.StringVect +import net.jami.services.DeviceRuntimeService +import net.jami.services.LogService +import net.jami.utils.FileUtils +import net.jami.utils.StringUtils +import java.io.File +import java.util.concurrent.ScheduledExecutorService +import kotlin.system.exitProcess + +class DeviceRuntimeServiceImpl( + private val mContext: Context, + private val mExecutor: ScheduledExecutorService, + private val logService: LogService +) : DeviceRuntimeService() { + private fun copyAssets() { + val pluginsPath = File(mContext.filesDir, "plugins") + Log.w(TAG, "Plugins: " + pluginsPath.absolutePath) + // Overwrite existing plugins folder in order to use newer plugins + copyAssetFolder(mContext.assets, "plugins", pluginsPath) + } + + override fun loadNativeLibrary() { + logService.w(TAG, "loadNativeLibrary") + mExecutor.execute { + Log.w(TAG, "System.loadLibrary") + try { + System.loadLibrary("ring") + } catch (e: Exception) { + Log.e(TAG, "Could not load Jami library", e) + Process.killProcess(Process.myPid()) + exitProcess(0) + } + } + } + + override fun provideFilesDir(): File { + return mContext.filesDir + } + + override fun getFilePath(filename: String): File { + return getFilePath(mContext, filename) + } + + override fun getConversationPath(conversationId: String, name: String): File { + return getConversationPath(mContext, conversationId, name) + } + + override fun getConversationPath( + accountId: String, + conversationId: String, + name: String + ): File { + return getConversationPath(mContext, accountId, conversationId, name) + } + + override fun getTemporaryPath(conversationId: String, name: String): File { + return getTempPath(mContext, conversationId, name) + } + + override fun getConversationDir(conversationId: String): File { + return getConversationDir(mContext, conversationId) + } + + override fun getCacheDir(): File { + return mContext.cacheDir + } + + override fun getPushToken(): String? { + return instance?.pushToken + } + + private fun isNetworkConnectedForType(connectivityManagerType: Int): Boolean { + val info = NetworkUtils.getNetworkInfo(mContext) + return info != null && info.isConnected && info.type == connectivityManagerType + } + + override fun isConnectedBluetooth(): Boolean { + return isNetworkConnectedForType(ConnectivityManager.TYPE_BLUETOOTH) + } + + override fun isConnectedWifi(): Boolean { + return isNetworkConnectedForType(ConnectivityManager.TYPE_WIFI) + } + + override fun isConnectedMobile(): Boolean { + return isNetworkConnectedForType(ConnectivityManager.TYPE_MOBILE) + } + + override fun isConnectedEthernet(): Boolean { + return isNetworkConnectedForType(ConnectivityManager.TYPE_ETHERNET) + } + + override fun hasVideoPermission(): Boolean { + return checkPermission(Manifest.permission.CAMERA) + } + + override fun hasAudioPermission(): Boolean { + return checkPermission(Manifest.permission.RECORD_AUDIO) + } + + override fun hasContactPermission(): Boolean { + return checkPermission(Manifest.permission.READ_CONTACTS) + } + + override fun hasCallLogPermission(): Boolean { + return checkPermission(Manifest.permission.WRITE_CALL_LOG) + } + + override fun hasWriteExternalStoragePermission(): Boolean { + return checkPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + + override fun hasGalleryPermission(): Boolean { + return checkPermission(Manifest.permission.READ_EXTERNAL_STORAGE) + } + + override fun getProfileName(): String? { + mContext.contentResolver.query( + ContactsContract.Profile.CONTENT_URI, + PROFILE_PROJECTION, + null, + null, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + return cursor.getString(cursor.getColumnIndex(ContactsContract.Profile.DISPLAY_NAME_PRIMARY)) + } + } + return null + } + + override fun hardLinkOrCopy(source: File, dest: File): Boolean { + try { + Os.link(source.absolutePath, dest.absolutePath) + } catch (e: ErrnoException) { + Log.w(TAG, "Can't create hardlink, copying instead: " + e.message) + return FileUtils.copyFile(source, dest) + } + return true + } + + private fun checkPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission( + mContext, + permission + ) == PackageManager.PERMISSION_GRANTED + } + + override fun getHardwareAudioFormat(ret: IntVect) { + var sampleRate = 44100 + var bufferSize = 64 + try { + val am = mContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + sampleRate = am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE).toInt() + bufferSize = am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER).toInt() + } catch (e: Exception) { + Log.w(javaClass.name, "Failed to read native OpenSL config", e) + } + ret.add(sampleRate) + ret.add(bufferSize) + Log.d(TAG, "getHardwareAudioFormat: $sampleRate $bufferSize") + } + + override fun getAppDataPath(name: String, ret: StringVect) { + when (name) { + "files" -> ret.add(mContext.filesDir.absolutePath) + "cache" -> ret.add(mContext.cacheDir.absolutePath) + else -> ret.add(mContext.getDir(name, Context.MODE_PRIVATE).absolutePath) + } + } + + override fun getDeviceName(ret: StringVect) { + val manufacturer = Build.MANUFACTURER + val model = Build.MODEL + if (model.startsWith(manufacturer)) { + ret.add(StringUtils.capitalize(model)) + } else { + ret.add(StringUtils.capitalize(manufacturer) + " " + model) + } + } + + companion object { + private val TAG = DeviceRuntimeServiceImpl::class.simpleName!! + private val PROFILE_PROJECTION = arrayOf( + ContactsContract.Profile._ID, + ContactsContract.Profile.DISPLAY_NAME_PRIMARY, + ContactsContract.Profile.PHOTO_ID + ) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.java b/ring-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.java deleted file mode 100644 index e5ec57866..000000000 --- a/ring-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.java +++ /dev/null @@ -1,788 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.services; - -import android.bluetooth.BluetoothHeadset; -import android.content.Context; -import android.content.pm.PackageManager; -import android.graphics.Point; -import android.media.AudioDeviceInfo; -import android.media.AudioManager; -import android.media.MediaRecorder; -import android.media.projection.MediaProjection; -import android.os.Build; -import android.view.SurfaceHolder; -import android.view.TextureView; -import android.view.WindowManager; - -import androidx.annotation.Nullable; -import androidx.media.AudioAttributesCompat; -import androidx.media.AudioFocusRequestCompat; -import androidx.media.AudioManagerCompat; - -import java.io.File; -import java.lang.ref.WeakReference; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import net.jami.daemon.IntVect; -import net.jami.daemon.JamiService; -import net.jami.daemon.UintVect; -import net.jami.model.Conference; -import net.jami.model.Call; -import net.jami.model.Call.CallStatus; -import cx.ring.utils.BluetoothWrapper; - -import net.jami.services.HardwareService; -import net.jami.utils.Log; -import cx.ring.utils.Ringer; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Observable; - -import net.jami.utils.Tuple; - -public class HardwareServiceImpl extends HardwareService implements AudioManager.OnAudioFocusChangeListener, BluetoothWrapper.BluetoothChangeListener { - - private static final Point VIDEO_SIZE_LOW = new Point(320, 240); - private static final Point VIDEO_SIZE_DEFAULT = new Point(720, 480); - private static final Point VIDEO_SIZE_HD = new Point(1280, 720); - private static final Point VIDEO_SIZE_FULL_HD = new Point(1920, 1080); - private static final Point VIDEO_SIZE_ULTRA_HD = new Point(3840, 2160); - - private static final String TAG = HardwareServiceImpl.class.getSimpleName(); - private static WeakReference<TextureView> mCameraPreviewSurface = new WeakReference<>(null); - private static WeakReference<Conference> mCameraPreviewCall = new WeakReference<>(null); - - private static final Map<String, WeakReference<SurfaceHolder>> videoSurfaces = Collections.synchronizedMap(new HashMap<>()); - private final Map<String, Shm> videoInputs = new HashMap<>(); - private final Context mContext; - private final CameraService cameraService; - private final Ringer mRinger; - private final AudioManager mAudioManager; - private BluetoothWrapper mBluetoothWrapper; - private AudioFocusRequestCompat currentFocus = null; - - private String mCapturingId = null; - private boolean mIsCapturing = false; - private boolean mIsScreenSharing = false; - - private boolean mShouldCapture = false; - private boolean mShouldSpeakerphone = false; - private final boolean mHasSpeakerPhone; - private boolean mIsChoosePlugin = false; - private String mMediaHandlerId = null; - private String mPluginCallId = null; - - public HardwareServiceImpl(Context context) { - mContext = context; - mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); - mHasSpeakerPhone = hasSpeakerphone(); - mRinger = new Ringer(mContext); - cameraService = new CameraService(mContext); - } - - public Completable initVideo() { - Log.i(TAG, "initVideo()"); - return cameraService.init(); - } - - public Observable<Tuple<Integer, Integer>> getMaxResolutions() { - return cameraService.getMaxResolutions(); - } - - public boolean isVideoAvailable() { - return mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) || cameraService.hasCamera(); - } - - public boolean hasMicrophone() { - PackageManager pm = mContext.getPackageManager(); - boolean hasMicrophone = pm.hasSystemFeature(PackageManager.FEATURE_MICROPHONE); - - if (!hasMicrophone) { - MediaRecorder recorder = new MediaRecorder(); - File testFile = new File(mContext.getCacheDir(), "MediaUtil#micAvailTestFile"); - try { - recorder.setAudioSource(MediaRecorder.AudioSource.MIC); - recorder.setOutputFormat(MediaRecorder.OutputFormat.DEFAULT); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.DEFAULT); - recorder.setOutputFile(testFile.getAbsolutePath()); - recorder.prepare(); - recorder.start(); - hasMicrophone = true; - } catch (IllegalStateException e) { - // Microphone is already in use - hasMicrophone = true; - } catch (Exception exception) { - hasMicrophone = false; - } finally { - recorder.release(); - testFile.delete(); - } - } - - return hasMicrophone; - } - - @Override - public boolean isSpeakerPhoneOn() { - return mAudioManager.isSpeakerphoneOn(); - } - - private final AudioFocusRequestCompat RINGTONE_REQUEST = new AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT) - .setAudioAttributes(new AudioAttributesCompat.Builder() - .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributesCompat.USAGE_NOTIFICATION_RINGTONE) - .setLegacyStreamType(AudioManager.STREAM_RING) - .build()) - .setOnAudioFocusChangeListener(this) - .build(); - - private final AudioFocusRequestCompat CALL_REQUEST = new AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT) - .setAudioAttributes(new AudioAttributesCompat.Builder() - .setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH) - .setUsage(AudioAttributesCompat.USAGE_VOICE_COMMUNICATION) - .setLegacyStreamType(AudioManager.STREAM_VOICE_CALL) - .build()) - .setOnAudioFocusChangeListener(this) - .build(); - - private void getFocus(AudioFocusRequestCompat request) { - if (currentFocus == request) - return; - if (currentFocus != null) { - AudioManagerCompat.abandonAudioFocusRequest(mAudioManager, currentFocus); - currentFocus = null; - } - if (request != null && AudioManagerCompat.requestAudioFocus(mAudioManager, request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - currentFocus = request; - } - } - - @Override - synchronized public void updateAudioState(final Call.CallStatus state, final boolean incomingCall, final boolean isOngoingVideo) { - Log.d(TAG, "updateAudioState: Call state updated to " + state + " Call is incoming: " + incomingCall + " Call is video: " + isOngoingVideo); - boolean callEnded = state.equals(CallStatus.HUNGUP) || state.equals(CallStatus.FAILURE) || state.equals(CallStatus.OVER); - try { - if (mBluetoothWrapper == null && !callEnded) { - mBluetoothWrapper = new BluetoothWrapper(mContext, this); - } - switch (state) { - case RINGING: - if (incomingCall) - startRinging(); - getFocus(RINGTONE_REQUEST); - if (incomingCall) { - // ringtone for incoming calls - mAudioManager.setMode(AudioManager.MODE_RINGTONE); - setAudioRouting(true); - mShouldSpeakerphone = isOngoingVideo; - } else - setAudioRouting(isOngoingVideo); - break; - case CURRENT: - stopRinging(); - getFocus(CALL_REQUEST); - mAudioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); - setAudioRouting(isOngoingVideo); - break; - case HOLD: - case UNHOLD: - case INACTIVE: - break; - default: - closeAudioState(); - break; - } - } catch (Exception e) { - Log.e(TAG, "Error updating audio state", e); - } - } - - /* - This is required in the case where a call is incoming. If you have an incoming call, and no bluetooth device is connected, the ringer should always be played through the speaker. - However, this results in the call starting in a state where the speaker is always on and the UI is in an incorrect state. - If it is a bluetooth device, it takes priority and does not play on speaker regardless. Otherwise, it returns mShouldSpeakerphone which was updated in updateaudiostate. - */ - @Override - public boolean shouldPlaySpeaker() { - if(mBluetoothWrapper != null && mBluetoothWrapper.canBluetooth() && mBluetoothWrapper.isBTHeadsetConnected() ) - return false; - else - return mShouldSpeakerphone; - } - - @Override - synchronized public void closeAudioState() { - stopRinging(); - abandonAudioFocus(); - } - - @Override - public void startRinging() { - mRinger.ring(); - } - - @Override - public void stopRinging() { - mRinger.stopRing(); - } - - @Override - public void onAudioFocusChange(int arg0) { - Log.i(TAG, "onAudioFocusChange " + arg0); - } - - @Override - synchronized public void abandonAudioFocus() { - if (currentFocus != null) { - AudioManagerCompat.abandonAudioFocusRequest(mAudioManager, currentFocus); - currentFocus = null; - } - if (mAudioManager.isSpeakerphoneOn()) { - mAudioManager.setSpeakerphoneOn(false); - } - mAudioManager.setMode(AudioManager.MODE_NORMAL); - - if (mBluetoothWrapper != null) { - mBluetoothWrapper.unregister(); - mBluetoothWrapper.setBluetoothOn(false); - mBluetoothWrapper = null; - } - } - - private void setAudioRouting(boolean requestSpeakerOn) { - mShouldSpeakerphone = requestSpeakerOn; - // prioritize bluetooth by checking for bluetooth device first - if (mBluetoothWrapper != null && mBluetoothWrapper.canBluetooth() && mBluetoothWrapper.isBTHeadsetConnected()) { - routeToBTHeadset(); - } else if (!mAudioManager.isWiredHeadsetOn() && mHasSpeakerPhone && mShouldSpeakerphone) { - routeToSpeaker(); - } else { - resetAudio(); - } - } - - private boolean hasSpeakerphone() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - // Check FEATURE_AUDIO_OUTPUT to guard against false positives. - PackageManager packageManager = mContext.getPackageManager(); - if (!packageManager.hasSystemFeature(PackageManager.FEATURE_AUDIO_OUTPUT)) { - return false; - } - - AudioDeviceInfo[] devices = mAudioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS); - for (AudioDeviceInfo device : devices) { - if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) { - return true; - } - } - return false; - } - return true; - } - - /** - * Routes audio to a bluetooth headset. - */ - private void routeToBTHeadset() { - Log.d(TAG, "routeToBTHeadset: Try to enable bluetooth"); - int oldMode = mAudioManager.getMode(); - mAudioManager.setMode(AudioManager.MODE_NORMAL); - mAudioManager.setSpeakerphoneOn(false); - mBluetoothWrapper.setBluetoothOn(true); - mAudioManager.setMode(oldMode); - audioStateSubject.onNext(new AudioState(AudioOutput.BLUETOOTH, mBluetoothWrapper.getDeviceName())); - } - - /** - * Routes audio to the device's speaker and takes into account whether the transition is coming from bluetooth. - */ - private void routeToSpeaker() { - // if we are returning from bluetooth mode, switch to mode normal, otherwise, we switch to mode in communication - if (mAudioManager.isBluetoothScoOn()) { - int oldMode = mAudioManager.getMode(); - mAudioManager.setMode(AudioManager.MODE_NORMAL); - mBluetoothWrapper.setBluetoothOn(false); - mAudioManager.setMode(oldMode); - } - mAudioManager.setSpeakerphoneOn(true); - audioStateSubject.onNext(STATE_SPEAKERS); - } - - /** - * Returns to earpiece audio - */ - private void resetAudio() { - if (mBluetoothWrapper != null) - mBluetoothWrapper.setBluetoothOn(false); - mAudioManager.setSpeakerphoneOn(false); - audioStateSubject.onNext(STATE_INTERNAL); - } - - @Override - synchronized public void toggleSpeakerphone(boolean checked) { - JamiService.setAudioPlugin(JamiService.getCurrentAudioOutputPlugin()); - mShouldSpeakerphone = checked; - Log.w(TAG, "toggleSpeakerphone setSpeakerphoneOn " + checked); - if (mHasSpeakerPhone && checked) { - routeToSpeaker(); - } else if (mBluetoothWrapper != null && mBluetoothWrapper.canBluetooth() && mBluetoothWrapper.isBTHeadsetConnected()) { - routeToBTHeadset(); - } else { - resetAudio(); - } - } - - @Override - synchronized public void onBluetoothStateChanged(int status) { - Log.d(TAG, "bluetoothStateChanged to: " + status); - BluetoothEvent event = new BluetoothEvent(); - if (status == BluetoothHeadset.STATE_AUDIO_CONNECTED) { - Log.d(TAG, "BluetoothHeadset Connected"); - event.connected = true; - } else if (status == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) { - Log.d(TAG, "BluetoothHeadset Disconnected"); - event.connected = false; - if (mShouldSpeakerphone) - routeToSpeaker(); - } - bluetoothEvents.onNext(event); - } - - public void decodingStarted(String id, String shmPath, int width, int height, boolean isMixer) { - Log.i(TAG, "decodingStarted() " + id + " " + width + "x" + height); - Shm shm = new Shm(); - shm.id = id; - shm.w = width; - shm.h = height; - videoInputs.put(id, shm); - WeakReference<SurfaceHolder> weakSurfaceHolder = videoSurfaces.get(id); - if (weakSurfaceHolder != null) { - SurfaceHolder holder = weakSurfaceHolder.get(); - if (holder != null) { - shm.window = startVideo(id, holder.getSurface(), width, height); - - if (shm.window == 0) { - Log.i(TAG, "DJamiService.decodingStarted() no window !"); - - VideoEvent event = new VideoEvent(); - event.start = true; - event.callId = shm.id; - videoEvents.onNext(event); - return; - } - - VideoEvent event = new VideoEvent(); - event.callId = shm.id; - event.started = true; - event.w = shm.w; - event.h = shm.h; - videoEvents.onNext(event); - } - } - } - - @Override - public void decodingStopped(String id, String shmPath, boolean isMixer) { - Log.i(TAG, "decodingStopped() " + id); - Shm shm = videoInputs.remove(id); - if (shm == null) { - return; - } - if (shm.window != 0) { - try { - stopVideo(shm.id, shm.window); - } catch (Exception e) { - Log.e(TAG, "decodingStopped error" + e); - } - shm.window = 0; - } - } - - @Override - public void getCameraInfo(String camId, IntVect formats, UintVect sizes, UintVect rates) { - // Use a larger resolution for Android 6.0+, 64 bits devices - final boolean useLargerSize = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && (Build.SUPPORTED_64_BIT_ABIS.length > 0 || mPreferenceService.isHardwareAccelerationEnabled()); - //int MIN_WIDTH = useLargerSize ? (useHD ? VIDEO_WIDTH_HD : VIDEO_WIDTH) : VIDEO_WIDTH_MIN; - Point minVideoSize; - if (useLargerSize) - minVideoSize = parseResolution(mPreferenceService.getResolution()); - else - minVideoSize = VIDEO_SIZE_LOW; - cameraService.getCameraInfo(camId, formats, sizes, rates, minVideoSize); - } - - private Point parseResolution(int resolution) { - switch(resolution) { - case 480: - return VIDEO_SIZE_DEFAULT; - case 720: - return VIDEO_SIZE_HD; - case 1080: - return VIDEO_SIZE_FULL_HD; - case 2160: - return VIDEO_SIZE_ULTRA_HD; - default: - return VIDEO_SIZE_HD; - } - } - - @Override - public void setParameters(String camId, int format, int width, int height, int rate) { - Log.d(TAG, "setParameters: " + camId + ", " + format + ", " + width + ", " + height + ", " + rate); - WindowManager windowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE); - cameraService.setParameters(camId, format, width, height, rate, windowManager.getDefaultDisplay().getRotation()); - } - - public boolean startScreenShare(Object projection) { - MediaProjection mediaProjection = (MediaProjection) projection; - if (mIsCapturing) { - endCapture(); - } - if (!mIsScreenSharing && mediaProjection != null) { - mIsScreenSharing = true; - mediaProjection.registerCallback(new MediaProjection.Callback(){ - @Override - public void onStop() { - stopScreenShare(); - } - }, cameraService.getVideoHandler()); - if (!cameraService.startScreenSharing(mediaProjection, mContext.getResources().getDisplayMetrics())) { - mIsScreenSharing = false; - mediaProjection.stop(); - return false; - } - return true; - } else { - return false; - } - } - - public void stopScreenShare() { - if (mIsScreenSharing) { - cameraService.stopScreenSharing(); - mIsScreenSharing = false; - if (mShouldCapture) - startCapture(mCapturingId); - } - } - - public void startMediaHandler(String mediaHandlerId) { - mIsChoosePlugin = true; - mMediaHandlerId = mediaHandlerId; - } - - private void toggleMediaHandler(String callId) { - if (mMediaHandlerId != null) - JamiService.toggleCallMediaHandler(mMediaHandlerId, callId, true); - } - - public void stopMediaHandler() { - mIsChoosePlugin = false; - mMediaHandlerId = null; - } - - @Override - public void startCapture(@Nullable String camId) { - if (mIsScreenSharing) { - cameraService.stopScreenSharing(); - mIsScreenSharing = false; - } - mShouldCapture = true; - if (mIsCapturing && mCapturingId != null && mCapturingId.equals(camId)) { - return; - } - if (camId == null) { - camId = mCapturingId != null ? mCapturingId : cameraService.switchInput(true); - } - CameraService.VideoParams videoParams = cameraService.getParams(camId); - if (videoParams == null) { - Log.w(TAG, "startCapture: no video parameters "); - return; - } - final TextureView surface = mCameraPreviewSurface.get(); - if (surface == null) { - Log.w(TAG, "Can't start capture: no surface registered."); - cameraService.setPreviewParams(videoParams); - VideoEvent event = new VideoEvent(); - event.start = true; - videoEvents.onNext(event); - return; - } - final Conference conf = mCameraPreviewCall.get(); - boolean useHardwareCodec = mPreferenceService.isHardwareAccelerationEnabled() && (conf == null || !conf.isConference()) && !mIsChoosePlugin; - if (conf != null && useHardwareCodec) { - Call call = conf.getCall(); - if (call != null) { - call.setDetails(JamiService.getCallDetails(call.getDaemonIdString()).toNative()); - videoParams.codec = call.getVideoCodec(); - } else { - videoParams.codec = null; - } - } - Log.w(TAG, "startCapture: call " + camId + " " + videoParams.codec + " useHardwareCodec:" + useHardwareCodec + " bitrate:" + mPreferenceService.getBitrate()); - - mIsCapturing = true; - mCapturingId = videoParams.id; - Log.d(TAG, "startCapture: startCapture " + videoParams.id + " " + videoParams.width + "x" + videoParams.height + " rot" + videoParams.rotation); - - mUiScheduler.scheduleDirect(() -> cameraService.openCamera(videoParams, surface, - new CameraService.CameraListener() { - @Override - public void onOpened() { - String currentCall = conf != null ? conf.getId() : null; - if (currentCall == null) - return; - if (mPluginCallId != null && !mPluginCallId.equals(currentCall)) { - JamiService.toggleCallMediaHandler(mMediaHandlerId, currentCall, false); - mIsChoosePlugin = false; - mMediaHandlerId = null; - mPluginCallId = null; - } - else if (mIsChoosePlugin && mMediaHandlerId != null) { - mPluginCallId = currentCall; - toggleMediaHandler(currentCall); - } - } - - @Override - public void onError() { - stopCapture(); - } - }, - useHardwareCodec, - mPreferenceService.getResolution(), - mPreferenceService.getBitrate())); - cameraService.setPreviewParams(videoParams); - VideoEvent event = new VideoEvent(); - event.started = true; - event.w = videoParams.width; - event.h = videoParams.height; - event.rot = videoParams.rotation; - videoEvents.onNext(event); - } - - @Override - public void stopCapture() { - Log.d(TAG, "stopCapture: " + cameraService.isOpen()); - mShouldCapture = false; - endCapture(); - if (mIsScreenSharing) { - cameraService.stopScreenSharing(); - mIsScreenSharing = false; - } - } - - public void requestKeyFrame() { - cameraService.requestKeyFrame(); - } - - public void setBitrate(String device, int bitrate) { - cameraService.setBitrate(bitrate); - } - - public void endCapture() { - if (cameraService.isOpen()) { - //final CameraService.VideoParams params = previewParams; - cameraService.closeCamera(); - VideoEvent event = new VideoEvent(); - event.started = false; - //event.w = params.width; - //event.h = params.height; - videoEvents.onNext(event); - } - mIsCapturing = false; - } - - @Override - public void addVideoSurface(String id, Object holder) { - if (!(holder instanceof SurfaceHolder)) { - return; - } - - Log.w(TAG, "addVideoSurface " + id); - - Shm shm = videoInputs.get(id); - WeakReference<SurfaceHolder> surfaceHolder = new WeakReference<>((SurfaceHolder) holder); - videoSurfaces.put(id, surfaceHolder); - if (shm != null && shm.window == 0) { - shm.window = startVideo(shm.id, surfaceHolder.get().getSurface(), shm.w, shm.h); - } - - if (shm == null || shm.window == 0) { - Log.i(TAG, "DJamiService.addVideoSurface() no window !"); - - VideoEvent event = new VideoEvent(); - event.start = true; - videoEvents.onNext(event); - return; - } - - VideoEvent event = new VideoEvent(); - event.callId = shm.id; - event.started = true; - event.w = shm.w; - event.h = shm.h; - videoEvents.onNext(event); - } - - @Override - public void updateVideoSurfaceId(String currentId, String newId) - { - Log.w(TAG, "updateVideoSurfaceId " + currentId + " " + newId); - - WeakReference<SurfaceHolder> surfaceHolder = videoSurfaces.get(currentId); - if (surfaceHolder == null) { - return; - } - SurfaceHolder surface = surfaceHolder.get(); - - Shm shm = videoInputs.get(currentId); - if (shm != null && shm.window != 0) { - try { - stopVideo(shm.id, shm.window); - } catch (Exception e) { - Log.e(TAG, "removeVideoSurface error" + e); - } - shm.window = 0; - } - videoSurfaces.remove(currentId); - if (surface != null) { - addVideoSurface(newId, surface); - } - } - - @Override - public void addPreviewVideoSurface(Object oholder, Conference conference) { - if (!(oholder instanceof TextureView)) { - return; - } - TextureView holder = (TextureView) oholder; - Log.w(TAG, "addPreviewVideoSurface " + holder.hashCode() + " mCapturingId " + mCapturingId); - if (mCameraPreviewSurface.get() == oholder) - return; - mCameraPreviewSurface = new WeakReference<>(holder); - mCameraPreviewCall = new WeakReference<>(conference); - if (mShouldCapture && !mIsCapturing) { - startCapture(mCapturingId); - } - } - - @Override - public void updatePreviewVideoSurface(Conference conference) { - Conference old = mCameraPreviewCall.get(); - mCameraPreviewCall = new WeakReference<>(conference); - if (old != conference && mIsCapturing) { - String id = mCapturingId; - stopCapture(); - startCapture(id); - } - } - - @Override - public void removeVideoSurface(String id) { - Log.i(TAG, "removeVideoSurface " + id); - videoSurfaces.remove(id); - Shm shm = videoInputs.get(id); - if (shm == null) { - return; - } - if (shm.window != 0) { - try { - stopVideo(shm.id, shm.window); - } catch (Exception e) { - Log.e(TAG, "removeVideoSurface error" + e); - } - - shm.window = 0; - } - - VideoEvent event = new VideoEvent(); - event.callId = shm.id; - event.started = false; - videoEvents.onNext(event); - } - - @Override - public void removePreviewVideoSurface() { - Log.w(TAG, "removePreviewVideoSurface"); - mCameraPreviewSurface.clear(); - } - - @Override - public void switchInput(String id, boolean setDefaultCamera) { - Log.w(TAG, "switchInput " + id); - mCapturingId = cameraService.switchInput(setDefaultCamera); - switchInput(id, "camera://" + mCapturingId); - } - - @Override - public void setPreviewSettings() { - setPreviewSettings(cameraService.getPreviewSettings()); - } - - @Override - public int getCameraCount() { - return cameraService.getCameraCount(); - } - - @Override - public boolean hasCamera() { - return cameraService.hasCamera(); - } - - @Override - public boolean isPreviewFromFrontCamera() { - return cameraService.isPreviewFromFrontCamera(); - } - - @Override - public void setDeviceOrientation(int rotation) { - cameraService.setOrientation(rotation); - if (mCapturingId != null) { - CameraService.VideoParams videoParams = cameraService.getParams(mCapturingId); - VideoEvent event = new VideoEvent(); - event.started = true; - event.w = videoParams.width; - event.h = videoParams.height; - event.rot = videoParams.rotation; - videoEvents.onNext(event); - } - } - - @Override - protected List<String> getVideoDevices() { - return cameraService.getCameraIds(); - } - - private static class Shm { - String id; - int w, h; - long window = 0; - } - - @Override - public void unregisterCameraDetectionCallback() { - cameraService.unregisterCameraDetectionCallback(); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.kt b/ring-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.kt new file mode 100644 index 000000000..fe7abca95 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/services/HardwareServiceImpl.kt @@ -0,0 +1,695 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.services + +import android.bluetooth.BluetoothHeadset +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Point +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.media.AudioManager.OnAudioFocusChangeListener +import android.media.MediaRecorder +import android.media.projection.MediaProjection +import android.os.Build +import android.view.SurfaceHolder +import android.view.TextureView +import android.view.WindowManager +import androidx.media.AudioAttributesCompat +import androidx.media.AudioFocusRequestCompat +import androidx.media.AudioManagerCompat +import cx.ring.services.CameraService.CameraListener +import cx.ring.utils.BluetoothWrapper +import cx.ring.utils.BluetoothWrapper.BluetoothChangeListener +import cx.ring.utils.Ringer +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Scheduler +import net.jami.daemon.IntVect +import net.jami.daemon.JamiService +import net.jami.daemon.UintVect +import net.jami.model.Call.CallStatus +import net.jami.model.Conference +import net.jami.services.HardwareService +import net.jami.services.PreferencesService +import net.jami.utils.Log +import net.jami.utils.Tuple +import java.io.File +import java.lang.ref.WeakReference +import java.util.* +import java.util.concurrent.ScheduledExecutorService + +class HardwareServiceImpl( + private val mContext: Context, + executor: ScheduledExecutorService, + preferenceService: PreferencesService, + uiScheduler: Scheduler +) : HardwareService(executor, preferenceService, uiScheduler), OnAudioFocusChangeListener, BluetoothChangeListener { + private val videoInputs: MutableMap<String?, Shm> = HashMap() + private val cameraService = CameraService(mContext) + private val mRinger = Ringer(mContext) + private val mAudioManager: AudioManager = mContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private var mBluetoothWrapper: BluetoothWrapper? = null + private var currentFocus: AudioFocusRequestCompat? = null + private var mCapturingId: String? = null + private var mIsCapturing = false + private var mIsScreenSharing = false + private var mShouldCapture = false + private var mShouldSpeakerphone = false + private val mHasSpeakerPhone: Boolean = hasSpeakerphone() + private var mIsChoosePlugin = false + private var mMediaHandlerId: String? = null + private var mPluginCallId: String? = null + override fun initVideo(): Completable { + Log.i(TAG, "initVideo()") + return cameraService.init() + } + + override val maxResolutions: Observable<Tuple<Int, Int>> + get() = cameraService.maxResolutions + override val isVideoAvailable: Boolean + get() = mContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) || cameraService.hasCamera() + + override fun hasMicrophone(): Boolean { + val pm = mContext.packageManager + var hasMicrophone = pm.hasSystemFeature(PackageManager.FEATURE_MICROPHONE) + if (!hasMicrophone) { + val recorder = MediaRecorder() + val testFile = File(mContext.cacheDir, "MediaUtil#micAvailTestFile") + hasMicrophone = try { + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder.setOutputFormat(MediaRecorder.OutputFormat.DEFAULT) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.DEFAULT) + recorder.setOutputFile(testFile.absolutePath) + recorder.prepare() + recorder.start() + true + } catch (e: IllegalStateException) { + // Microphone is already in use + true + } catch (exception: Exception) { + false + } finally { + recorder.release() + testFile.delete() + } + } + return hasMicrophone + } + + override val isSpeakerphoneOn: Boolean + get() = mAudioManager.isSpeakerphoneOn + + private val RINGTONE_REQUEST = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT) + .setAudioAttributes( + AudioAttributesCompat.Builder() + .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributesCompat.USAGE_NOTIFICATION_RINGTONE) + .setLegacyStreamType(AudioManager.STREAM_RING) + .build() + ) + .setOnAudioFocusChangeListener(this) + .build() + private val CALL_REQUEST = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT) + .setAudioAttributes( + AudioAttributesCompat.Builder() + .setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributesCompat.USAGE_VOICE_COMMUNICATION) + .setLegacyStreamType(AudioManager.STREAM_VOICE_CALL) + .build() + ) + .setOnAudioFocusChangeListener(this) + .build() + + private fun getFocus(request: AudioFocusRequestCompat?) { + if (currentFocus === request) return + if (currentFocus != null) { + AudioManagerCompat.abandonAudioFocusRequest(mAudioManager, currentFocus!!) + currentFocus = null + } + if (request != null && AudioManagerCompat.requestAudioFocus( + mAudioManager, + request + ) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + ) { + currentFocus = request + } + } + + @Synchronized + override fun updateAudioState(state: CallStatus?, incomingCall: Boolean, isOngoingVideo: Boolean) { + Log.d(TAG, "updateAudioState: Call state updated to $state Call is incoming: $incomingCall Call is video: $isOngoingVideo") + val callEnded = state == CallStatus.HUNGUP || state == CallStatus.FAILURE || state == CallStatus.OVER + try { + if (mBluetoothWrapper == null && !callEnded) { + mBluetoothWrapper = BluetoothWrapper(mContext, this) + } + when (state) { + CallStatus.RINGING -> { + if (incomingCall) startRinging() + getFocus(RINGTONE_REQUEST) + if (incomingCall) { + // ringtone for incoming calls + mAudioManager.mode = AudioManager.MODE_RINGTONE + setAudioRouting(true) + mShouldSpeakerphone = isOngoingVideo + } else setAudioRouting(isOngoingVideo) + } + CallStatus.CURRENT -> { + stopRinging() + getFocus(CALL_REQUEST) + mAudioManager.mode = AudioManager.MODE_IN_COMMUNICATION + setAudioRouting(isOngoingVideo) + } + CallStatus.HOLD, CallStatus.UNHOLD, CallStatus.INACTIVE -> { + } + else -> closeAudioState() + } + } catch (e: Exception) { + Log.e(TAG, "Error updating audio state", e) + } + } + + /* + This is required in the case where a call is incoming. If you have an incoming call, and no bluetooth device is connected, the ringer should always be played through the speaker. + However, this results in the call starting in a state where the speaker is always on and the UI is in an incorrect state. + If it is a bluetooth device, it takes priority and does not play on speaker regardless. Otherwise, it returns mShouldSpeakerphone which was updated in updateaudiostate. + */ + override fun shouldPlaySpeaker(): Boolean { + return if (mBluetoothWrapper != null && mBluetoothWrapper!!.canBluetooth() && mBluetoothWrapper!!.isBTHeadsetConnected) false else mShouldSpeakerphone + } + + @Synchronized + override fun closeAudioState() { + stopRinging() + abandonAudioFocus() + } + + override fun startRinging() { + mRinger.ring() + } + + override fun stopRinging() { + mRinger.stopRing() + } + + override fun onAudioFocusChange(arg0: Int) { + Log.i(TAG, "onAudioFocusChange $arg0") + } + + @Synchronized + override fun abandonAudioFocus() { + if (currentFocus != null) { + AudioManagerCompat.abandonAudioFocusRequest(mAudioManager, currentFocus!!) + currentFocus = null + } + if (mAudioManager.isSpeakerphoneOn) { + mAudioManager.isSpeakerphoneOn = false + } + mAudioManager.mode = AudioManager.MODE_NORMAL + mBluetoothWrapper?.let { bluetoothWrapper -> + bluetoothWrapper.unregister() + bluetoothWrapper.setBluetoothOn(false) + mBluetoothWrapper = null + } + } + + private fun setAudioRouting(requestSpeakerOn: Boolean) { + mShouldSpeakerphone = requestSpeakerOn + // prioritize bluetooth by checking for bluetooth device first + if (mBluetoothWrapper != null && mBluetoothWrapper!!.canBluetooth() && mBluetoothWrapper!!.isBTHeadsetConnected) { + routeToBTHeadset() + } else if (!mAudioManager.isWiredHeadsetOn && mHasSpeakerPhone && mShouldSpeakerphone) { + routeToSpeaker() + } else { + resetAudio() + } + } + + private fun hasSpeakerphone(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Check FEATURE_AUDIO_OUTPUT to guard against false positives. + val packageManager = mContext.packageManager + if (!packageManager.hasSystemFeature(PackageManager.FEATURE_AUDIO_OUTPUT)) { + return false + } + val devices = mAudioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + for (device in devices) { + if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) { + return true + } + } + return false + } + return true + } + + /** + * Routes audio to a bluetooth headset. + */ + private fun routeToBTHeadset() { + Log.d(TAG, "routeToBTHeadset: Try to enable bluetooth") + val oldMode = mAudioManager.mode + mAudioManager.mode = AudioManager.MODE_NORMAL + mAudioManager.isSpeakerphoneOn = false + mBluetoothWrapper!!.setBluetoothOn(true) + mAudioManager.mode = oldMode + audioStateSubject.onNext(AudioState(AudioOutput.BLUETOOTH, mBluetoothWrapper!!.deviceName)) + } + + /** + * Routes audio to the device's speaker and takes into account whether the transition is coming from bluetooth. + */ + private fun routeToSpeaker() { + // if we are returning from bluetooth mode, switch to mode normal, otherwise, we switch to mode in communication + if (mAudioManager.isBluetoothScoOn) { + val oldMode = mAudioManager.mode + mAudioManager.mode = AudioManager.MODE_NORMAL + mBluetoothWrapper!!.setBluetoothOn(false) + mAudioManager.mode = oldMode + } + mAudioManager.isSpeakerphoneOn = true + audioStateSubject.onNext(STATE_SPEAKERS) + } + + /** + * Returns to earpiece audio + */ + private fun resetAudio() { + if (mBluetoothWrapper != null) mBluetoothWrapper!!.setBluetoothOn(false) + mAudioManager.isSpeakerphoneOn = false + audioStateSubject.onNext(STATE_INTERNAL) + } + + @Synchronized + override fun toggleSpeakerphone(checked: Boolean) { + JamiService.setAudioPlugin(JamiService.getCurrentAudioOutputPlugin()) + mShouldSpeakerphone = checked + Log.w(TAG, "toggleSpeakerphone setSpeakerphoneOn $checked") + if (mHasSpeakerPhone && checked) { + routeToSpeaker() + } else if (mBluetoothWrapper != null && mBluetoothWrapper!!.canBluetooth() && mBluetoothWrapper!!.isBTHeadsetConnected) { + routeToBTHeadset() + } else { + resetAudio() + } + } + + @Synchronized + override fun onBluetoothStateChanged(status: Int) { + Log.d(TAG, "bluetoothStateChanged to: $status") + val event = BluetoothEvent() + if (status == BluetoothHeadset.STATE_AUDIO_CONNECTED) { + Log.d(TAG, "BluetoothHeadset Connected") + event.connected = true + } else if (status == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) { + Log.d(TAG, "BluetoothHeadset Disconnected") + event.connected = false + if (mShouldSpeakerphone) routeToSpeaker() + } + bluetoothEvents.onNext(event) + } + + override fun decodingStarted(id: String, shmPath: String, width: Int, height: Int, isMixer: Boolean) { + Log.i(TAG, "decodingStarted() " + id + " " + width + "x" + height) + val shm = Shm() + shm.id = id + shm.w = width + shm.h = height + videoInputs[id] = shm + val weakSurfaceHolder = videoSurfaces[id] + if (weakSurfaceHolder != null) { + val holder = weakSurfaceHolder.get() + if (holder != null) { + shm.window = startVideo(id, holder.surface, width, height) + if (shm.window == 0L) { + Log.i(TAG, "DJamiService.decodingStarted() no window !") + val event = VideoEvent() + event.start = true + event.callId = shm.id + videoEvents.onNext(event) + return + } + val event = VideoEvent() + event.callId = shm.id + event.started = true + event.w = shm.w + event.h = shm.h + videoEvents.onNext(event) + } + } + } + + override fun decodingStopped(id: String, shmPath: String, isMixer: Boolean) { + Log.i(TAG, "decodingStopped() $id") + val shm = videoInputs.remove(id) ?: return + if (shm.window != 0L) { + try { + stopVideo(shm.id, shm.window) + } catch (e: Exception) { + Log.e(TAG, "decodingStopped error$e") + } + shm.window = 0 + } + } + + override fun getCameraInfo(camId: String, formats: IntVect, sizes: UintVect, rates: UintVect) { + // Use a larger resolution for Android 6.0+, 64 bits devices + val useLargerSize = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && (Build.SUPPORTED_64_BIT_ABIS.isNotEmpty() || mPreferenceService.isHardwareAccelerationEnabled) + //int MIN_WIDTH = useLargerSize ? (useHD ? VIDEO_WIDTH_HD : VIDEO_WIDTH) : VIDEO_WIDTH_MIN; + val minVideoSize: Point = if (useLargerSize) parseResolution(mPreferenceService.resolution) else VIDEO_SIZE_LOW + cameraService.getCameraInfo(camId, formats, sizes, rates, minVideoSize) + } + + private fun parseResolution(resolution: Int): Point { + return when (resolution) { + 480 -> VIDEO_SIZE_DEFAULT + 720 -> VIDEO_SIZE_HD + 1080 -> VIDEO_SIZE_FULL_HD + 2160 -> VIDEO_SIZE_ULTRA_HD + else -> VIDEO_SIZE_HD + } + } + + override fun setParameters(camId: String, format: Int, width: Int, height: Int, rate: Int) { + Log.d(TAG, "setParameters: $camId, $format, $width, $height, $rate") + val windowManager = mContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager + cameraService.setParameters(camId, format, width, height, rate, windowManager.defaultDisplay.rotation) + } + + override fun startScreenShare(mediaProjection: Any?): Boolean { + val projection = mediaProjection as MediaProjection? + if (mIsCapturing) { + endCapture() + } + return if (!mIsScreenSharing && projection != null) { + mIsScreenSharing = true + projection.registerCallback(object : MediaProjection.Callback() { + override fun onStop() { + stopScreenShare() + } + }, cameraService.videoHandler) + if (!cameraService.startScreenSharing(projection, mContext.resources.displayMetrics)) { + mIsScreenSharing = false + projection.stop() + return false + } + true + } else { + false + } + } + + override fun stopScreenShare() { + if (mIsScreenSharing) { + cameraService.stopScreenSharing() + mIsScreenSharing = false + if (mShouldCapture) startCapture(mCapturingId) + } + } + + override fun startMediaHandler(mediaHandlerId: String?) { + mIsChoosePlugin = true + mMediaHandlerId = mediaHandlerId + } + + private fun toggleMediaHandler(callId: String) { + if (mMediaHandlerId != null) JamiService.toggleCallMediaHandler(mMediaHandlerId, callId, true) + } + + override fun stopMediaHandler() { + mIsChoosePlugin = false + mMediaHandlerId = null + } + + override fun startCapture(camId: String?) { + if (mIsScreenSharing) { + cameraService.stopScreenSharing() + mIsScreenSharing = false + } + mShouldCapture = true + if (mIsCapturing && mCapturingId != null && mCapturingId == camId) { + return + } + val cam = camId ?: if (mCapturingId != null) mCapturingId else cameraService.switchInput(true) + val videoParams = cameraService.getParams(cam) + if (videoParams == null) { + Log.w(TAG, "startCapture: no video parameters ") + return + } + val surface = mCameraPreviewSurface.get() + if (surface == null) { + Log.w(TAG, "Can't start capture: no surface registered.") + cameraService.setPreviewParams(videoParams) + val event = VideoEvent() + event.start = true + videoEvents.onNext(event) + return + } + val conf = mCameraPreviewCall.get() + val useHardwareCodec = + mPreferenceService.isHardwareAccelerationEnabled && (conf == null || !conf.isConference) && !mIsChoosePlugin + if (conf != null && useHardwareCodec) { + val call = conf.call + if (call != null) { + call.setDetails(JamiService.getCallDetails(call.daemonIdString).toNative()) + videoParams.codec = call.videoCodec + } else { + videoParams.codec = null + } + } + Log.w(TAG, "startCapture: call " + cam + " " + videoParams.codec + " useHardwareCodec:" + useHardwareCodec + " bitrate:" + mPreferenceService.bitrate) + mIsCapturing = true + mCapturingId = videoParams.id + Log.d(TAG, "startCapture: startCapture " + videoParams.id + " " + videoParams.width + "x" + videoParams.height + " rot" + videoParams.rotation) + mUiScheduler.scheduleDirect { + cameraService.openCamera(videoParams, surface, + object : CameraListener { + override fun onOpened() { + val currentCall = conf?.id ?: return + if (mPluginCallId != null && mPluginCallId != currentCall) { + JamiService.toggleCallMediaHandler(mMediaHandlerId, currentCall, false) + mIsChoosePlugin = false + mMediaHandlerId = null + mPluginCallId = null + } else if (mIsChoosePlugin && mMediaHandlerId != null) { + mPluginCallId = currentCall + toggleMediaHandler(currentCall) + } + } + + override fun onError() { + stopCapture() + } + }, + useHardwareCodec, + mPreferenceService.resolution, + mPreferenceService.bitrate + ) + } + cameraService.setPreviewParams(videoParams) + val event = VideoEvent() + event.started = true + event.w = videoParams.width + event.h = videoParams.height + event.rot = videoParams.rotation + videoEvents.onNext(event) + } + + override fun stopCapture() { + Log.d(TAG, "stopCapture: " + cameraService.isOpen) + mShouldCapture = false + endCapture() + if (mIsScreenSharing) { + cameraService.stopScreenSharing() + mIsScreenSharing = false + } + } + + override fun requestKeyFrame() { + cameraService.requestKeyFrame() + } + + override fun setBitrate(device: String?, bitrate: Int) { + cameraService.setBitrate(bitrate) + } + + override fun endCapture() { + if (cameraService.isOpen) { + //final CameraService.VideoParams params = previewParams; + cameraService.closeCamera() + val event = VideoEvent() + event.started = false + //event.w = params.width; + //event.h = params.height; + videoEvents.onNext(event) + } + mIsCapturing = false + } + + override fun addVideoSurface(id: String?, holder: Any?) { + if (holder !is SurfaceHolder) { + return + } + Log.w(TAG, "addVideoSurface $id") + val shm = videoInputs[id] + val surfaceHolder = WeakReference(holder) + videoSurfaces[id] = surfaceHolder + if (shm != null && shm.window == 0L) { + shm.window = startVideo(shm.id, surfaceHolder.get()!!.surface, shm.w, shm.h) + } + if (shm == null || shm.window == 0L) { + Log.i(TAG, "DJamiService.addVideoSurface() no window !") + val event = VideoEvent() + event.start = true + videoEvents.onNext(event) + return + } + val event = VideoEvent() + event.callId = shm.id + event.started = true + event.w = shm.w + event.h = shm.h + videoEvents.onNext(event) + } + + override fun updateVideoSurfaceId(currentId: String?, newId: String?) { + Log.w(TAG, "updateVideoSurfaceId $currentId $newId") + val surfaceHolder = videoSurfaces[currentId] ?: return + val surface = surfaceHolder.get() + val shm = videoInputs[currentId] + if (shm != null && shm.window != 0L) { + try { + stopVideo(shm.id, shm.window) + } catch (e: Exception) { + Log.e(TAG, "removeVideoSurface error$e") + } + shm.window = 0 + } + videoSurfaces.remove(currentId) + surface?.let { addVideoSurface(newId, it) } + } + + override fun addPreviewVideoSurface(holder: Any?, conference: Conference?) { + if (holder !is TextureView) + return + Log.w(TAG, "addPreviewVideoSurface " + holder.hashCode() + " mCapturingId " + mCapturingId) + if (mCameraPreviewSurface.get() === holder) return + mCameraPreviewSurface = WeakReference(holder) + mCameraPreviewCall = WeakReference(conference) + if (mShouldCapture && !mIsCapturing) { + startCapture(mCapturingId) + } + } + + override fun updatePreviewVideoSurface(conference: Conference?) { + val old = mCameraPreviewCall.get() + mCameraPreviewCall = WeakReference(conference) + if (old !== conference && mIsCapturing) { + val id = mCapturingId + stopCapture() + startCapture(id) + } + } + + override fun removeVideoSurface(id: String?) { + Log.i(TAG, "removeVideoSurface $id") + videoSurfaces.remove(id) + val shm = videoInputs[id] ?: return + if (shm.window != 0L) { + try { + stopVideo(shm.id, shm.window) + } catch (e: Exception) { + Log.e(TAG, "removeVideoSurface error$e") + } + shm.window = 0 + } + val event = VideoEvent() + event.callId = shm.id + event.started = false + videoEvents.onNext(event) + } + + override fun removePreviewVideoSurface() { + Log.w(TAG, "removePreviewVideoSurface") + mCameraPreviewSurface.clear() + } + + override fun switchInput(id: String?, setDefaultCamera: Boolean) { + Log.w(TAG, "switchInput $id") + mCapturingId = cameraService.switchInput(setDefaultCamera) + switchInput(id, "camera://$mCapturingId") + } + + override fun setPreviewSettings() { + setPreviewSettings(cameraService.previewSettings) + } + + override val cameraCount: Int + get() = cameraService.cameraCount + + override fun hasCamera(): Boolean { + return cameraService.hasCamera() + } + + override val isPreviewFromFrontCamera: Boolean + get() = cameraService.isPreviewFromFrontCamera + + override fun setDeviceOrientation(rotation: Int) { + cameraService.setOrientation(rotation) + if (mCapturingId != null) { + val videoParams = cameraService.getParams(mCapturingId) + val event = VideoEvent() + event.started = true + event.w = videoParams.width + event.h = videoParams.height + event.rot = videoParams.rotation + videoEvents.onNext(event) + } + } + + override val videoDevices: List<String> + get() = cameraService.cameraIds + + private class Shm { + var id: String? = null + var w = 0 + var h = 0 + var window: Long = 0 + } + + override fun unregisterCameraDetectionCallback() { + cameraService.unregisterCameraDetectionCallback() + } + + companion object { + private val VIDEO_SIZE_LOW = Point(320, 240) + private val VIDEO_SIZE_DEFAULT = Point(720, 480) + private val VIDEO_SIZE_HD = Point(1280, 720) + private val VIDEO_SIZE_FULL_HD = Point(1920, 1080) + private val VIDEO_SIZE_ULTRA_HD = Point(3840, 2160) + private val TAG = HardwareServiceImpl::class.simpleName!! + private var mCameraPreviewSurface = WeakReference<TextureView?>(null) + private var mCameraPreviewCall = WeakReference<Conference?>(null) + private val videoSurfaces = Collections.synchronizedMap(HashMap<String?, WeakReference<SurfaceHolder>>()) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/services/HistoryServiceImpl.java b/ring-android/app/src/main/java/cx/ring/services/HistoryServiceImpl.java deleted file mode 100644 index 4b54c7d85..000000000 --- a/ring-android/app/src/main/java/cx/ring/services/HistoryServiceImpl.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Author: Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.services; - -import android.content.Context; -import android.content.SharedPreferences; -import android.util.Log; - -import com.j256.ormlite.dao.Dao; -import com.j256.ormlite.support.ConnectionSource; - -import java.io.File; -import java.sql.SQLException; -import java.util.concurrent.ConcurrentHashMap; - -import javax.inject.Inject; - -import cx.ring.history.DatabaseHelper; - -import net.jami.model.ConversationHistory; -import net.jami.model.Interaction; -import net.jami.model.Uri; -import net.jami.services.HistoryService; - -import static cx.ring.fragments.ConversationFragment.KEY_PREFERENCE_CONVERSATION_LAST_READ; - -/** - * Implements the necessary Android related methods for the {@link HistoryService} - */ -public class HistoryServiceImpl extends HistoryService { - private static final String TAG = HistoryServiceImpl.class.getSimpleName(); - private final static String DATABASE_NAME = "history.db"; - private final static String LEGACY_DATABASE_KEY = "legacy"; - - private final ConcurrentHashMap<String, DatabaseHelper> databaseHelpers = new ConcurrentHashMap<>(); - - @Inject - protected Context mContext; - - public HistoryServiceImpl() { - } - - @Override - protected ConnectionSource getConnectionSource(String dbName) { - return getHelper(dbName).getConnectionSource(); - } - - @Override - protected Dao<Interaction, Integer> getInteractionDataDao(String dbName) { - try { - return getHelper(dbName).getInteractionDataDao(); - } catch (SQLException e) { - Log.e(TAG, "Unable to get a interactionDataDao"); - return null; - } - } - - @Override - protected Dao<ConversationHistory, Integer> getConversationDataDao(String dbName) { - try { - return getHelper(dbName).getConversationDataDao(); - } catch (SQLException e) { - Log.e(TAG, "Unable to get a conversationDataDao"); - return null; - } - } - - /** - * Creates an instance of our database's helper. - * Stores it in a hash map for easy retrieval in the future. - * - * @param accountId represents the file where the database is stored - * @return the database helper - */ - private DatabaseHelper initHelper(String accountId) { - File db = new File(new File(mContext.getFilesDir(), accountId), DATABASE_NAME); - DatabaseHelper helper = new DatabaseHelper(mContext, db.getAbsolutePath()); - databaseHelpers.put(accountId, helper); - return helper; - } - - /** - * Retrieve helper for our DB. Creates a new instance if it does not exist through the initHelper method. - * - * @param accountId represents the file where the database is stored - * @return the database helper - * @see #initHelper(String) initHelper - */ - @SuppressWarnings("JavadocReference") - @Override - protected DatabaseHelper getHelper(String accountId) { - DatabaseHelper helper = databaseHelpers.get(accountId); - return helper == null ? initHelper(accountId) : helper; - } - - /** - * Deletes the user's account file and all its children - * - * @param accountId the file name - * @see #deleteFolder(File) deleteFolder - */ - @Override - protected void deleteAccountHistory(String accountId) { - File accountDir = new File(mContext.getFilesDir(), accountId); - if (accountDir.exists()) - deleteFolder(accountDir); - } - - @Override - public void setMessageRead(String accountId, Uri conversationUri, String lastId) { - SharedPreferences preferences = mContext.getSharedPreferences(accountId + "_" + conversationUri.getUri(), Context.MODE_PRIVATE); - preferences.edit().putString(KEY_PREFERENCE_CONVERSATION_LAST_READ, lastId).apply(); - } - - @Override - public String getLastMessageRead(String accountId, Uri conversationUri) { - SharedPreferences preferences = mContext.getSharedPreferences(accountId + "_" + conversationUri.getUri(), Context.MODE_PRIVATE); - return preferences.getString(KEY_PREFERENCE_CONVERSATION_LAST_READ, null); - } - - /** - * Deletes a file and all its children recursively - * - * @param file the file to delete - */ - private void deleteFolder(File file) { - if (file.isDirectory()) { - File[] children = file.listFiles(); - if (children != null) { - for (File child : children) - deleteFolder(child); - } - } - file.delete(); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/services/HistoryServiceImpl.kt b/ring-android/app/src/main/java/cx/ring/services/HistoryServiceImpl.kt new file mode 100644 index 000000000..bb6b1599e --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/services/HistoryServiceImpl.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Author: Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.services + +import android.content.Context +import com.j256.ormlite.dao.Dao +import com.j256.ormlite.support.ConnectionSource +import cx.ring.fragments.ConversationFragment +import cx.ring.history.DatabaseHelper +import net.jami.model.ConversationHistory +import net.jami.model.Interaction +import net.jami.model.Uri +import net.jami.services.HistoryService +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Implements the necessary Android related methods for the [HistoryService] + */ +class HistoryServiceImpl(private val mContext: Context) : HistoryService() { + private val databaseHelpers = ConcurrentHashMap<String, DatabaseHelper>() + override fun getConnectionSource(dbName: String): ConnectionSource { + return getHelper(dbName).connectionSource + } + + override fun getInteractionDataDao(dbName: String): Dao<Interaction, Int> { + return getHelper(dbName).interactionDataDao + } + + override fun getConversationDataDao(dbName: String): Dao<ConversationHistory, Int> { + return getHelper(dbName).conversationDataDao + } + + /** + * Creates an instance of our database's helper. + * Stores it in a hash map for easy retrieval in the future. + * + * @param accountId represents the file where the database is stored + * @return the database helper + */ + private fun initHelper(accountId: String): DatabaseHelper { + val db = File(File(mContext.filesDir, accountId), Companion.DATABASE_NAME) + val helper = DatabaseHelper(mContext, db.absolutePath) + databaseHelpers[accountId] = helper + return helper + } + + /** + * Retrieve helper for our DB. Creates a new instance if it does not exist through the initHelper method. + * + * @param accountId represents the file where the database is stored + * @return the database helper + * @see .initHelper + */ + override fun getHelper(accountId: String): DatabaseHelper { + val helper = databaseHelpers[accountId] + return helper ?: initHelper(accountId) + } + + /** + * Deletes the user's account file and all its children + * + * @param accountId the file name + * @see .deleteFolder + */ + override fun deleteAccountHistory(accountId: String) { + val accountDir = File(mContext.filesDir, accountId) + if (accountDir.exists()) deleteFolder(accountDir) + } + + override fun setMessageRead(accountId: String, conversationUri: Uri, lastId: String) { + val preferences = mContext.getSharedPreferences( + accountId + "_" + conversationUri.uri, + Context.MODE_PRIVATE + ) + preferences.edit() + .putString(ConversationFragment.KEY_PREFERENCE_CONVERSATION_LAST_READ, lastId).apply() + } + + override fun getLastMessageRead(accountId: String, conversationUri: Uri): String? { + val preferences = mContext.getSharedPreferences( + accountId + "_" + conversationUri.uri, + Context.MODE_PRIVATE + ) + return preferences.getString( + ConversationFragment.KEY_PREFERENCE_CONVERSATION_LAST_READ, + null + ) + } + + /** + * Deletes a file and all its children recursively + * + * @param file the file to delete + */ + private fun deleteFolder(file: File) { + if (file.isDirectory) { + val children = file.listFiles() + if (children != null) { + for (child in children) deleteFolder(child) + } + } + file.delete() + } + + companion object { + private const val DATABASE_NAME = "history.db" + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/services/LogServiceImpl.java b/ring-android/app/src/main/java/cx/ring/services/LogServiceImpl.java index 042db2428..169e6f490 100644 --- a/ring-android/app/src/main/java/cx/ring/services/LogServiceImpl.java +++ b/ring-android/app/src/main/java/cx/ring/services/LogServiceImpl.java @@ -56,6 +56,4 @@ public class LogServiceImpl implements LogService { public void i(String tag, String message, Throwable e) { Log.i(tag, message, e); } - - } diff --git a/ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.java b/ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.java deleted file mode 100644 index 649ca69c5..000000000 --- a/ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.java +++ /dev/null @@ -1,990 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.services; - -import android.annotation.SuppressLint; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationChannelGroup; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.media.AudioAttributes; -import android.media.RingtoneManager; -import android.os.Build; -import android.text.TextUtils; -import android.text.format.Formatter; -import android.util.Log; -import android.util.SparseArray; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.CarExtender.UnreadConversation; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.app.Person; -import androidx.core.app.RemoteInput; -import androidx.core.content.ContextCompat; -import androidx.core.content.res.ResourcesCompat; -import androidx.core.graphics.drawable.IconCompat; -import androidx.core.util.Pair; - -import com.bumptech.glide.Glide; - -import java.io.File; -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.Objects; -import java.util.Random; -import java.util.Set; -import java.util.TreeMap; -import java.util.concurrent.ConcurrentHashMap; - -import javax.inject.Inject; - -import cx.ring.R; -import cx.ring.client.ConversationActivity; -import cx.ring.client.HomeActivity; -import cx.ring.contactrequests.ContactRequestsFragment; -import cx.ring.views.AvatarFactory; -import cx.ring.fragments.ConversationFragment; -import net.jami.model.Account; -import net.jami.model.Contact; -import net.jami.model.Conference; -import net.jami.model.Conversation; -import net.jami.model.Interaction; -import net.jami.model.Interaction.InteractionStatus; -import net.jami.model.DataTransfer; -import net.jami.model.Call; -import net.jami.model.TextMessage; -import net.jami.model.Uri; -import cx.ring.service.CallNotificationService; -import cx.ring.service.DRingService; -import cx.ring.settings.SettingsFragment; -import cx.ring.tv.call.TVCallActivity; -import cx.ring.utils.ConversationPath; -import cx.ring.utils.DeviceUtils; -import cx.ring.utils.ResourceMapper; - -import net.jami.services.AccountService; -import net.jami.services.ContactService; -import net.jami.services.DeviceRuntimeService; -import net.jami.services.HistoryService; -import net.jami.services.NotificationService; -import net.jami.services.PreferencesService; -import net.jami.utils.Tuple; - -public class NotificationServiceImpl implements NotificationService { - - public static final String EXTRA_BUBBLE = "bubble"; - - private static final String TAG = NotificationServiceImpl.class.getSimpleName(); - - private static final String NOTIF_MSG = "MESSAGE"; - private static final String NOTIF_TRUST_REQUEST = "TRUST REQUEST"; - private static final String NOTIF_FILE_TRANSFER = "FILE_TRANSFER"; - private static final String NOTIF_MISSED_CALL = "MISSED_CALL"; - - private static final String NOTIF_CHANNEL_CALL_IN_PROGRESS = "current_call"; - private static final String NOTIF_CHANNEL_MISSED_CALL = "missed_calls"; - private static final String NOTIF_CHANNEL_INCOMING_CALL = "incoming_call"; - - private static final String NOTIF_CHANNEL_MESSAGE = "messages"; - private static final String NOTIF_CHANNEL_REQUEST = "requests"; - private static final String NOTIF_CHANNEL_FILE_TRANSFER = "file_transfer"; - public static final String NOTIF_CHANNEL_SYNC = "sync"; - private static final String NOTIF_CHANNEL_SERVICE = "service"; - - private static final String NOTIF_CALL_GROUP = "calls"; - - public static final int NOTIF_CALL_ID = 1001; - - private final SparseArray<NotificationCompat.Builder> mNotificationBuilders = new SparseArray<>(); - @Inject - protected Context mContext; - @Inject - protected AccountService mAccountService; - @Inject - protected ContactService mContactService; - @Inject - protected PreferencesService mPreferencesService; - @Inject - protected HistoryService mHistoryService; - @Inject - protected DeviceRuntimeService mDeviceRuntimeService; - - private NotificationManagerCompat notificationManager; - private final Random random = new Random(); - private int avatarSize; - private final LinkedHashMap<String, Conference> currentCalls = new LinkedHashMap<>(); - private final ConcurrentHashMap<Integer, Notification> callNotifications = new ConcurrentHashMap<>(); - private final ConcurrentHashMap<Integer, Notification> dataTransferNotifications = new ConcurrentHashMap<>(); - - @SuppressLint("CheckResult") - public void initHelper() { - if (notificationManager == null) { - notificationManager = NotificationManagerCompat.from(mContext); - } - avatarSize = (int) (mContext.getResources().getDisplayMetrics().density * AvatarFactory.SIZE_NOTIF); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - registerNotificationChannels(mContext); - } - } - - @RequiresApi(api = Build.VERSION_CODES.O) - public static void registerNotificationChannels(Context context) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - if (notificationManager == null) - return; - - // Setting up groups - notificationManager.createNotificationChannelGroup(new NotificationChannelGroup(NOTIF_CALL_GROUP, context.getString(R.string.notif_group_calls))); - - // Missed calls channel - NotificationChannel missedCallsChannel = new NotificationChannel(NOTIF_CHANNEL_MISSED_CALL, context.getString(R.string.notif_channel_missed_calls), NotificationManager.IMPORTANCE_DEFAULT); - missedCallsChannel.setLockscreenVisibility(Notification.VISIBILITY_SECRET); - missedCallsChannel.setSound(null, null); - missedCallsChannel.enableVibration(false); - missedCallsChannel.setGroup(NOTIF_CALL_GROUP); - notificationManager.createNotificationChannel(missedCallsChannel); - - // Incoming call channel - NotificationChannel incomingCallChannel = new NotificationChannel(NOTIF_CHANNEL_INCOMING_CALL, context.getString(R.string.notif_channel_incoming_calls), NotificationManager.IMPORTANCE_HIGH); - incomingCallChannel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); - incomingCallChannel.setGroup(NOTIF_CALL_GROUP); - incomingCallChannel.setSound(null, null); - incomingCallChannel.enableVibration(false); - notificationManager.createNotificationChannel(incomingCallChannel); - - // Call in progress channel - NotificationChannel callInProgressChannel = new NotificationChannel(NOTIF_CHANNEL_CALL_IN_PROGRESS, context.getString(R.string.notif_channel_call_in_progress), NotificationManager.IMPORTANCE_DEFAULT); - callInProgressChannel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); - callInProgressChannel.setSound(null, null); - callInProgressChannel.enableVibration(false); - callInProgressChannel.setGroup(NOTIF_CALL_GROUP); - notificationManager.createNotificationChannel(callInProgressChannel); - - // Text messages channel - AudioAttributes soundAttributes = new AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) - .build(); - - NotificationChannel messageChannel = new NotificationChannel(NOTIF_CHANNEL_MESSAGE, context.getString(R.string.notif_channel_messages), NotificationManager.IMPORTANCE_HIGH); - messageChannel.enableVibration(true); - messageChannel.setLockscreenVisibility(Notification.VISIBILITY_SECRET); - messageChannel.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), soundAttributes); - notificationManager.createNotificationChannel(messageChannel); - - // Contact requests - NotificationChannel requestsChannel = new NotificationChannel(NOTIF_CHANNEL_REQUEST, context.getString(R.string.notif_channel_requests), NotificationManager.IMPORTANCE_DEFAULT); - requestsChannel.enableVibration(true); - requestsChannel.setLockscreenVisibility(Notification.VISIBILITY_SECRET); - requestsChannel.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), soundAttributes); - notificationManager.createNotificationChannel(requestsChannel); - - // File transfer requests - NotificationChannel fileTransferChannel = new NotificationChannel(NOTIF_CHANNEL_FILE_TRANSFER, context.getString(R.string.notif_channel_file_transfer), NotificationManager.IMPORTANCE_DEFAULT); - fileTransferChannel.enableVibration(true); - fileTransferChannel.setLockscreenVisibility(Notification.VISIBILITY_SECRET); - fileTransferChannel.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), soundAttributes); - notificationManager.createNotificationChannel(fileTransferChannel); - - // File transfer requests - NotificationChannel syncChannel = new NotificationChannel(NOTIF_CHANNEL_SYNC, context.getString(R.string.notif_channel_sync), NotificationManager.IMPORTANCE_DEFAULT); - syncChannel.setLockscreenVisibility(Notification.VISIBILITY_SECRET); - syncChannel.enableLights(false); - syncChannel.enableVibration(false); - syncChannel.setShowBadge(false); - syncChannel.setSound(null, null); - notificationManager.createNotificationChannel(syncChannel); - - // Background service channel - NotificationChannel backgroundChannel = new NotificationChannel(NOTIF_CHANNEL_SERVICE, context.getString(R.string.notif_channel_background_service), NotificationManager.IMPORTANCE_LOW); - backgroundChannel.setDescription(context.getString(R.string.notif_channel_background_service_descr)); - backgroundChannel.setLockscreenVisibility(Notification.VISIBILITY_SECRET); - backgroundChannel.enableLights(false); - backgroundChannel.enableVibration(false); - backgroundChannel.setShowBadge(false); - notificationManager.createNotificationChannel(backgroundChannel); - } - - /** - * Starts the call activity directly for Android TV - * - * @param callId the call ID - */ - private void startCallActivity(String callId) { - mContext.startActivity(new Intent(Intent.ACTION_VIEW) - .putExtra(KEY_CALL_ID, callId) - .setClass(mContext.getApplicationContext(), TVCallActivity.class) - .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK)); - } - - private Notification buildCallNotification(@NonNull Conference conference) { - String ongoingCallId = null; - for (Conference conf : currentCalls.values()) { - if (conf != conference && conf.getState() == Call.CallStatus.CURRENT) - ongoingCallId = conf.getParticipants().get(0).getDaemonIdString(); - } - - Call call = conference.getParticipants().get(0); - PendingIntent gotoIntent = PendingIntent.getService(mContext, - random.nextInt(), - new Intent(DRingService.ACTION_CALL_VIEW) - .setClass(mContext, DRingService.class) - .putExtra(KEY_CALL_ID, call.getDaemonIdString()), 0); - - Contact contact = call.getContact(); - NotificationCompat.Builder messageNotificationBuilder; - if (conference.isOnGoing()) { - messageNotificationBuilder = new NotificationCompat.Builder(mContext, NOTIF_CHANNEL_CALL_IN_PROGRESS); - messageNotificationBuilder.setContentTitle(mContext.getString(R.string.notif_current_call_title, contact.getDisplayName())) - .setContentText(mContext.getText(R.string.notif_current_call)) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setContentIntent(gotoIntent) - .setSound(null) - .setVibrate(null) - .setColorized(true) - .setUsesChronometer(true) - .setWhen(conference.getTimestampStart()) - .setColor(mContext.getResources().getColor(R.color.color_primary_light)) - .addAction(R.drawable.baseline_call_end_24, mContext.getText(R.string.action_call_hangup), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(DRingService.ACTION_CALL_END) - .setClass(mContext, DRingService.class) - .putExtra(KEY_CALL_ID, call.getDaemonIdString()), - PendingIntent.FLAG_ONE_SHOT)); - } else if (conference.isRinging()) { - if (conference.isIncoming()) { - messageNotificationBuilder = new NotificationCompat.Builder(mContext, NOTIF_CHANNEL_INCOMING_CALL); - messageNotificationBuilder.setContentTitle(mContext.getString(R.string.notif_incoming_call_title, contact.getDisplayName())) - .setPriority(NotificationCompat.PRIORITY_MAX) - .setContentText(mContext.getText(R.string.notif_incoming_call)) - .setContentIntent(gotoIntent) - .setSound(null) - .setVibrate(null) - .setFullScreenIntent(gotoIntent, true) - .addAction(R.drawable.baseline_call_end_24, mContext.getText(R.string.action_call_decline), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(DRingService.ACTION_CALL_REFUSE) - .setClass(mContext, DRingService.class) - .putExtra(KEY_CALL_ID, call.getDaemonIdString()), - PendingIntent.FLAG_ONE_SHOT)) - .addAction(R.drawable.baseline_call_24, ongoingCallId == null ? - mContext.getText(R.string.action_call_accept) : mContext.getText(R.string.action_call_end_accept), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(ongoingCallId == null ? DRingService.ACTION_CALL_ACCEPT : DRingService.ACTION_CALL_END_ACCEPT) - .setClass(mContext, DRingService.class) - .putExtra(KEY_END_ID, ongoingCallId) - .putExtra(KEY_CALL_ID, call.getDaemonIdString()), - PendingIntent.FLAG_ONE_SHOT)); - if (ongoingCallId != null) { - messageNotificationBuilder.addAction(R.drawable.baseline_call_24, mContext.getText(R.string.action_call_hold_accept), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(DRingService.ACTION_CALL_HOLD_ACCEPT) - .setClass(mContext, DRingService.class) - .putExtra(KEY_HOLD_ID, ongoingCallId) - .putExtra(KEY_CALL_ID, call.getDaemonIdString()), - PendingIntent.FLAG_ONE_SHOT)); - } - } else { - messageNotificationBuilder = new NotificationCompat.Builder(mContext, NOTIF_CHANNEL_CALL_IN_PROGRESS); - messageNotificationBuilder.setContentTitle(mContext.getString(R.string.notif_outgoing_call_title, contact.getDisplayName())) - .setContentText(mContext.getText(R.string.notif_outgoing_call)) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setContentIntent(gotoIntent) - .setSound(null) - .setVibrate(null) - .setColorized(true) - .setColor(mContext.getResources().getColor(R.color.color_primary_light)) - .addAction(R.drawable.baseline_call_end_24, mContext.getText(R.string.action_call_hangup), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(DRingService.ACTION_CALL_END) - .setClass(mContext, DRingService.class) - .putExtra(KEY_CALL_ID, call.getDaemonIdString()), - PendingIntent.FLAG_ONE_SHOT)); - } - } else { - return null; - } - - messageNotificationBuilder.setOngoing(true) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setSmallIcon(R.drawable.ic_ring_logo_white); - - setContactPicture(contact, messageNotificationBuilder); - - return messageNotificationBuilder.build(); - } - - @Override - public Object showCallNotification(int notifId) { - return callNotifications.remove(notifId); - } - - @Override - public void showLocationNotification(Account first, Contact contact) { - android.net.Uri path = ConversationPath.toUri(first.getAccountID(), contact.getUri()); - - Intent intentConversation = new Intent(Intent.ACTION_VIEW, path, mContext, ConversationActivity.class) - .putExtra(ConversationFragment.EXTRA_SHOW_MAP, true); - - NotificationCompat.Builder messageNotificationBuilder = new NotificationCompat.Builder(mContext, NOTIF_CHANNEL_MESSAGE) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setDefaults(NotificationCompat.DEFAULT_ALL) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setSmallIcon(R.drawable.ic_ring_logo_white) - .setLargeIcon(getContactPicture(contact)) - .setContentText(mContext.getString(R.string.location_share_contact, contact.getDisplayName())) - .setContentIntent(PendingIntent.getActivity(mContext, random.nextInt(), intentConversation, 0)) - .setAutoCancel(false) - .setColor(ResourcesCompat.getColor(mContext.getResources(), R.color.color_primary_dark, null)); - notificationManager.notify(Objects.hash( "Location", path), messageNotificationBuilder.build()); - } - - @Override - public void cancelLocationNotification(Account first, Contact contact) { - notificationManager.cancel(Objects.hash( "Location", ConversationPath.toUri(first.getAccountID(), contact.getUri()))); - } - - /** - * Updates a notification - * - * @param notification a built notification object - * @param notificationId the notification's id - */ - @Override - public void updateNotification(Object notification, int notificationId) { - if(notification != null) - notificationManager.notify(notificationId, (Notification) notification); - } - - /** - * Starts a service (data transfer or call) - * - * @param id the notification id - */ - private void startForegroundService(int id, Class serviceClass) { - ContextCompat.startForegroundService(mContext, new Intent(mContext, serviceClass) - .putExtra(KEY_NOTIFICATION_ID, id)); - } - - /** - * Handles the creation and destruction of services associated with calls as well as displaying notifications. - * - * @param conference the conference object for the notification - * @param remove true if it should be removed from current calls - */ - @Override - public void handleCallNotification(Conference conference, boolean remove) { - if (DeviceUtils.isTv(mContext)) { - if (!remove) - startCallActivity(conference.getId()); - return; - } - - Notification notification = null; - - // Build notification - String id = conference.getId(); - currentCalls.remove(id); - if (!remove) { - currentCalls.put(id, conference); - notification = buildCallNotification(conference); - } - if (notification == null && !currentCalls.isEmpty()) { - // Build notification for other calls if any remains - for (Conference c : currentCalls.values()) - conference = c; - notification = buildCallNotification(conference); - } - - // Send notification to the Service - if (notification != null) { - int nid = random.nextInt(); - callNotifications.put(nid, notification); - ContextCompat.startForegroundService(mContext, new Intent(CallNotificationService.ACTION_START, null, mContext, CallNotificationService.class) - .putExtra(KEY_NOTIFICATION_ID, nid)); - } else { - try { - mContext.startService(new Intent(CallNotificationService.ACTION_STOP, null, mContext, CallNotificationService.class)); - } catch (Exception e) { - Log.w(TAG, "Error stopping service", e); - } - } - } - - @Override - public void onConnectionUpdate(Boolean b) { - /*Log.i(TAG, "onConnectionUpdate " + b); - if (b) { - Intent serviceIntent = new Intent(SyncService.ACTION_START).setClass(mContext, SyncService.class); - try { - ContextCompat.startForegroundService(mContext, serviceIntent); - } catch (IllegalStateException e) { - Log.e(TAG, "Error starting service", e); - } - } else { - try { - mContext.startService(new Intent(SyncService.ACTION_STOP).setClass(mContext, SyncService.class)); - } catch (IllegalStateException ignored) { - } - }*/ - } - - /** - * Handles the creation and destruction of services associated with transfers as well as displaying notifications. - * - * @param transfer the data transfer object - * @param conversation the contact to whom the data transfer is being sent - * @param remove true if it should be removed from current calls - */ - @Override - public void handleDataTransferNotification(DataTransfer transfer, Conversation conversation, boolean remove) { - Log.d(TAG, "handleDataTransferNotification, a data transfer event is in progress " + remove); - if (DeviceUtils.isTv(mContext)) { - return; - } - if (!remove) { - showFileTransferNotification(conversation, transfer); - } else { - removeTransferNotification(ConversationPath.toUri(conversation), transfer.getFileId()); - } - } - - @Override - public void removeTransferNotification(String accountId, Uri conversationUri, String transferId) { - removeTransferNotification(ConversationPath.toUri(accountId, conversationUri), transferId); - } - - /** - * Cancels a data transfer notification and removes it from the list of notifications - * - * @param transferId the transfer id which is required to generate the notification id - */ - public void removeTransferNotification(android.net.Uri path, String transferId) { - int id = getFileTransferNotificationId(path, transferId); - dataTransferNotifications.remove(id); - cancelFileNotification(id, false); - if (dataTransferNotifications.isEmpty()) { - mContext.startService(new Intent(DataTransferService.ACTION_STOP, path, mContext, DataTransferService.class) - .putExtra(KEY_NOTIFICATION_ID, id)); - } else { - ContextCompat.startForegroundService(mContext, new Intent(DataTransferService.ACTION_STOP, path, mContext, DataTransferService.class) - .putExtra(KEY_NOTIFICATION_ID, id)); - } - } - - /** - * @param notificationId the notification id - * @return the notification object for a data transfer notification - */ - @Override - public Notification getDataTransferNotification(int notificationId) { - return dataTransferNotifications.get(notificationId); - } - - @Override - public void showTextNotification(String accountId, Conversation conversation) { - TreeMap<Long, TextMessage> texts = conversation.getUnreadTextMessages(); - - //Log.w(TAG, "showTextNotification start " + accountId + " " + conversation.getUri() + " " + texts.size()); - - //TODO handle groups - if (texts.isEmpty() || conversation.isVisible()) { - cancelTextNotification(conversation.getAccountId(), conversation.getUri()); - return; - } - if (texts.lastEntry().getValue().isNotified()) { - return; - } - - Log.w(TAG, "showTextNotification " + accountId + " " + conversation.getUri()); - mContactService.getLoadedContact(accountId, conversation.getContacts(), false) - .subscribe(c -> textNotification(accountId, texts, conversation), - e -> Log.w(TAG, "Can't load contact", e)); - } - - private void textNotification(String accountId, TreeMap<Long, TextMessage> texts, Conversation conversation) { - ConversationPath cpath = new ConversationPath(conversation); - android.net.Uri path = cpath.toUri(); - Pair<Bitmap, String> conversationProfile = getProfile(conversation); - - int notificationVisibility = mPreferencesService.getSettings().getNotificationVisibility(); - switch (notificationVisibility){ - case SettingsFragment.NOTIFICATION_PUBLIC: - notificationVisibility = Notification.VISIBILITY_PUBLIC; - break; - case SettingsFragment.NOTIFICATION_SECRET: - notificationVisibility = Notification.VISIBILITY_SECRET; - break; - case SettingsFragment.NOTIFICATION_PRIVATE: - default: - notificationVisibility = Notification.VISIBILITY_PRIVATE; - } - - TextMessage last = texts.lastEntry().getValue(); - Intent intentConversation = new Intent(DRingService.ACTION_CONV_ACCEPT, path, mContext, DRingService.class); - Intent intentDelete = new Intent(DRingService.ACTION_CONV_DISMISS, path, mContext, DRingService.class); - - NotificationCompat.Builder messageNotificationBuilder = new NotificationCompat.Builder(mContext, NOTIF_CHANNEL_MESSAGE) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setPriority(Notification.PRIORITY_HIGH) - .setDefaults(NotificationCompat.DEFAULT_ALL) - .setVisibility(notificationVisibility) - .setSmallIcon(R.drawable.ic_ring_logo_white) - .setContentTitle(conversationProfile.second) - .setContentText(last.getBody()) - .setWhen(last.getTimestamp()) - .setContentIntent(PendingIntent.getService(mContext, random.nextInt(), intentConversation, 0)) - .setDeleteIntent(PendingIntent.getService(mContext, random.nextInt(), intentDelete, 0)) - .setAutoCancel(true) - .setColor(ResourcesCompat.getColor(mContext.getResources(), R.color.color_primary_dark, null)); - - String key = cpath.toKey(); - - Person conversationPerson = new Person.Builder() - .setKey(key) - .setName(conversationProfile.second) - .setIcon(conversationProfile.first == null ? null : IconCompat.createWithBitmap(conversationProfile.first)) - .build(); - - if (conversationProfile.first != null) { - messageNotificationBuilder.setLargeIcon(conversationProfile.first); - Intent intentBubble = new Intent(Intent.ACTION_VIEW, path, mContext, ConversationActivity.class); - intentBubble.putExtra(EXTRA_BUBBLE, true); - messageNotificationBuilder.setBubbleMetadata(new NotificationCompat.BubbleMetadata.Builder() - .setDesiredHeight(600) - .setIcon(IconCompat.createWithAdaptiveBitmap(conversationProfile.first)) - .setIntent(PendingIntent.getActivity(mContext, 0, intentBubble, - PendingIntent.FLAG_UPDATE_CURRENT)) - .build()) - .setShortcutId(key); - } - - UnreadConversation.Builder unreadConvBuilder = new UnreadConversation.Builder(conversationProfile.second) - .setLatestTimestamp(last.getTimestamp()); - - if (texts.size() == 1) { - last.setNotified(true); - messageNotificationBuilder.setStyle(null); - unreadConvBuilder.addMessage(last.getBody()); - } else { - Account account = mAccountService.getAccount(accountId); - Tuple<String, Object> profile = account == null ? null : VCardServiceImpl.loadProfile(mContext, account).blockingGet(); - Bitmap myPic = account == null ? null : getContactPicture(account); - Person userPerson = new Person.Builder() - .setKey(accountId) - .setName(profile == null || TextUtils.isEmpty(profile.first) ? "You" : profile.first) - .setIcon(myPic == null ? null : IconCompat.createWithBitmap(myPic)) - .build(); - - NotificationCompat.MessagingStyle history = new NotificationCompat.MessagingStyle(userPerson); - for (TextMessage textMessage : texts.values()) { - Contact contact = textMessage.getContact(); - Bitmap contactPicture = getContactPicture(contact); - Person contactPerson = new Person.Builder() - .setKey(ConversationPath.toKey(cpath.getAccountId(), contact.getUri().getUri())) - .setName(contact.getDisplayName()) - .setIcon(contactPicture == null ? null : IconCompat.createWithBitmap(contactPicture)) - .build(); - history.addMessage(new NotificationCompat.MessagingStyle.Message( - textMessage.getBody(), - textMessage.getTimestamp(), - textMessage.isIncoming() ? contactPerson : null)); - unreadConvBuilder.addMessage(textMessage.getBody()); - } - messageNotificationBuilder.setStyle(history); - } - - int notificationId = getTextNotificationId(conversation.getAccountId(), conversation.getUri()); - int replyId = notificationId + 1; - int markAsReadId = notificationId + 2; - - CharSequence replyLabel = mContext.getText(R.string.notif_reply); - RemoteInput remoteInput = new RemoteInput.Builder(DRingService.KEY_TEXT_REPLY) - .setLabel(replyLabel) - .build(); - - PendingIntent replyPendingIntent = PendingIntent.getService(mContext, replyId, - new Intent(DRingService.ACTION_CONV_REPLY_INLINE, path, mContext, DRingService.class), - PendingIntent.FLAG_UPDATE_CURRENT); - - PendingIntent readPendingIntent = PendingIntent.getService(mContext, markAsReadId, - new Intent(DRingService.ACTION_CONV_READ, path, mContext, DRingService.class), 0); - - messageNotificationBuilder - .addAction(new NotificationCompat.Action.Builder(R.drawable.baseline_reply_24, replyLabel, replyPendingIntent) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .addRemoteInput(remoteInput) - .extend(new NotificationCompat.Action.WearableExtender() - .setHintDisplayActionInline(true)) - .build()) - .addAction(new NotificationCompat.Action.Builder(0, - mContext.getString(R.string.notif_mark_as_read), - readPendingIntent) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) - .setShowsUserInterface(false) - .build()) - .extend(new NotificationCompat.CarExtender() - .setUnreadConversation(unreadConvBuilder - .setReadPendingIntent(readPendingIntent) - .setReplyAction(replyPendingIntent, remoteInput) - .build())); - - notificationManager.notify(notificationId, messageNotificationBuilder.build()); - mNotificationBuilders.put(notificationId, messageNotificationBuilder); - } - - private NotificationCompat.Builder getRequestNotificationBuilder(String accountId) { - NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext, NOTIF_CHANNEL_REQUEST) - .setDefaults(NotificationCompat.DEFAULT_ALL) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setAutoCancel(true) - .setSmallIcon(R.drawable.ic_ring_logo_white) - .setCategory(NotificationCompat.CATEGORY_SOCIAL) - .setContentTitle(mContext.getString(R.string.contact_request_title)); - Intent intentOpenTrustRequestFragment = new Intent(HomeActivity.ACTION_PRESENT_TRUST_REQUEST_FRAGMENT) - .setClass(mContext, HomeActivity.class) - .putExtra(ContactRequestsFragment.ACCOUNT_ID, accountId); - builder.setContentIntent(PendingIntent.getActivity(mContext, - random.nextInt(), intentOpenTrustRequestFragment, PendingIntent.FLAG_ONE_SHOT)); - builder.setColor(ResourcesCompat.getColor(mContext.getResources(), - R.color.color_primary_dark, null)); - return builder; - } - - @Override - public void showIncomingTrustRequestNotification(final Account account) { - int notificationId = getIncomingTrustNotificationId(account.getAccountID()); - Set<String> notifiedRequests = mPreferencesService.loadRequestsPreferences(account.getAccountID()); - - Collection<Conversation> requests = account.getPending(); - if (requests.isEmpty()) { - notificationManager.cancel(notificationId); - return; - } - if (requests.size() == 1) { - Conversation request = requests.iterator().next(); - String contactKey = request.getUri().getRawUriString(); - if (notifiedRequests.contains(contactKey)) { - return; - } - mContactService.getLoadedContact(account.getAccountID(), request.getContacts(), false).subscribe(c -> { - NotificationCompat.Builder builder = getRequestNotificationBuilder(account.getAccountID()); - mPreferencesService.saveRequestPreferences(account.getAccountID(), contactKey); - android.net.Uri info = ConversationPath.toUri(account.getAccountID(), request.getUri()); - builder.setContentText(request.getUriTitle()) - .addAction(R.drawable.baseline_person_add_24, mContext.getText(R.string.accept), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(DRingService.ACTION_TRUST_REQUEST_ACCEPT, info, mContext, DRingService.class), - PendingIntent.FLAG_ONE_SHOT)) - .addAction(R.drawable.baseline_delete_24, mContext.getText(R.string.refuse), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(DRingService.ACTION_TRUST_REQUEST_REFUSE, info, mContext, DRingService.class), - PendingIntent.FLAG_ONE_SHOT)) - .addAction(R.drawable.baseline_block_24, mContext.getText(R.string.block), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(DRingService.ACTION_TRUST_REQUEST_BLOCK, info, mContext, DRingService.class), - PendingIntent.FLAG_ONE_SHOT)); - - Bitmap pic = getContactPicture(request); - if (pic != null) - builder.setLargeIcon(pic); - notificationManager.notify(notificationId, builder.build()); - }, e -> Log.w(TAG, "error showing notification", e)); - } else { - NotificationCompat.Builder builder = getRequestNotificationBuilder(account.getAccountID()); - boolean newRequest = false; - for (Conversation request : requests) { - Contact contact = request.getContact(); - if (contact != null) { - String contactKey = contact.getUri().getRawRingId(); - if (!notifiedRequests.contains(contactKey)) { - newRequest = true; - mPreferencesService.saveRequestPreferences(account.getAccountID(), contactKey); - } - } - } - if (!newRequest) - return; - builder.setContentText(String.format(mContext.getString(R.string.contact_request_msg), requests.size())); - builder.setLargeIcon(null); - notificationManager.notify(notificationId, builder.build()); - } - } - - @Override - public void showFileTransferNotification(Conversation conversation, DataTransfer info) { - if (info == null) { - return; - } - InteractionStatus event = info.getStatus(); - if (event == null || event == InteractionStatus.FILE_AVAILABLE) { - return; - } - android.net.Uri path = ConversationPath.toUri(conversation); - Log.d(TAG, "showFileTransferNotification " + path); - String dataTransferId = info.getFileId(); - int notificationId = getFileTransferNotificationId(path, dataTransferId); - - Intent intentConversation = new Intent(DRingService.ACTION_CONV_ACCEPT, path, mContext, DRingService.class); - - if (event.isOver()) { - removeTransferNotification(path, dataTransferId); - if (info.isOutgoing() || info.isError()) { - return; - } - - NotificationCompat.Builder notif = new NotificationCompat.Builder(mContext, NOTIF_CHANNEL_FILE_TRANSFER) - .setSmallIcon(R.drawable.ic_ring_logo_white) - .setContentIntent(PendingIntent.getService(mContext, random.nextInt(), intentConversation, 0)) - .setAutoCancel(true); - - if (info.showPicture()) { - File filePath = mDeviceRuntimeService.getConversationPath(conversation.getUri().getRawRingId(), info.getStoragePath()); - Bitmap img; - try { - BitmapDrawable d = (BitmapDrawable) Glide.with(mContext) - .load(filePath) - .submit() - .get(); - img = d.getBitmap(); - notif.setContentTitle(mContext.getString(R.string.notif_incoming_picture, conversation.getTitle())); - notif.setStyle(new NotificationCompat.BigPictureStyle() - .bigPicture(img)); - } catch (Exception e) { - Log.w(TAG, "Can't load image for notification", e); - return; - } - } else { - notif.setContentTitle(mContext.getString(R.string.notif_incoming_file_transfer_title, conversation.getTitle())); - notif.setStyle(null); - } - Bitmap picture = getContactPicture(conversation); - if (picture != null) - notif.setLargeIcon(picture); - notificationManager.notify(random.nextInt(), notif.build()); - return; - } - NotificationCompat.Builder messageNotificationBuilder = mNotificationBuilders.get(notificationId); - if (messageNotificationBuilder == null) { - messageNotificationBuilder = new NotificationCompat.Builder(mContext, NOTIF_CHANNEL_FILE_TRANSFER); - } - - boolean ongoing = event == InteractionStatus.TRANSFER_ONGOING || event == InteractionStatus.TRANSFER_ACCEPTED; - String titleMessage = mContext.getString(info.isOutgoing() ? R.string.notif_outgoing_file_transfer_title : R.string.notif_incoming_file_transfer_title, conversation.getTitle()); - messageNotificationBuilder.setContentTitle(titleMessage) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setAutoCancel(false) - .setOngoing(ongoing) - .setSmallIcon(R.drawable.ic_ring_logo_white) - .setCategory(NotificationCompat.CATEGORY_PROGRESS) - .setOnlyAlertOnce(true) - .setContentText(event == Interaction.InteractionStatus.TRANSFER_ONGOING ? - Formatter.formatFileSize(mContext, info.getBytesProgress()) + " / " + Formatter.formatFileSize(mContext, info.getTotalSize()) : - info.getDisplayName() + ": " + ResourceMapper.getReadableFileTransferStatus(mContext, event)) - .setContentIntent(PendingIntent.getService(mContext, random.nextInt(), intentConversation, 0)) - .setColor(ResourcesCompat.getColor(mContext.getResources(), R.color.color_primary_dark, null)); - Bitmap picture = getContactPicture(conversation); - if (picture != null) - messageNotificationBuilder.setLargeIcon(picture); - if (event.isOver()) { - messageNotificationBuilder.setProgress(0, 0, false); - } else if (ongoing) { - messageNotificationBuilder.setProgress((int) info.getTotalSize(), (int) info.getBytesProgress(), false); - } else { - messageNotificationBuilder.setProgress(0, 0, true); - } - if (event == Interaction.InteractionStatus.TRANSFER_CREATED) { - messageNotificationBuilder.setDefaults(NotificationCompat.DEFAULT_VIBRATE); - mNotificationBuilders.put(notificationId, messageNotificationBuilder); - // updateNotification(messageNotificationBuilder.build(), notificationId); - return; - } else { - messageNotificationBuilder.setDefaults(NotificationCompat.DEFAULT_LIGHTS); - } - messageNotificationBuilder.mActions.clear(); - if (event == Interaction.InteractionStatus.TRANSFER_AWAITING_HOST) { - messageNotificationBuilder - .addAction(R.drawable.baseline_call_received_24, mContext.getText(R.string.accept), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(DRingService.ACTION_FILE_ACCEPT, path, mContext, DRingService.class) - .putExtra(DRingService.KEY_TRANSFER_ID, dataTransferId), - PendingIntent.FLAG_ONE_SHOT)) - .addAction(R.drawable.baseline_cancel_24, mContext.getText(R.string.refuse), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(DRingService.ACTION_FILE_CANCEL, path, mContext, DRingService.class) - .putExtra(DRingService.KEY_TRANSFER_ID, dataTransferId), - PendingIntent.FLAG_ONE_SHOT)); - mNotificationBuilders.put(notificationId, messageNotificationBuilder); - updateNotification(messageNotificationBuilder.build(), notificationId); - return; - } else if (!event.isOver()) { - messageNotificationBuilder - .addAction(R.drawable.baseline_cancel_24, mContext.getText(android.R.string.cancel), - PendingIntent.getService(mContext, random.nextInt(), - new Intent(DRingService.ACTION_FILE_CANCEL, path, mContext, DRingService.class) - .putExtra(DRingService.KEY_TRANSFER_ID, dataTransferId), - PendingIntent.FLAG_ONE_SHOT)); - } - mNotificationBuilders.put(notificationId, messageNotificationBuilder); - dataTransferNotifications.put(notificationId, messageNotificationBuilder.build()); - ContextCompat.startForegroundService(mContext, new Intent(DataTransferService.ACTION_START, path, mContext, DataTransferService.class) - .putExtra(KEY_NOTIFICATION_ID, notificationId)); - //startForegroundService(notificationId, DataTransferService.class); - } - - @Override - public void showMissedCallNotification(Call call) { - final int notificationId = call.getDaemonIdString().hashCode(); - NotificationCompat.Builder messageNotificationBuilder = mNotificationBuilders.get(notificationId); - if (messageNotificationBuilder == null) { - messageNotificationBuilder = new NotificationCompat.Builder(mContext, NOTIF_CHANNEL_MISSED_CALL); - } - - android.net.Uri path = ConversationPath.toUri(call); - - Intent intentConversation = new Intent(DRingService.ACTION_CONV_ACCEPT, path, mContext, DRingService.class); - - messageNotificationBuilder.setContentTitle(mContext.getText(R.string.notif_missed_incoming_call)) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setSmallIcon(R.drawable.baseline_call_missed_24) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setOnlyAlertOnce(true) - .setAutoCancel(true) - .setContentText(call.getContact().getDisplayName()) - .setContentIntent(PendingIntent.getService(mContext, random.nextInt(), intentConversation, 0)) - .setColor(ResourcesCompat.getColor(mContext.getResources(), R.color.color_primary_dark, null)); - - setContactPicture(call.getContact(), messageNotificationBuilder); - notificationManager.notify(notificationId, messageNotificationBuilder.build()); - } - - @Override - public Object getServiceNotification() { - Intent intentHome = new Intent(Intent.ACTION_VIEW) - .setClass(mContext, HomeActivity.class) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - PendingIntent pendIntent = PendingIntent.getActivity(mContext, 0, intentHome, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Builder messageNotificationBuilder = new NotificationCompat.Builder(mContext, NotificationServiceImpl.NOTIF_CHANNEL_SERVICE); - messageNotificationBuilder - .setContentTitle(mContext.getText(R.string.app_name)) - .setContentText(mContext.getText(R.string.notif_background_service)) - .setSmallIcon(R.drawable.ic_ring_logo_white) - .setContentIntent(pendIntent) - .setVisibility(NotificationCompat.VISIBILITY_SECRET) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setOngoing(true) - .setCategory(NotificationCompat.CATEGORY_SERVICE); - return messageNotificationBuilder.build(); - } - - @Override - public void cancelTextNotification(String accountId, Uri contact) { - int notificationId = getTextNotificationId(accountId, contact); - notificationManager.cancel(notificationId); - mNotificationBuilders.remove(notificationId); - } - - @Override - public void cancelTrustRequestNotification(String accountID) { - if (accountID == null) { - return; - } - int notificationId = getIncomingTrustNotificationId(accountID); - notificationManager.cancel(notificationId); - } - - @Override - public void cancelCallNotification() { - notificationManager.cancel(NOTIF_CALL_ID); - mNotificationBuilders.remove(NOTIF_CALL_ID); - callNotifications.clear(); - } - - /**\ - * Cancels a notification - * @param notificationId the notification ID - * @param isMigratingToService true if the notification is being updated to be a part of the foreground service - */ - @Override - public void cancelFileNotification(int notificationId, boolean isMigratingToService) { - notificationManager.cancel(notificationId); - if(!isMigratingToService) - mNotificationBuilders.remove(notificationId); - } - - @Override - public void cancelAll() { - notificationManager.cancelAll(); - mNotificationBuilders.clear(); - } - - private int getIncomingTrustNotificationId(String accountId) { - return (NOTIF_TRUST_REQUEST + accountId).hashCode(); - } - - private int getTextNotificationId(String accountId, Uri contact) { - return (NOTIF_MSG + accountId + contact.toString()).hashCode(); - } - - private int getFileTransferNotificationId(android.net.Uri path, String dataTransferId) { - return (NOTIF_FILE_TRANSFER + path.toString() + dataTransferId).hashCode(); - } - - private Bitmap getContactPicture(Contact contact) { - try { - return AvatarFactory.getBitmapAvatar(mContext, contact, avatarSize, false).blockingGet(); - } catch (Exception e) { - return null; - } - } - private Bitmap getContactPicture(Conversation conversation) { - try { - return AvatarFactory.getBitmapAvatar(mContext, conversation, avatarSize, false).blockingGet(); - } catch (Exception e) { - return null; - } - } - - private Bitmap getContactPicture(Account account) { - return AvatarFactory.getBitmapAvatar(mContext, account, avatarSize).blockingGet(); - } - - private Pair<Bitmap, String> getProfile(Conversation conversation) { - return Pair.create(getContactPicture(conversation), conversation.getTitle()); - } - - private void setContactPicture(Contact contact, NotificationCompat.Builder messageNotificationBuilder) { - Bitmap pic = getContactPicture(contact); - if (pic != null) - messageNotificationBuilder.setLargeIcon(pic); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.kt b/ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.kt new file mode 100644 index 000000000..670963168 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.kt @@ -0,0 +1,1068 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.services + +import android.annotation.SuppressLint +import android.app.* +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.media.AudioAttributes +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build +import android.text.TextUtils +import android.text.format.Formatter +import android.util.Log +import android.util.SparseArray +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.core.util.Pair +import com.bumptech.glide.Glide +import cx.ring.R +import cx.ring.client.ConversationActivity +import cx.ring.client.HomeActivity +import cx.ring.contactrequests.ContactRequestsFragment +import cx.ring.fragments.ConversationFragment +import cx.ring.service.CallNotificationService +import cx.ring.service.DRingService +import cx.ring.settings.SettingsFragment +import cx.ring.tv.call.TVCallActivity +import cx.ring.utils.ConversationPath +import cx.ring.utils.DeviceUtils +import cx.ring.utils.ResourceMapper +import cx.ring.views.AvatarFactory +import net.jami.model.* +import net.jami.model.Interaction.InteractionStatus +import net.jami.services.* +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +class NotificationServiceImpl( + val mContext: Context, + var mAccountService: AccountService, + var mContactService: ContactService, + var mPreferencesService: PreferencesService, + var mDeviceRuntimeService: DeviceRuntimeService) : NotificationService { + private val mNotificationBuilders = SparseArray<NotificationCompat.Builder>() + + private var notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(mContext) + private val random = Random() + private var avatarSize = (mContext.resources.displayMetrics.density * AvatarFactory.SIZE_NOTIF).toInt() + private val currentCalls = LinkedHashMap<String, Conference>() + private val callNotifications = ConcurrentHashMap<Int, Notification>() + private val dataTransferNotifications = ConcurrentHashMap<Int, Notification>() + @SuppressLint("CheckResult") + fun initHelper() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + registerNotificationChannels(mContext) + } + } + + /** + * Starts the call activity directly for Android TV + * + * @param callId the call ID + */ + private fun startCallActivity(callId: String) { + mContext.startActivity( + Intent(Intent.ACTION_VIEW) + .putExtra(NotificationService.KEY_CALL_ID, callId) + .setClass(mContext.applicationContext, TVCallActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + + private fun buildCallNotification(conference: Conference): Notification? { + var ongoingCallId: String? = null + for (conf in currentCalls.values) { + if (conf !== conference && conf.state == Call.CallStatus.CURRENT) ongoingCallId = + conf.participants[0].daemonIdString + } + val call = conference.participants[0] + val gotoIntent = PendingIntent.getService(mContext, random.nextInt(), + Intent(DRingService.ACTION_CALL_VIEW) + .setClass(mContext, DRingService::class.java) + .putExtra(NotificationService.KEY_CALL_ID, call.daemonIdString), 0) + val contact = call.contact!! + val messageNotificationBuilder: NotificationCompat.Builder + if (conference.isOnGoing) { + messageNotificationBuilder = NotificationCompat.Builder(mContext, NOTIF_CHANNEL_CALL_IN_PROGRESS) + messageNotificationBuilder.setContentTitle(mContext.getString(R.string.notif_current_call_title, contact.displayName)) + .setContentText(mContext.getText(R.string.notif_current_call)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(gotoIntent) + .setSound(null) + .setVibrate(null) + .setColorized(true) + .setUsesChronometer(true) + .setWhen(conference.timestampStart) + .setColor(ContextCompat.getColor(mContext, R.color.color_primary_light)) + .addAction( + R.drawable.baseline_call_end_24, + mContext.getText(R.string.action_call_hangup), + PendingIntent.getService( + mContext, random.nextInt(), + Intent(DRingService.ACTION_CALL_END) + .setClass(mContext, DRingService::class.java) + .putExtra(NotificationService.KEY_CALL_ID, call.daemonIdString), + PendingIntent.FLAG_ONE_SHOT + ) + ) + } else if (conference.isRinging) { + if (conference.isIncoming) { + messageNotificationBuilder = + NotificationCompat.Builder(mContext, NOTIF_CHANNEL_INCOMING_CALL) + messageNotificationBuilder.setContentTitle( + mContext.getString( + R.string.notif_incoming_call_title, + contact.displayName + ) + ) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setContentText(mContext.getText(R.string.notif_incoming_call)) + .setContentIntent(gotoIntent) + .setSound(null) + .setVibrate(null) + .setFullScreenIntent(gotoIntent, true) + .addAction( + R.drawable.baseline_call_end_24, + mContext.getText(R.string.action_call_decline), + PendingIntent.getService( + mContext, random.nextInt(), + Intent(DRingService.ACTION_CALL_REFUSE) + .setClass(mContext, DRingService::class.java) + .putExtra(NotificationService.KEY_CALL_ID, call.daemonIdString), + PendingIntent.FLAG_ONE_SHOT + ) + ) + .addAction( + R.drawable.baseline_call_24, + if (ongoingCallId == null) + mContext.getText(R.string.action_call_accept) + else + mContext.getText(R.string.action_call_end_accept), + PendingIntent.getService( + mContext, random.nextInt(), + Intent(if (ongoingCallId == null) DRingService.ACTION_CALL_ACCEPT else DRingService.ACTION_CALL_END_ACCEPT) + .setClass(mContext, DRingService::class.java) + .putExtra(NotificationService.KEY_END_ID, ongoingCallId) + .putExtra(NotificationService.KEY_CALL_ID, call.daemonIdString), + PendingIntent.FLAG_ONE_SHOT + ) + ) + if (ongoingCallId != null) { + messageNotificationBuilder.addAction( + R.drawable.baseline_call_24, + mContext.getText(R.string.action_call_hold_accept), + PendingIntent.getService( + mContext, random.nextInt(), + Intent(DRingService.ACTION_CALL_HOLD_ACCEPT) + .setClass(mContext, DRingService::class.java) + .putExtra(NotificationService.KEY_HOLD_ID, ongoingCallId) + .putExtra(NotificationService.KEY_CALL_ID, call.daemonIdString), + PendingIntent.FLAG_ONE_SHOT + ) + ) + } + } else { + messageNotificationBuilder = NotificationCompat.Builder(mContext, NOTIF_CHANNEL_CALL_IN_PROGRESS) + .setContentTitle(mContext.getString(R.string.notif_outgoing_call_title, contact.displayName)) + .setContentText(mContext.getText(R.string.notif_outgoing_call)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(gotoIntent) + .setSound(null) + .setVibrate(null) + .setColorized(true) + .setColor(ContextCompat.getColor(mContext, R.color.color_primary_light)) + .addAction( + R.drawable.baseline_call_end_24, + mContext.getText(R.string.action_call_hangup), + PendingIntent.getService( + mContext, random.nextInt(), + Intent(DRingService.ACTION_CALL_END) + .setClass(mContext, DRingService::class.java) + .putExtra(NotificationService.KEY_CALL_ID, call.daemonIdString), + PendingIntent.FLAG_ONE_SHOT + ) + ) + } + } else { + return null + } + messageNotificationBuilder.setOngoing(true) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setSmallIcon(R.drawable.ic_ring_logo_white) + setContactPicture(contact, messageNotificationBuilder) + return messageNotificationBuilder.build() + } + + override fun showCallNotification(notifId: Int): Any { + return callNotifications.remove(notifId)!! + } + + override fun showLocationNotification(first: Account, contact: Contact) { + val path = ConversationPath.toUri(first.accountID, contact.uri) + val intentConversation = + Intent(Intent.ACTION_VIEW, path, mContext, ConversationActivity::class.java) + .putExtra(ConversationFragment.EXTRA_SHOW_MAP, true) + val messageNotificationBuilder = NotificationCompat.Builder( + mContext, NOTIF_CHANNEL_MESSAGE + ) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setSmallIcon(R.drawable.ic_ring_logo_white) + .setLargeIcon(getContactPicture(contact)) + .setContentText( + mContext.getString( + R.string.location_share_contact, + contact.displayName + ) + ) + .setContentIntent( + PendingIntent.getActivity( + mContext, + random.nextInt(), + intentConversation, + 0 + ) + ) + .setAutoCancel(false) + .setColor(ResourcesCompat.getColor( + mContext.resources, + R.color.color_primary_dark, + null + )) + notificationManager.notify( + Objects.hash("Location", path), + messageNotificationBuilder.build() + ) + } + + override fun cancelLocationNotification(first: Account, contact: Contact) { + notificationManager.cancel( + Objects.hash( + "Location", + ConversationPath.toUri(first.accountID, contact.uri) + ) + ) + } + + /** + * Updates a notification + * + * @param notification a built notification object + * @param notificationId the notification's id + */ + private fun updateNotification(notification: Notification, notificationId: Int) { + notificationManager.notify(notificationId, notification) + } + + /** + * Starts a service (data transfer or call) + * + * @param id the notification id + */ + private fun startForegroundService(id: Int, serviceClass: Class<*>) { + ContextCompat.startForegroundService( + mContext, Intent(mContext, serviceClass) + .putExtra(NotificationService.KEY_NOTIFICATION_ID, id) + ) + } + + /** + * Handles the creation and destruction of services associated with calls as well as displaying notifications. + * + * @param conference the conference object for the notification + * @param remove true if it should be removed from current calls + */ + override fun handleCallNotification(conference: Conference, remove: Boolean) { + if (DeviceUtils.isTv(mContext)) { + if (!remove) startCallActivity(conference.id) + return + } + var notification: Notification? = null + + // Build notification + val id = conference.id + currentCalls.remove(id) + if (!remove) { + currentCalls[id] = conference + notification = buildCallNotification(conference) + } + if (notification == null && currentCalls.isNotEmpty()) { + // Build notification for other calls if any remains + //for (c in currentCalls.values) conference = c + notification = buildCallNotification(currentCalls.values.last()) + } + + // Send notification to the Service + if (notification != null) { + val nid = random.nextInt() + callNotifications[nid] = notification + ContextCompat.startForegroundService(mContext, + Intent(CallNotificationService.ACTION_START, null, mContext, CallNotificationService::class.java) + .putExtra(NotificationService.KEY_NOTIFICATION_ID, nid)) + } else { + try { + mContext.startService(Intent(CallNotificationService.ACTION_STOP, null, mContext, CallNotificationService::class.java)) + } catch (e: Exception) { + Log.w(TAG, "Error stopping service", e) + } + } + } + + override fun onConnectionUpdate(b: Boolean) { + /*Log.i(TAG, "onConnectionUpdate " + b); + if (b) { + Intent serviceIntent = new Intent(SyncService.ACTION_START).setClass(mContext, SyncService.class); + try { + ContextCompat.startForegroundService(mContext, serviceIntent); + } catch (IllegalStateException e) { + Log.e(TAG, "Error starting service", e); + } + } else { + try { + mContext.startService(new Intent(SyncService.ACTION_STOP).setClass(mContext, SyncService.class)); + } catch (IllegalStateException ignored) { + } + }*/ + } + + /** + * Handles the creation and destruction of services associated with transfers as well as displaying notifications. + * + * @param transfer the data transfer object + * @param conversation the contact to whom the data transfer is being sent + * @param remove true if it should be removed from current calls + */ + override fun handleDataTransferNotification(transfer: DataTransfer, conversation: Conversation, remove: Boolean) { + Log.d(TAG, "handleDataTransferNotification, a data transfer event is in progress $remove") + if (DeviceUtils.isTv(mContext)) { + return + } + if (!remove) { + showFileTransferNotification(conversation, transfer) + } else { + removeTransferNotification(ConversationPath.toUri(conversation), transfer.fileId ?: transfer.id.toString()) + } + } + + override fun removeTransferNotification(accountId: String, conversationUri: net.jami.model.Uri, transferId: String) { + removeTransferNotification(ConversationPath.toUri(accountId, conversationUri), transferId) + } + + /** + * Cancels a data transfer notification and removes it from the list of notifications + * + * @param transferId the transfer id which is required to generate the notification id + */ + fun removeTransferNotification(path: Uri, transferId: String) { + val id = getFileTransferNotificationId(path, transferId) + dataTransferNotifications.remove(id) + cancelFileNotification(id, false) + if (dataTransferNotifications.isEmpty()) { + mContext.startService( + Intent( + DataTransferService.ACTION_STOP, + path, + mContext, + DataTransferService::class.java + ) + .putExtra(NotificationService.KEY_NOTIFICATION_ID, id) + ) + } else { + ContextCompat.startForegroundService( + mContext, + Intent( + DataTransferService.ACTION_STOP, + path, + mContext, + DataTransferService::class.java + ) + .putExtra(NotificationService.KEY_NOTIFICATION_ID, id) + ) + } + } + + /** + * @param notificationId the notification id + * @return the notification object for a data transfer notification + */ + override fun getDataTransferNotification(notificationId: Int): Notification { + return dataTransferNotifications[notificationId]!! + } + + override fun showTextNotification(accountId: String, conversation: Conversation) { + val texts = conversation.unreadTextMessages + + //Log.w(TAG, "showTextNotification start " + accountId + " " + conversation.getUri() + " " + texts.size()); + + //TODO handle groups + if (texts.isEmpty() || conversation.isVisible) { + cancelTextNotification(conversation.accountId, conversation.uri) + return + } + if (texts.lastEntry().value.isNotified) { + return + } + Log.w(TAG, "showTextNotification " + accountId + " " + conversation.uri) + mContactService.getLoadedContact(accountId, conversation.contacts, false) + .subscribe({ textNotification(accountId, texts, conversation) }) + { e: Throwable -> Log.w(TAG, "Can't load contact", e) } + } + + private fun textNotification( + accountId: String, + texts: TreeMap<Long, TextMessage>, + conversation: Conversation + ) { + val cpath = ConversationPath(conversation) + val path = cpath.toUri() + val conversationProfile = getProfile(conversation) + var notificationVisibility = mPreferencesService.settings.notificationVisibility + notificationVisibility = when (notificationVisibility) { + SettingsFragment.NOTIFICATION_PUBLIC -> Notification.VISIBILITY_PUBLIC + SettingsFragment.NOTIFICATION_SECRET -> Notification.VISIBILITY_SECRET + SettingsFragment.NOTIFICATION_PRIVATE -> Notification.VISIBILITY_PRIVATE + else -> Notification.VISIBILITY_PRIVATE + } + val last = texts.lastEntry()?.value + val intentConversation = Intent(DRingService.ACTION_CONV_ACCEPT, path, mContext, DRingService::class.java) + val intentDelete = Intent(DRingService.ACTION_CONV_DISMISS, path, mContext, DRingService::class.java) + val messageNotificationBuilder = NotificationCompat.Builder(mContext, NOTIF_CHANNEL_MESSAGE) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(Notification.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setVisibility(notificationVisibility) + .setSmallIcon(R.drawable.ic_ring_logo_white) + .setContentTitle(conversationProfile.second) + .setContentText(last?.body) + .setWhen(last?.timestamp ?: 0) + .setContentIntent(PendingIntent.getService(mContext, random.nextInt(), intentConversation, 0)) + .setDeleteIntent(PendingIntent.getService(mContext, random.nextInt(), intentDelete, 0)) + .setAutoCancel(true) + .setColor( + ResourcesCompat.getColor( + mContext.resources, + R.color.color_primary_dark, + null + ) + ) + val key = cpath.toKey() + val conversationPerson = Person.Builder() + .setKey(key) + .setName(conversationProfile.second) + .setIcon( + if (conversationProfile.first == null) null else IconCompat.createWithBitmap(conversationProfile.first) + ) + .build() + if (conversationProfile.first != null) { + messageNotificationBuilder.setLargeIcon(conversationProfile.first) + val intentBubble = Intent(Intent.ACTION_VIEW, path, mContext, ConversationActivity::class.java) + intentBubble.putExtra(EXTRA_BUBBLE, true) + messageNotificationBuilder + .setBubbleMetadata(NotificationCompat.BubbleMetadata.Builder(PendingIntent.getActivity( + mContext, 0, intentBubble, + PendingIntent.FLAG_UPDATE_CURRENT + ), IconCompat.createWithAdaptiveBitmap(conversationProfile.first)) + .setDesiredHeight(600) + .build()) + .addPerson(conversationPerson) + .setShortcutId(key) + } + if (texts.size == 1) { + last!!.isNotified = true + messageNotificationBuilder.setStyle(null) + } else { + val account = mAccountService.getAccount(accountId) + val profile = if (account == null) null else VCardServiceImpl.loadProfile( + mContext, account + ).blockingFirst() + val myPic = account?.let { getContactPicture(it) } + val userPerson = Person.Builder() + .setKey(accountId) + .setName(if (profile == null || TextUtils.isEmpty(profile.first)) "You" else profile.first) + .setIcon(if (myPic == null) null else IconCompat.createWithBitmap(myPic)) + .build() + val history = NotificationCompat.MessagingStyle(userPerson) + for (textMessage in texts.values) { + val contact = textMessage.contact!! + val contactPicture = getContactPicture(contact) + val contactPerson = Person.Builder() + .setKey(ConversationPath.toKey(cpath.accountId, contact.uri.uri)) + .setName(contact.displayName) + .setIcon(if (contactPicture == null) null else IconCompat.createWithBitmap(contactPicture)) + .build() + history.addMessage( + NotificationCompat.MessagingStyle.Message( + textMessage.body, + textMessage.timestamp, + if (textMessage.isIncoming) contactPerson else null + ) + ) + } + messageNotificationBuilder.setStyle(history) + } + val notificationId = getTextNotificationId(conversation.accountId, conversation.uri) + val replyId = notificationId + 1 + val markAsReadId = notificationId + 2 + val replyLabel = mContext.getText(R.string.notif_reply) + val remoteInput = RemoteInput.Builder(DRingService.KEY_TEXT_REPLY) + .setLabel(replyLabel) + .build() + val replyPendingIntent = PendingIntent.getService( + mContext, replyId, + Intent(DRingService.ACTION_CONV_REPLY_INLINE, path, mContext, DRingService::class.java), + PendingIntent.FLAG_UPDATE_CURRENT + ) + val readPendingIntent = PendingIntent.getService( + mContext, markAsReadId, + Intent(DRingService.ACTION_CONV_READ, path, mContext, DRingService::class.java), 0 + ) + messageNotificationBuilder + .addAction( + NotificationCompat.Action.Builder( + R.drawable.baseline_reply_24, + replyLabel, + replyPendingIntent + ) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .addRemoteInput(remoteInput) + .extend( + NotificationCompat.Action.WearableExtender() + .setHintDisplayActionInline(true) + ) + .build() + ) + .addAction( + NotificationCompat.Action.Builder( + 0, + mContext.getString(R.string.notif_mark_as_read), + readPendingIntent + ) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .setShowsUserInterface(false) + .build() + ) + notificationManager.notify(notificationId, messageNotificationBuilder.build()) + mNotificationBuilders.put(notificationId, messageNotificationBuilder) + } + + private fun getRequestNotificationBuilder(accountId: String): NotificationCompat.Builder { + val builder = NotificationCompat.Builder( + mContext, NOTIF_CHANNEL_REQUEST + ) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_ring_logo_white) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setContentTitle(mContext.getString(R.string.contact_request_title)) + val intentOpenTrustRequestFragment = + Intent(HomeActivity.ACTION_PRESENT_TRUST_REQUEST_FRAGMENT) + .setClass(mContext, HomeActivity::class.java) + .putExtra(ContactRequestsFragment.ACCOUNT_ID, accountId) + builder.setContentIntent( + PendingIntent.getActivity( + mContext, + random.nextInt(), intentOpenTrustRequestFragment, PendingIntent.FLAG_ONE_SHOT + ) + ) + builder.color = ResourcesCompat.getColor( + mContext.resources, + R.color.color_primary_dark, null + ) + return builder + } + + override fun showIncomingTrustRequestNotification(account: Account) { + val notificationId = getIncomingTrustNotificationId(account.accountID) + val notifiedRequests = mPreferencesService.loadRequestsPreferences(account.accountID) + val requests = account.getPending() + if (requests.isEmpty()) { + notificationManager.cancel(notificationId) + return + } + if (requests.size == 1) { + val request = requests.iterator().next() + val contactKey = request.uri.rawUriString + if (notifiedRequests.contains(contactKey)) { + return + } + mContactService.getLoadedContact(account.accountID, request.contacts, false) + .subscribe( + { + val builder = getRequestNotificationBuilder(account.accountID) + mPreferencesService.saveRequestPreferences(account.accountID, contactKey) + val info = ConversationPath.toUri(account.accountID, request.uri) + builder.setContentText(request.uriTitle) + .addAction( + R.drawable.baseline_person_add_24, + mContext.getText(R.string.accept), + PendingIntent.getService( + mContext, random.nextInt(), + Intent( + DRingService.ACTION_TRUST_REQUEST_ACCEPT, + info, + mContext, + DRingService::class.java + ), + PendingIntent.FLAG_ONE_SHOT + ) + ) + .addAction( + R.drawable.baseline_delete_24, mContext.getText(R.string.refuse), + PendingIntent.getService( + mContext, random.nextInt(), + Intent( + DRingService.ACTION_TRUST_REQUEST_REFUSE, + info, + mContext, + DRingService::class.java + ), + PendingIntent.FLAG_ONE_SHOT + ) + ) + .addAction( + R.drawable.baseline_block_24, mContext.getText(R.string.block), + PendingIntent.getService( + mContext, random.nextInt(), + Intent( + DRingService.ACTION_TRUST_REQUEST_BLOCK, + info, + mContext, + DRingService::class.java + ), + PendingIntent.FLAG_ONE_SHOT + ) + ) + val pic = getContactPicture(request) + if (pic != null) builder.setLargeIcon(pic) + notificationManager.notify(notificationId, builder.build()) + }) { e: Throwable? -> Log.w(TAG, "error showing notification", e) } + } else { + val builder = getRequestNotificationBuilder(account.accountID) + var newRequest = false + for (request in requests) { + val contact = request.contact + if (contact != null) { + val contactKey = contact.uri.rawRingId + if (!notifiedRequests.contains(contactKey)) { + newRequest = true + mPreferencesService.saveRequestPreferences(account.accountID, contactKey) + } + } + } + if (!newRequest) return + builder.setContentText( + String.format( + mContext.getString(R.string.contact_request_msg), + requests.size + ) + ) + builder.setLargeIcon(null) + notificationManager.notify(notificationId, builder.build()) + } + } + + override fun showFileTransferNotification(conversation: Conversation, info: DataTransfer) { + val event = info.status ?: return + if (event == InteractionStatus.FILE_AVAILABLE) + return + val path = ConversationPath.toUri(conversation) + Log.d(TAG, "showFileTransferNotification $path") + val dataTransferId = info.fileId ?: info.id.toString() + val notificationId = getFileTransferNotificationId(path, dataTransferId) + val intentConversation = + Intent(DRingService.ACTION_CONV_ACCEPT, path, mContext, DRingService::class.java) + if (event.isOver) { + removeTransferNotification(path, dataTransferId) + if (info.isOutgoing || info.isError) { + return + } + val notif = NotificationCompat.Builder(mContext, NOTIF_CHANNEL_FILE_TRANSFER) + .setSmallIcon(R.drawable.ic_ring_logo_white) + .setContentIntent(PendingIntent.getService(mContext, random.nextInt(), intentConversation, 0)) + .setAutoCancel(true) + if (info.showPicture()) { + val filePath = mDeviceRuntimeService.getConversationPath( + conversation.uri.rawRingId, + info.storagePath + ) + val img: Bitmap + try { + val d = Glide.with(mContext) + .load(filePath) + .submit() + .get() as BitmapDrawable + img = d.bitmap + notif.setContentTitle(mContext.getString(R.string.notif_incoming_picture,conversation.title)) + notif.setStyle(NotificationCompat.BigPictureStyle().bigPicture(img)) + } catch (e: Exception) { + Log.w(TAG, "Can't load image for notification", e) + return + } + } else { + notif.setContentTitle(mContext.getString(R.string.notif_incoming_file_transfer_title, conversation.title)) + notif.setStyle(null) + } + val picture = getContactPicture(conversation) + if (picture != null) notif.setLargeIcon(picture) + notificationManager.notify(random.nextInt(), notif.build()) + return + } + var messageNotificationBuilder = mNotificationBuilders[notificationId] + if (messageNotificationBuilder == null) { + messageNotificationBuilder = NotificationCompat.Builder(mContext, NOTIF_CHANNEL_FILE_TRANSFER) + } + val ongoing = event == InteractionStatus.TRANSFER_ONGOING || event == InteractionStatus.TRANSFER_ACCEPTED + val titleMessage = mContext.getString( + if (info.isOutgoing) R.string.notif_outgoing_file_transfer_title else R.string.notif_incoming_file_transfer_title, + conversation.title + ) + messageNotificationBuilder.setContentTitle(titleMessage) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setAutoCancel(false) + .setOngoing(ongoing) + .setSmallIcon(R.drawable.ic_ring_logo_white) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setOnlyAlertOnce(true) + .setContentText( + if (event == InteractionStatus.TRANSFER_ONGOING) + Formatter.formatFileSize(mContext, info.bytesProgress) + " / " + Formatter.formatFileSize(mContext, info.totalSize) + else + info.displayName + ": " + ResourceMapper.getReadableFileTransferStatus(mContext, event) + ) + .setContentIntent(PendingIntent.getService(mContext, random.nextInt(), intentConversation, 0)) + .color = ResourcesCompat.getColor(mContext.resources, R.color.color_primary_dark, null) + val picture = getContactPicture(conversation) + if (picture != null) messageNotificationBuilder.setLargeIcon(picture) + when { + event.isOver -> messageNotificationBuilder.setProgress(0, 0, false) + ongoing -> messageNotificationBuilder.setProgress(info.totalSize.toInt(), info.bytesProgress.toInt(), false) + else -> messageNotificationBuilder.setProgress(0, 0, true) + } + if (event == InteractionStatus.TRANSFER_CREATED) { + messageNotificationBuilder.setDefaults(NotificationCompat.DEFAULT_VIBRATE) + mNotificationBuilders.put(notificationId, messageNotificationBuilder) + // updateNotification(messageNotificationBuilder.build(), notificationId); + return + } else { + messageNotificationBuilder.setDefaults(NotificationCompat.DEFAULT_LIGHTS) + } + messageNotificationBuilder.mActions.clear() + if (event == InteractionStatus.TRANSFER_AWAITING_HOST) { + messageNotificationBuilder + .addAction(R.drawable.baseline_call_received_24, mContext.getText(R.string.accept), + PendingIntent.getService(mContext, random.nextInt(), + Intent(DRingService.ACTION_FILE_ACCEPT, path, mContext, DRingService::class.java) + .putExtra(DRingService.KEY_TRANSFER_ID, dataTransferId), PendingIntent.FLAG_ONE_SHOT)) + .addAction(R.drawable.baseline_cancel_24, mContext.getText(R.string.refuse), + PendingIntent.getService(mContext, random.nextInt(), + Intent(DRingService.ACTION_FILE_CANCEL, path, mContext, DRingService::class.java) + .putExtra(DRingService.KEY_TRANSFER_ID, dataTransferId), PendingIntent.FLAG_ONE_SHOT)) + mNotificationBuilders.put(notificationId, messageNotificationBuilder) + updateNotification(messageNotificationBuilder.build(), notificationId) + return + } else if (!event.isOver) { + messageNotificationBuilder + .addAction(R.drawable.baseline_cancel_24, mContext.getText(android.R.string.cancel), + PendingIntent.getService(mContext, random.nextInt(), + Intent(DRingService.ACTION_FILE_CANCEL, path, mContext, DRingService::class.java) + .putExtra(DRingService.KEY_TRANSFER_ID, dataTransferId), PendingIntent.FLAG_ONE_SHOT)) + } + mNotificationBuilders.put(notificationId, messageNotificationBuilder) + dataTransferNotifications[notificationId] = messageNotificationBuilder.build() + ContextCompat.startForegroundService(mContext, Intent(DataTransferService.ACTION_START, path, mContext, DataTransferService::class.java) + .putExtra(NotificationService.KEY_NOTIFICATION_ID, notificationId)) + //startForegroundService(notificationId, DataTransferService.class); + } + + override fun showMissedCallNotification(call: Call) { + val notificationId = call.daemonIdString.hashCode() + var messageNotificationBuilder = mNotificationBuilders[notificationId] + if (messageNotificationBuilder == null) { + messageNotificationBuilder = NotificationCompat.Builder(mContext, NOTIF_CHANNEL_MISSED_CALL) + } + val path = ConversationPath.toUri(call) + val intentConversation = Intent(DRingService.ACTION_CONV_ACCEPT, path, mContext, DRingService::class.java) + val contact = call.contact!! + messageNotificationBuilder.setContentTitle(mContext.getText(R.string.notif_missed_incoming_call)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setSmallIcon(R.drawable.baseline_call_missed_24) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setContentText(contact.displayName) + .setContentIntent(PendingIntent.getService(mContext, random.nextInt(), intentConversation, 0)) + .color = ResourcesCompat.getColor(mContext.resources, R.color.color_primary_dark, null) + setContactPicture(contact, messageNotificationBuilder) + notificationManager.notify(notificationId, messageNotificationBuilder.build()) + } + + override fun getServiceNotification(): Any { + val intentHome = Intent(Intent.ACTION_VIEW) + .setClass(mContext, HomeActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val pendIntent = PendingIntent.getActivity(mContext, 0, intentHome, PendingIntent.FLAG_UPDATE_CURRENT) + val messageNotificationBuilder = NotificationCompat.Builder(mContext, NOTIF_CHANNEL_SERVICE) + messageNotificationBuilder + .setContentTitle(mContext.getText(R.string.app_name)) + .setContentText(mContext.getText(R.string.notif_background_service)) + .setSmallIcon(R.drawable.ic_ring_logo_white) + .setContentIntent(pendIntent) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setOngoing(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + return messageNotificationBuilder.build() + } + + override fun cancelTextNotification(accountId: String, contact: net.jami.model.Uri) { + val notificationId = getTextNotificationId(accountId, contact) + notificationManager.cancel(notificationId) + mNotificationBuilders.remove(notificationId) + } + + override fun cancelTrustRequestNotification(accountID: String) { + val notificationId = getIncomingTrustNotificationId(accountID) + notificationManager.cancel(notificationId) + } + + override fun cancelCallNotification() { + notificationManager.cancel(NOTIF_CALL_ID) + mNotificationBuilders.remove(NOTIF_CALL_ID) + callNotifications.clear() + } + + /**\ + * Cancels a notification + * @param notificationId the notification ID + * @param isMigratingToService true if the notification is being updated to be a part of the foreground service + */ + override fun cancelFileNotification(notificationId: Int, isMigratingToService: Boolean) { + notificationManager.cancel(notificationId) + if (!isMigratingToService) mNotificationBuilders.remove(notificationId) + } + + override fun cancelAll() { + notificationManager.cancelAll() + mNotificationBuilders.clear() + } + + private fun getIncomingTrustNotificationId(accountId: String): Int { + return (NOTIF_TRUST_REQUEST + accountId).hashCode() + } + + private fun getTextNotificationId(accountId: String, contact: net.jami.model.Uri): Int { + return (NOTIF_MSG + accountId + contact.toString()).hashCode() + } + + private fun getFileTransferNotificationId(path: Uri, dataTransferId: String): Int { + return (NOTIF_FILE_TRANSFER + path.toString() + dataTransferId).hashCode() + } + + private fun getContactPicture(contact: Contact): Bitmap? { + return try { + AvatarFactory.getBitmapAvatar(mContext, contact, avatarSize, false).blockingGet() + } catch (e: Exception) { + null + } + } + + private fun getContactPicture(conversation: Conversation): Bitmap? { + return try { + AvatarFactory.getBitmapAvatar(mContext, conversation, avatarSize, false).blockingGet() + } catch (e: Exception) { + null + } + } + + private fun getContactPicture(account: Account): Bitmap { + return AvatarFactory.getBitmapAvatar(mContext, account, avatarSize).blockingGet() + } + + private fun getProfile(conversation: Conversation): Pair<Bitmap?, String> { + return Pair.create(getContactPicture(conversation), conversation.title) + } + + private fun setContactPicture( + contact: Contact, + messageNotificationBuilder: NotificationCompat.Builder + ) { + val pic = getContactPicture(contact) + if (pic != null) messageNotificationBuilder.setLargeIcon(pic) + } + + companion object { + const val EXTRA_BUBBLE = "bubble" + private val TAG = NotificationServiceImpl::class.java.simpleName + private const val NOTIF_MSG = "MESSAGE" + private const val NOTIF_TRUST_REQUEST = "TRUST REQUEST" + private const val NOTIF_FILE_TRANSFER = "FILE_TRANSFER" + private const val NOTIF_MISSED_CALL = "MISSED_CALL" + private const val NOTIF_CHANNEL_CALL_IN_PROGRESS = "current_call" + private const val NOTIF_CHANNEL_MISSED_CALL = "missed_calls" + private const val NOTIF_CHANNEL_INCOMING_CALL = "incoming_call" + private const val NOTIF_CHANNEL_MESSAGE = "messages" + private const val NOTIF_CHANNEL_REQUEST = "requests" + private const val NOTIF_CHANNEL_FILE_TRANSFER = "file_transfer" + const val NOTIF_CHANNEL_SYNC = "sync" + private const val NOTIF_CHANNEL_SERVICE = "service" + private const val NOTIF_CALL_GROUP = "calls" + const val NOTIF_CALL_ID = 1001 + + @RequiresApi(api = Build.VERSION_CODES.O) + fun registerNotificationChannels(context: Context) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Setting up groups + notificationManager.createNotificationChannelGroup( + NotificationChannelGroup( + NOTIF_CALL_GROUP, context.getString(R.string.notif_group_calls) + ) + ) + + // Missed calls channel + val missedCallsChannel = NotificationChannel( + NOTIF_CHANNEL_MISSED_CALL, + context.getString(R.string.notif_channel_missed_calls), + NotificationManager.IMPORTANCE_DEFAULT + ) + missedCallsChannel.lockscreenVisibility = Notification.VISIBILITY_SECRET + missedCallsChannel.setSound(null, null) + missedCallsChannel.enableVibration(false) + missedCallsChannel.group = NOTIF_CALL_GROUP + notificationManager.createNotificationChannel(missedCallsChannel) + + // Incoming call channel + val incomingCallChannel = NotificationChannel( + NOTIF_CHANNEL_INCOMING_CALL, + context.getString(R.string.notif_channel_incoming_calls), + NotificationManager.IMPORTANCE_HIGH + ) + incomingCallChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + incomingCallChannel.group = NOTIF_CALL_GROUP + incomingCallChannel.setSound(null, null) + incomingCallChannel.enableVibration(false) + notificationManager.createNotificationChannel(incomingCallChannel) + + // Call in progress channel + val callInProgressChannel = NotificationChannel( + NOTIF_CHANNEL_CALL_IN_PROGRESS, + context.getString(R.string.notif_channel_call_in_progress), + NotificationManager.IMPORTANCE_DEFAULT + ) + callInProgressChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + callInProgressChannel.setSound(null, null) + callInProgressChannel.enableVibration(false) + callInProgressChannel.group = NOTIF_CALL_GROUP + notificationManager.createNotificationChannel(callInProgressChannel) + + // Text messages channel + val soundAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) + .build() + val messageChannel = NotificationChannel( + NOTIF_CHANNEL_MESSAGE, + context.getString(R.string.notif_channel_messages), + NotificationManager.IMPORTANCE_HIGH + ) + messageChannel.enableVibration(true) + messageChannel.lockscreenVisibility = Notification.VISIBILITY_SECRET + messageChannel.setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), + soundAttributes + ) + notificationManager.createNotificationChannel(messageChannel) + + // Contact requests + val requestsChannel = NotificationChannel( + NOTIF_CHANNEL_REQUEST, + context.getString(R.string.notif_channel_requests), + NotificationManager.IMPORTANCE_DEFAULT + ) + requestsChannel.enableVibration(true) + requestsChannel.lockscreenVisibility = Notification.VISIBILITY_SECRET + requestsChannel.setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), + soundAttributes + ) + notificationManager.createNotificationChannel(requestsChannel) + + // File transfer requests + val fileTransferChannel = NotificationChannel( + NOTIF_CHANNEL_FILE_TRANSFER, + context.getString(R.string.notif_channel_file_transfer), + NotificationManager.IMPORTANCE_DEFAULT + ) + fileTransferChannel.enableVibration(true) + fileTransferChannel.lockscreenVisibility = Notification.VISIBILITY_SECRET + fileTransferChannel.setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), + soundAttributes + ) + notificationManager.createNotificationChannel(fileTransferChannel) + + // File transfer requests + val syncChannel = NotificationChannel( + NOTIF_CHANNEL_SYNC, + context.getString(R.string.notif_channel_sync), + NotificationManager.IMPORTANCE_DEFAULT + ) + syncChannel.lockscreenVisibility = Notification.VISIBILITY_SECRET + syncChannel.enableLights(false) + syncChannel.enableVibration(false) + syncChannel.setShowBadge(false) + syncChannel.setSound(null, null) + notificationManager.createNotificationChannel(syncChannel) + + // Background service channel + val backgroundChannel = NotificationChannel( + NOTIF_CHANNEL_SERVICE, + context.getString(R.string.notif_channel_background_service), + NotificationManager.IMPORTANCE_LOW + ) + backgroundChannel.description = + context.getString(R.string.notif_channel_background_service_descr) + backgroundChannel.lockscreenVisibility = Notification.VISIBILITY_SECRET + backgroundChannel.enableLights(false) + backgroundChannel.enableVibration(false) + backgroundChannel.setShowBadge(false) + notificationManager.createNotificationChannel(backgroundChannel) + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/services/SharedPreferencesServiceImpl.java b/ring-android/app/src/main/java/cx/ring/services/SharedPreferencesServiceImpl.java deleted file mode 100644 index 355717553..000000000 --- a/ring-android/app/src/main/java/cx/ring/services/SharedPreferencesServiceImpl.java +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.services; - -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Build; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.preference.PreferenceManager; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -import javax.inject.Inject; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import net.jami.model.Settings; -import net.jami.model.Uri; -import net.jami.services.PreferencesService; - -import cx.ring.utils.DeviceUtils; -import cx.ring.utils.NetworkUtils; - -public class SharedPreferencesServiceImpl extends PreferencesService { - - public static final String PREFS_SETTINGS = "ring_settings"; - private static final String PREFS_REQUESTS = "ring_requests"; - public static final String PREFS_THEME = "theme"; - public static final String PREFS_VIDEO = "videoPrefs"; - public static final String PREFS_ACCOUNT = "account_"; - - private static final String PREF_PUSH_NOTIFICATIONS = "push_notifs"; - private static final String PREF_PERSISTENT_NOTIFICATION = "persistent_notif"; - private static final String PREF_SHOW_TYPING = "persistent_typing"; - private static final String PREF_SHOW_READ = "persistent_read"; - private static final String PREF_BLOCK_RECORD = "persistent_block_record"; - private static final String PREF_NOTIFICATION_VISIBILITY = "persistent_notification"; - private static final String PREF_HW_ENCODING = "video_hwenc"; - public static final String PREF_BITRATE = "video_bitrate"; - public static final String PREF_RESOLUTION = "video_resolution"; - private static final String PREF_SYSTEM_CONTACTS = "system_contacts"; - private static final String PREF_PLACE_CALLS = "place_calls"; - private static final String PREF_ON_STARTUP = "on_startup"; - public static final String PREF_DARK_MODE= "darkMode"; - private static final String PREF_ACCEPT_IN_MAX_SIZE = "acceptIncomingFilesMaxSize"; - public static final String PREF_PLUGINS = "plugins"; - private final Map<String, Set<String>> mNotifiedRequests = new HashMap<>(); - - @Inject - protected Context mContext; - - @Override - protected void saveSettings(Settings settings) { - SharedPreferences appPrefs = getPreferences(); - SharedPreferences.Editor edit = appPrefs.edit(); - edit.clear(); - edit.putBoolean(PREF_SYSTEM_CONTACTS, settings.isAllowSystemContacts()); - edit.putBoolean(PREF_PLACE_CALLS, settings.isAllowPlaceSystemCalls()); - edit.putBoolean(PREF_ON_STARTUP, settings.isAllowOnStartup()); - edit.putBoolean(PREF_PUSH_NOTIFICATIONS, settings.isAllowPushNotifications()); - edit.putBoolean(PREF_PERSISTENT_NOTIFICATION, settings.isAllowPersistentNotification()); - edit.putBoolean(PREF_SHOW_TYPING, settings.isAllowTypingIndicator()); - edit.putBoolean(PREF_SHOW_READ, settings.isAllowReadIndicator()); - edit.putBoolean(PREF_BLOCK_RECORD, settings.isRecordingBlocked()); - edit.putInt(PREF_NOTIFICATION_VISIBILITY, settings.getNotificationVisibility()); - edit.apply(); - } - - @Override - protected Settings loadSettings() { - SharedPreferences appPrefs = getPreferences(); - Settings settings = getUserSettings(); - if (settings == null) { - settings = new Settings(); - } - settings.setAllowSystemContacts(appPrefs.getBoolean(PREF_SYSTEM_CONTACTS, false)); - settings.setAllowPlaceSystemCalls(appPrefs.getBoolean(PREF_PLACE_CALLS, false)); - settings.setAllowRingOnStartup(appPrefs.getBoolean(PREF_ON_STARTUP, true)); - settings.setAllowPushNotifications(appPrefs.getBoolean(PREF_PUSH_NOTIFICATIONS, false)); - settings.setAllowPersistentNotification(appPrefs.getBoolean(PREF_PERSISTENT_NOTIFICATION, false)); - settings.setAllowTypingIndicator(appPrefs.getBoolean(PREF_SHOW_TYPING, true)); - settings.setAllowReadIndicator(appPrefs.getBoolean(PREF_SHOW_READ, true)); - settings.setBlockRecordIndicator(appPrefs.getBoolean(PREF_BLOCK_RECORD, false)); - settings.setNotificationVisibility(appPrefs.getInt(PREF_NOTIFICATION_VISIBILITY, 0)); - return settings; - } - - private void saveRequests(String accountId, Set<String> requests) { - SharedPreferences preferences = mContext.getSharedPreferences(PREFS_REQUESTS, Context.MODE_PRIVATE); - SharedPreferences.Editor edit = preferences.edit(); - edit.putStringSet(accountId, requests); - edit.apply(); - } - - @Override - public void saveRequestPreferences(String accountId, String contactId) { - Set<String> requests = loadRequestsPreferences(accountId); - requests.add(contactId); - saveRequests(accountId, requests); - } - - @Override - @NonNull - public Set<String> loadRequestsPreferences(@NonNull String accountId) { - Set<String> requests = mNotifiedRequests.get(accountId); - if (requests == null) { - SharedPreferences preferences = mContext.getSharedPreferences(PREFS_REQUESTS, Context.MODE_PRIVATE); - requests = new HashSet<>(preferences.getStringSet(accountId, new HashSet<>())); - mNotifiedRequests.put(accountId, requests); - } - return requests; - } - - @Override - public void removeRequestPreferences(String accountId, String contactId) { - Set<String> requests = loadRequestsPreferences(accountId); - requests.remove(contactId); - saveRequests(accountId, requests); - } - - @Override - public boolean hasNetworkConnected() { - return NetworkUtils.isConnectivityAllowed(mContext); - } - - @Override - public boolean isPushAllowed() { - String token = JamiApplication.getInstance().getPushToken(); - return getSettings().isAllowPushNotifications() && !TextUtils.isEmpty(token) /*&& NetworkUtils.isPushAllowed(mContext, getSettings().isAllowMobileData())*/; - } - - @Override - public int getResolution() { - return Integer.parseInt(getVideoPreferences().getString(PREF_RESOLUTION, - DeviceUtils.isTv(mContext) - ? mContext.getString(R.string.video_resolution_default_tv) - : mContext.getString(R.string.video_resolution_default))); - } - - @Override - public int getBitrate() { - return Integer.parseInt(getVideoPreferences().getString(PREF_BITRATE, mContext.getString(R.string.video_bitrate_default))); - } - - @Override - public boolean isHardwareAccelerationEnabled() { - return getVideoPreferences().getBoolean(PREF_HW_ENCODING, true); - } - - @Override - public void setDarkMode(boolean enabled) { - SharedPreferences.Editor edit = getThemePreferences().edit(); - edit.putBoolean(PREF_DARK_MODE, enabled) - .apply(); - applyDarkMode(enabled); - } - - @Override - public boolean getDarkMode() { - return getThemePreferences().getBoolean(PREF_DARK_MODE, false); - } - - @Override - public void loadDarkMode() { - applyDarkMode(getDarkMode()); - } - - @Override - public int getMaxFileAutoAccept(String accountId) { - return mContext.getSharedPreferences(PREFS_ACCOUNT+accountId, Context.MODE_PRIVATE) - .getInt(PREF_ACCEPT_IN_MAX_SIZE, 30) * 1024 * 1024; - } - - public static SharedPreferences getConversationPreferences(@NonNull Context context, String accountId, Uri conversationUri) { - return context.getSharedPreferences(accountId + "_" + conversationUri.getUri(), Context.MODE_PRIVATE); - } - - private void applyDarkMode(boolean enabled) { - AppCompatDelegate.setDefaultNightMode( - enabled ? AppCompatDelegate.MODE_NIGHT_YES - : Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - ? AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - : AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY); - } - - private SharedPreferences getPreferences() { - return mContext.getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE); - } - - private SharedPreferences getVideoPreferences() { - return mContext.getSharedPreferences(PREFS_VIDEO, Context.MODE_PRIVATE); - } - - private SharedPreferences getThemePreferences() { - return PreferenceManager.getDefaultSharedPreferences(mContext); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/services/SharedPreferencesServiceImpl.kt b/ring-android/app/src/main/java/cx/ring/services/SharedPreferencesServiceImpl.kt new file mode 100644 index 000000000..225893201 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/services/SharedPreferencesServiceImpl.kt @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.services + +import android.content.Context +import net.jami.services.PreferencesService +import java.util.HashMap +import android.content.SharedPreferences +import java.util.HashSet +import cx.ring.utils.NetworkUtils +import cx.ring.application.JamiApplication +import android.text.TextUtils +import cx.ring.utils.DeviceUtils +import cx.ring.R +import androidx.appcompat.app.AppCompatDelegate +import android.os.Build +import androidx.preference.PreferenceManager +import net.jami.model.Settings +import net.jami.model.Uri +import net.jami.services.AccountService +import net.jami.services.DeviceRuntimeService + +class SharedPreferencesServiceImpl(val mContext: Context, accountService: AccountService, deviceService: DeviceRuntimeService) : PreferencesService(accountService, deviceService) { + private val mNotifiedRequests: MutableMap<String, MutableSet<String>> = HashMap() + + override fun saveSettings(settings: Settings) { + val appPrefs = preferences + val edit = appPrefs.edit() + edit.clear() + edit.putBoolean(PREF_SYSTEM_CONTACTS, settings.isAllowSystemContacts) + edit.putBoolean(PREF_PLACE_CALLS, settings.isAllowPlaceSystemCalls) + edit.putBoolean(PREF_ON_STARTUP, settings.isAllowOnStartup) + edit.putBoolean(PREF_PUSH_NOTIFICATIONS, settings.isAllowPushNotifications) + edit.putBoolean(PREF_PERSISTENT_NOTIFICATION, settings.isAllowPersistentNotification) + edit.putBoolean(PREF_SHOW_TYPING, settings.isAllowTypingIndicator) + edit.putBoolean(PREF_SHOW_READ, settings.isAllowReadIndicator) + edit.putBoolean(PREF_BLOCK_RECORD, settings.isRecordingBlocked) + edit.putInt(PREF_NOTIFICATION_VISIBILITY, settings.notificationVisibility) + edit.apply() + } + + override fun loadSettings(): Settings { + val appPrefs = preferences + val settings = userSettings ?: Settings() + settings.isAllowSystemContacts = + appPrefs.getBoolean(PREF_SYSTEM_CONTACTS, false) + settings.isAllowPlaceSystemCalls = + appPrefs.getBoolean(PREF_PLACE_CALLS, false) + settings.setAllowRingOnStartup(appPrefs.getBoolean(PREF_ON_STARTUP, true)) + settings.isAllowPushNotifications = + appPrefs.getBoolean(PREF_PUSH_NOTIFICATIONS, false) + settings.isAllowPersistentNotification = appPrefs.getBoolean( + PREF_PERSISTENT_NOTIFICATION, + false + ) + settings.isAllowTypingIndicator = + appPrefs.getBoolean(PREF_SHOW_TYPING, true) + settings.isAllowReadIndicator = + appPrefs.getBoolean(PREF_SHOW_READ, true) + settings.setBlockRecordIndicator(appPrefs.getBoolean(PREF_BLOCK_RECORD, false)) + settings.notificationVisibility = + appPrefs.getInt(PREF_NOTIFICATION_VISIBILITY, 0) + return settings + } + + private fun saveRequests(accountId: String, requests: Set<String>) { + val preferences = mContext.getSharedPreferences(PREFS_REQUESTS, Context.MODE_PRIVATE) + val edit = preferences.edit() + edit.putStringSet(accountId, requests) + edit.apply() + } + + override fun saveRequestPreferences(accountId: String, contactId: String) { + val requests = loadRequestsPreferences(accountId) + requests.add(contactId) + saveRequests(accountId, requests) + } + + override fun loadRequestsPreferences(accountId: String): MutableSet<String> { + var requests = mNotifiedRequests[accountId] + if (requests == null) { + val preferences = mContext.getSharedPreferences(PREFS_REQUESTS, Context.MODE_PRIVATE) + requests = HashSet(preferences.getStringSet(accountId, null) ?: HashSet()) + mNotifiedRequests[accountId] = requests + } + return requests + } + + override fun removeRequestPreferences(accountId: String, contactId: String) { + val requests = loadRequestsPreferences(accountId) + requests.remove(contactId) + saveRequests(accountId, requests) + } + + override fun hasNetworkConnected(): Boolean { + return NetworkUtils.isConnectivityAllowed(mContext) + } + + override fun isPushAllowed(): Boolean { + val token = JamiApplication.instance?.pushToken + return settings.isAllowPushNotifications && !TextUtils.isEmpty(token) /*&& NetworkUtils.isPushAllowed(mContext, getSettings().isAllowMobileData())*/ + } + + override fun getResolution(): Int { + return videoPreferences.getString( + PREF_RESOLUTION, + if (DeviceUtils.isTv(mContext)) mContext.getString(R.string.video_resolution_default_tv) + else mContext.getString(R.string.video_resolution_default) + )!!.toInt() + } + + override fun getBitrate(): Int { + return videoPreferences.getString( + PREF_BITRATE, + mContext.getString(R.string.video_bitrate_default) + )!!.toInt() + } + + override fun isHardwareAccelerationEnabled(): Boolean { + return videoPreferences.getBoolean(PREF_HW_ENCODING, true) + } + + override fun setDarkMode(enabled: Boolean) { + val edit = themePreferences.edit() + edit.putBoolean(PREF_DARK_MODE, enabled) + .apply() + applyDarkMode(enabled) + } + + override fun getDarkMode(): Boolean { + return themePreferences.getBoolean(PREF_DARK_MODE, false) + } + + override fun loadDarkMode() { + applyDarkMode(darkMode) + } + + override fun getMaxFileAutoAccept(accountId: String): Int { + return mContext.getSharedPreferences(PREFS_ACCOUNT + accountId, Context.MODE_PRIVATE) + .getInt(PREF_ACCEPT_IN_MAX_SIZE, 30) * 1024 * 1024 + } + + private fun applyDarkMode(enabled: Boolean) { + AppCompatDelegate.setDefaultNightMode( + if (enabled) AppCompatDelegate.MODE_NIGHT_YES else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM else AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + ) + } + + private val preferences: SharedPreferences = mContext.getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE) + private val videoPreferences: SharedPreferences = mContext.getSharedPreferences(PREFS_VIDEO, Context.MODE_PRIVATE) + private val themePreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(mContext) + + companion object { + const val PREFS_SETTINGS = "ring_settings" + private const val PREFS_REQUESTS = "ring_requests" + const val PREFS_THEME = "theme" + const val PREFS_VIDEO = "videoPrefs" + const val PREFS_ACCOUNT = "account_" + private const val PREF_PUSH_NOTIFICATIONS = "push_notifs" + private const val PREF_PERSISTENT_NOTIFICATION = "persistent_notif" + private const val PREF_SHOW_TYPING = "persistent_typing" + private const val PREF_SHOW_READ = "persistent_read" + private const val PREF_BLOCK_RECORD = "persistent_block_record" + private const val PREF_NOTIFICATION_VISIBILITY = "persistent_notification" + private const val PREF_HW_ENCODING = "video_hwenc" + const val PREF_BITRATE = "video_bitrate" + const val PREF_RESOLUTION = "video_resolution" + private const val PREF_SYSTEM_CONTACTS = "system_contacts" + private const val PREF_PLACE_CALLS = "place_calls" + private const val PREF_ON_STARTUP = "on_startup" + const val PREF_DARK_MODE = "darkMode" + private const val PREF_ACCEPT_IN_MAX_SIZE = "acceptIncomingFilesMaxSize" + const val PREF_PLUGINS = "plugins" + + @JvmStatic fun getConversationPreferences( + context: Context, + accountId: String, + conversationUri: Uri + ): SharedPreferences { + return context.getSharedPreferences( + accountId + "_" + conversationUri.uri, + Context.MODE_PRIVATE + ) + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/services/VCardServiceImpl.java b/ring-android/app/src/main/java/cx/ring/services/VCardServiceImpl.java deleted file mode 100644 index f7e860f4d..000000000 --- a/ring-android/app/src/main/java/cx/ring/services/VCardServiceImpl.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.services; - -import android.content.Context; -import android.graphics.Bitmap; -import android.util.Base64; - -import androidx.annotation.NonNull; - -import java.io.ByteArrayOutputStream; -import java.io.File; - -import net.jami.model.Account; -import cx.ring.utils.BitmapUtils; - -import net.jami.services.VCardService; -import net.jami.utils.Tuple; -import net.jami.utils.VCardUtils; -import ezvcard.VCard; -import ezvcard.parameter.ImageType; -import ezvcard.property.Photo; -import ezvcard.property.RawProperty; -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class VCardServiceImpl extends VCardService { - - private final Context mContext; - - public VCardServiceImpl(Context context) { - this.mContext = context; - } - - public static Single<Tuple<String, Object>> loadProfile(@NonNull Context context, @NonNull Account account) { - synchronized (account) { - Single<Tuple<String, Object>> ret = account.getLoadedProfile(); - if (ret == null) { - ret = VCardUtils.loadLocalProfileFromDiskWithDefault(context.getFilesDir(), account.getAccountID()) - .map(VCardServiceImpl::readData) - .subscribeOn(Schedulers.computation()) - .cache(); - account.setLoadedProfile(ret); - } - return ret; - } - } - - @Override - public Single<Tuple<String, Object>> loadProfile(@NonNull Account account) { - return loadProfile(mContext, account); - } - - @Override - public Maybe<VCard> loadSmallVCard(String accountId, int maxSize) { - return VCardUtils.loadLocalProfileFromDisk(mContext.getFilesDir(), accountId) - .filter(vcard -> !VCardUtils.isEmpty(vcard)) - .map(vcard -> { - if (!vcard.getPhotos().isEmpty()) { - // Reduce photo to fit in maxSize, assuming JPEG compress with ratio of at least 8 - byte[] data = vcard.getPhotos().get(0).getData(); - Bitmap photo = BitmapUtils.bytesToBitmap(data, maxSize * 8); - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - photo.compress(Bitmap.CompressFormat.JPEG, 88, stream); - vcard.removeProperties(Photo.class); - vcard.addPhoto(new Photo(stream.toByteArray(), ImageType.JPEG)); - } - vcard.removeProperties(RawProperty.class); - return vcard; - }); - } - - @Override - public Single<VCard> saveVCardProfile(String accountId, String uri, String displayName, String picture) - { - return Single.fromCallable(() -> VCardUtils.writeData(uri, displayName, Base64.decode(picture, Base64.DEFAULT))) - .flatMap(vcard -> VCardUtils.saveLocalProfileToDisk(vcard, accountId, mContext.getFilesDir())); - } - - @Override - public Single<Tuple<String, Object>> loadVCardProfile(VCard vcard) { - return Single.fromCallable(() -> readData(vcard)); - } - - @Override - public Single<Tuple<String, Object>> peerProfileReceived(String accountId, String peerId, File vcard) - { - return VCardUtils.peerProfileReceived(mContext.getFilesDir(), accountId, peerId, vcard) - .map(VCardServiceImpl::readData); - } - - public static Tuple<String, Object> readData(VCard vcard) { - return readData(VCardUtils.readData(vcard)); - } - - public static Tuple<String, Object> readData(Tuple<String, byte[]> profile) { - return new Tuple<>(profile.first, BitmapUtils.bytesToBitmap(profile.second)); - } - - @Override - public Object base64ToBitmap(String base64) { - return BitmapUtils.base64ToBitmap(base64); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/services/VCardServiceImpl.kt b/ring-android/app/src/main/java/cx/ring/services/VCardServiceImpl.kt new file mode 100644 index 000000000..9ee35c4ff --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/services/VCardServiceImpl.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.services + +import android.content.Context +import net.jami.services.VCardService +import ezvcard.VCard +import net.jami.utils.VCardUtils +import android.graphics.Bitmap +import android.util.Base64 +import cx.ring.utils.BitmapUtils +import ezvcard.parameter.ImageType +import ezvcard.property.Photo +import java.io.ByteArrayOutputStream +import ezvcard.property.RawProperty +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.model.Account +import net.jami.utils.Tuple +import java.io.File + +class VCardServiceImpl(private val mContext: Context) : VCardService() { + override fun loadProfile(account: Account): Observable<Tuple<String?, Any?>> { + return loadProfile(mContext, account) + } + + override fun loadSmallVCard(accountId: String, maxSize: Int): Maybe<VCard> { + return VCardUtils.loadLocalProfileFromDisk(mContext.filesDir, accountId) + .filter { vcard: VCard -> !VCardUtils.isEmpty(vcard) } + .map { vcard: VCard -> + if (vcard.photos.isNotEmpty()) { + // Reduce photo to fit in maxSize, assuming JPEG compress with ratio of at least 8 + val data = vcard.photos[0].data + val photo = BitmapUtils.bytesToBitmap(data, maxSize * 8) + val stream = ByteArrayOutputStream() + photo.compress(Bitmap.CompressFormat.JPEG, 88, stream) + vcard.removeProperties(Photo::class.java) + vcard.addPhoto(Photo(stream.toByteArray(), ImageType.JPEG)) + } + vcard.removeProperties(RawProperty::class.java) + vcard + } + } + + override fun saveVCardProfile(accountId: String, uri: String, displayName: String, picture: String): Single<VCard> { + return Single.fromCallable { VCardUtils.writeData(uri, displayName, Base64.decode(picture, Base64.DEFAULT)) } + .flatMap { vcard: VCard -> VCardUtils.saveLocalProfileToDisk(vcard, accountId, mContext.filesDir) } + } + + override fun loadVCardProfile(vcard: VCard): Single<Tuple<String?, Any?>> { + return Single.fromCallable { readData(vcard) } + } + + override fun peerProfileReceived(accountId: String, peerId: String, vcardFile: File): Single<Tuple<String?, Any?>> { + return VCardUtils.peerProfileReceived(mContext.filesDir, accountId, peerId, vcardFile) + .map { vcard -> readData(vcard) } + } + + override fun base64ToBitmap(base64: String): Any? { + return BitmapUtils.base64ToBitmap(base64) + } + + companion object { + fun loadProfile(context: Context, account: Account): Observable<Tuple<String?, Any?>> { + synchronized(account) { + var ret = account.loadedProfile + if (ret == null) { + ret = VCardUtils.loadLocalProfileFromDiskWithDefault(context.filesDir, account.accountID) + .map { vcard: VCard -> readData(vcard) } + .subscribeOn(Schedulers.computation()) + .cache() + account.loadedProfile = ret + } + return account.loadedProfileObservable + } + } + + fun readData(vcard: VCard?): Tuple<String?, Any?> { + return readData(VCardUtils.readData(vcard)) + } + + fun readData(profile: Tuple<String?, ByteArray?>): Tuple<String?, Any?> { + return Tuple(profile.first, BitmapUtils.bytesToBitmap(profile.second)) + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/settings/AccountFragment.java b/ring-android/app/src/main/java/cx/ring/settings/AccountFragment.java index b712255c6..100c966ef 100644 --- a/ring-android/app/src/main/java/cx/ring/settings/AccountFragment.java +++ b/ring-android/app/src/main/java/cx/ring/settings/AccountFragment.java @@ -40,13 +40,15 @@ import cx.ring.account.JamiAccountSummaryFragment; import cx.ring.application.JamiApplication; import cx.ring.client.HomeActivity; import cx.ring.databinding.FragAccountBinding; +import dagger.hilt.android.AndroidEntryPoint; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; import net.jami.services.AccountService; +@AndroidEntryPoint public class AccountFragment extends Fragment implements ViewTreeObserver.OnScrollChangedListener { - + public static final String TAG = AccountFragment.class.getSimpleName(); private static final int SCROLL_DIRECTION_UP = -1; public static AccountFragment newInstance(@NonNull String accountId) { @@ -67,7 +69,6 @@ public class AccountFragment extends Fragment implements ViewTreeObserver.OnScro @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { mBinding = FragAccountBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); return mBinding.getRoot(); } diff --git a/ring-android/app/src/main/java/cx/ring/settings/SettingsFragment.java b/ring-android/app/src/main/java/cx/ring/settings/SettingsFragment.java index df685ca7a..eeacca004 100644 --- a/ring-android/app/src/main/java/cx/ring/settings/SettingsFragment.java +++ b/ring-android/app/src/main/java/cx/ring/settings/SettingsFragment.java @@ -48,13 +48,16 @@ import cx.ring.databinding.FragSettingsBinding; import net.jami.daemon.JamiService; import net.jami.model.Settings; import cx.ring.mvp.BaseSupportFragment; +import dagger.hilt.android.AndroidEntryPoint; + import net.jami.mvp.GenericView; import net.jami.settings.SettingsPresenter; /** * TODO: improvements : handle multiples permissions for feature. */ -public class SettingsFragment extends BaseSupportFragment<SettingsPresenter> implements GenericView<Settings>, ViewTreeObserver.OnScrollChangedListener { +@AndroidEntryPoint +public class SettingsFragment extends BaseSupportFragment<SettingsPresenter, GenericView<Settings>> implements GenericView<Settings>, ViewTreeObserver.OnScrollChangedListener { private static final int SCROLL_DIRECTION_UP = -1; @@ -72,7 +75,6 @@ public class SettingsFragment extends BaseSupportFragment<SettingsPresenter> imp @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { binding = FragSettingsBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); return binding.getRoot(); } diff --git a/ring-android/app/src/main/java/cx/ring/settings/pluginssettings/PathListAdapter.java b/ring-android/app/src/main/java/cx/ring/settings/pluginssettings/PathListAdapter.java index 7da0c7b77..8f724edfb 100644 --- a/ring-android/app/src/main/java/cx/ring/settings/pluginssettings/PathListAdapter.java +++ b/ring-android/app/src/main/java/cx/ring/settings/pluginssettings/PathListAdapter.java @@ -37,23 +37,20 @@ import cx.ring.utils.AndroidFileUtils; public class PathListAdapter extends RecyclerView.Adapter<PathListAdapter.PathViewHolder> { private List<String> mList; - private PathListItemListener listener; - private Drawable icon; + private PathListItemListener mListener; public static final String TAG = PathListAdapter.class.getSimpleName(); PathListAdapter(List<String> pathList, PathListItemListener listener) { - this.mList = pathList; - this.listener = listener; + mList = pathList; + mListener = listener; } - @NonNull @Override public PathViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.frag_path_list_item, parent, false); - - return new PathViewHolder(view, listener); + return new PathViewHolder(view, mListener); } @Override @@ -93,7 +90,7 @@ public class PathListAdapter extends RecyclerView.Adapter<PathListAdapter.PathVi if (file.exists()) { if (AndroidFileUtils.isImage(s)) { pathTextView.setVisibility(View.GONE); - icon = Drawable.createFromPath(s); + Drawable icon = Drawable.createFromPath(s); if (icon != null) { pathIcon.setImageDrawable(icon); } diff --git a/ring-android/app/src/main/java/cx/ring/share/ScanFragment.java b/ring-android/app/src/main/java/cx/ring/share/ScanFragment.java deleted file mode 100644 index c629964f5..000000000 --- a/ring-android/app/src/main/java/cx/ring/share/ScanFragment.java +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.share; - -import android.Manifest; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.os.Build; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; - - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.google.zxing.BarcodeFormat; -import com.google.zxing.ResultPoint; -import com.journeyapps.barcodescanner.BarcodeCallback; -import com.journeyapps.barcodescanner.BarcodeResult; -import com.journeyapps.barcodescanner.DecoratedBarcodeView; -import com.journeyapps.barcodescanner.DefaultDecoderFactory; - -import java.util.Collection; -import java.util.Collections; -import java.util.List; - - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.client.HomeActivity; -import cx.ring.fragments.ConversationFragment; -import cx.ring.fragments.QRCodeFragment; -import cx.ring.mvp.BaseSupportFragment; - -public class ScanFragment extends BaseSupportFragment { - public static final String TAG = ScanFragment.class.getSimpleName(); - - private DecoratedBarcodeView barcodeView; - private TextView mErrorMessageTextView; - - private boolean hasCameraPermission() { - return ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED; - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View rootView = inflater.inflate(R.layout.frag_scan, container, false); - - barcodeView = rootView.findViewById(R.id.barcode_scanner); - mErrorMessageTextView = rootView.findViewById(R.id.error_msg_txt); - - if (hasCameraPermission()) { - hideErrorPanel(); - initializeBarcode(); - } - - return rootView; - } - - @Override - public void onResume() { - super.onResume(); - if (checkPermission() && barcodeView != null) { - barcodeView.resume(); - } - } - - @Override - public void onPause() { - super.onPause(); - if (hasCameraPermission() && barcodeView != null) { - barcodeView.pause(); - } - } - - private void showErrorPanel(final int textResId) { - if (mErrorMessageTextView != null) { - mErrorMessageTextView.setText(textResId); - mErrorMessageTextView.setVisibility(View.VISIBLE); - } - if (barcodeView != null) { - barcodeView.setVisibility(View.GONE); - } - } - - private void hideErrorPanel() { - if (mErrorMessageTextView != null) { - mErrorMessageTextView.setVisibility(View.GONE); - } - if (barcodeView != null) { - barcodeView.setVisibility(View.VISIBLE); - } - } - - private void displayNoPermissionsError() { - showErrorPanel(R.string.error_scan_no_camera_permissions); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - for (int i = 0, n = permissions.length; i < n; i++) { - switch (permissions[i]) { - case Manifest.permission.CAMERA: - boolean granted = grantResults[i] == PackageManager.PERMISSION_GRANTED; - if (granted) { - hideErrorPanel(); - initializeBarcode(); - } else { - displayNoPermissionsError(); - } - return; - default: - break; - } - } - } - private void initializeBarcode() { - if (barcodeView != null) { - barcodeView.getBarcodeView().setDecoderFactory(new DefaultDecoderFactory(Collections.singletonList(BarcodeFormat.QR_CODE))); - //barcodeView.initializeFromIntent(getActivity().getIntent()); - barcodeView.decodeContinuous(callback); - } - } - - private final BarcodeCallback callback = new BarcodeCallback() { - @Override - public void barcodeResult(@NonNull BarcodeResult result) { - if (result.getText() != null) { - String contactUri = result.getText(); - if (contactUri != null) { - QRCodeFragment parent = (QRCodeFragment) getParentFragment(); - if (parent != null) { - parent.dismiss(); - } - goToConversation(contactUri); - } - } - } - - @Override - public void possibleResultPoints(List<ResultPoint> resultPoints) { - } - }; - - private void goToConversation(String conversationUri) { - ((HomeActivity) requireActivity()).startConversation(conversationUri); - } - - private boolean checkPermission() { - if (!hasCameraPermission()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - requestPermissions(new String[]{Manifest.permission.CAMERA}, JamiApplication.PERMISSIONS_REQUEST); - } else { - displayNoPermissionsError(); - } - return false; - } - return true; - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/share/ScanFragment.kt b/ring-android/app/src/main/java/cx/ring/share/ScanFragment.kt new file mode 100644 index 000000000..afd944ee2 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/share/ScanFragment.kt @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.share + +import android.Manifest +import com.journeyapps.barcodescanner.DecoratedBarcodeView +import android.widget.TextView +import androidx.core.content.ContextCompat +import android.content.pm.PackageManager +import android.view.LayoutInflater +import android.view.ViewGroup +import android.os.Bundle +import cx.ring.R +import androidx.annotation.StringRes +import com.journeyapps.barcodescanner.DefaultDecoderFactory +import com.google.zxing.BarcodeFormat +import com.journeyapps.barcodescanner.BarcodeCallback +import com.journeyapps.barcodescanner.BarcodeResult +import cx.ring.fragments.QRCodeFragment +import com.google.zxing.ResultPoint +import android.os.Build +import android.view.View +import androidx.fragment.app.Fragment +import cx.ring.application.JamiApplication +import cx.ring.client.HomeActivity + +class ScanFragment : Fragment() { + private var barcodeView: DecoratedBarcodeView? = null + private var mErrorMessageTextView: TextView? = null + private fun hasCameraPermission(): Boolean { + return ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val rootView = inflater.inflate(R.layout.frag_scan, container, false) + barcodeView = rootView.findViewById(R.id.barcode_scanner) + mErrorMessageTextView = rootView.findViewById(R.id.error_msg_txt) + if (hasCameraPermission()) { + hideErrorPanel() + initializeBarcode() + } + return rootView + } + + override fun onResume() { + super.onResume() + if (checkPermission() && barcodeView != null) { + barcodeView!!.resume() + } + } + + override fun onPause() { + super.onPause() + if (hasCameraPermission() && barcodeView != null) { + barcodeView!!.pause() + } + } + + private fun showErrorPanel(@StringRes textResId: Int) { + if (mErrorMessageTextView != null) { + mErrorMessageTextView!!.setText(textResId) + mErrorMessageTextView!!.visibility = View.VISIBLE + } + if (barcodeView != null) { + barcodeView!!.visibility = View.GONE + } + } + + private fun hideErrorPanel() { + if (mErrorMessageTextView != null) { + mErrorMessageTextView!!.visibility = View.GONE + } + if (barcodeView != null) { + barcodeView!!.visibility = View.VISIBLE + } + } + + private fun displayNoPermissionsError() { + showErrorPanel(R.string.error_scan_no_camera_permissions) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array<String>, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + var i = 0 + val n = permissions.size + while (i < n) { + when (permissions[i]) { + Manifest.permission.CAMERA -> { + val granted = grantResults[i] == PackageManager.PERMISSION_GRANTED + if (granted) { + hideErrorPanel() + initializeBarcode() + } else { + displayNoPermissionsError() + } + return + } + else -> { + } + } + i++ + } + } + + private fun initializeBarcode() { + if (barcodeView != null) { + barcodeView!!.barcodeView.decoderFactory = + DefaultDecoderFactory(listOf(BarcodeFormat.QR_CODE)) + //barcodeView.initializeFromIntent(getActivity().getIntent()); + barcodeView!!.decodeContinuous(callback) + } + } + + private val callback: BarcodeCallback = object : BarcodeCallback { + override fun barcodeResult(result: BarcodeResult) { + if (result.text != null) { + val contactUri = result.text + if (contactUri != null) { + val parent = parentFragment as QRCodeFragment? + parent?.dismiss() + goToConversation(contactUri) + } + } + } + + override fun possibleResultPoints(resultPoints: List<ResultPoint>) {} + } + + private fun goToConversation(conversationUri: String) { + (requireActivity() as HomeActivity).startConversation(conversationUri) + } + + private fun checkPermission(): Boolean { + if (!hasCameraPermission()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestPermissions( + arrayOf(Manifest.permission.CAMERA), + JamiApplication.PERMISSIONS_REQUEST + ) + } else { + displayNoPermissionsError() + } + return false + } + return true + } + + companion object { + val TAG = ScanFragment::class.simpleName!! + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/share/ShareFragment.java b/ring-android/app/src/main/java/cx/ring/share/ShareFragment.java index 8749cd56a..997bb7dec 100644 --- a/ring-android/app/src/main/java/cx/ring/share/ShareFragment.java +++ b/ring-android/app/src/main/java/cx/ring/share/ShareFragment.java @@ -36,11 +36,12 @@ import net.jami.share.ShareViewModel; import net.jami.utils.QRCodeUtils; import cx.ring.R; -import cx.ring.application.JamiApplication; import cx.ring.databinding.FragShareBinding; import cx.ring.mvp.BaseSupportFragment; +import dagger.hilt.android.AndroidEntryPoint; -public class ShareFragment extends BaseSupportFragment<SharePresenter> implements GenericView<ShareViewModel> { +@AndroidEntryPoint +public class ShareFragment extends BaseSupportFragment<SharePresenter, GenericView<ShareViewModel>> implements GenericView<ShareViewModel> { private String mUriToShow; private boolean isShareLocked = false; @@ -50,7 +51,6 @@ public class ShareFragment extends BaseSupportFragment<SharePresenter> implement @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { binding = FragShareBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); return binding.getRoot(); } diff --git a/ring-android/app/src/main/java/cx/ring/tv/about/AboutActivity.java b/ring-android/app/src/main/java/cx/ring/tv/about/AboutActivity.java deleted file mode 100644 index d41337d1a..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/about/AboutActivity.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.tv.about; - -import android.app.Activity; -import android.os.Bundle; - -import cx.ring.R; - -public class AboutActivity extends Activity { - - public static final String TAG = AboutActivity.class.getSimpleName(); - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.tv_activity_about); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/tv/about/AboutDetailsFragment.java b/ring-android/app/src/main/java/cx/ring/tv/about/AboutDetailsFragment.java index a4710e3e1..7207eabf5 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/about/AboutDetailsFragment.java +++ b/ring-android/app/src/main/java/cx/ring/tv/about/AboutDetailsFragment.java @@ -17,11 +17,12 @@ */ package cx.ring.tv.about; +import android.content.Context; import android.content.res.Resources; import android.graphics.BitmapFactory; import android.os.Bundle; -import androidx.leanback.app.DetailsFragment; -import androidx.leanback.app.DetailsFragmentBackgroundController; +import androidx.leanback.app.DetailsSupportFragment; +import androidx.leanback.app.DetailsSupportFragmentBackgroundController; import androidx.leanback.widget.ArrayObjectAdapter; import androidx.leanback.widget.ClassPresenterSelector; import androidx.leanback.widget.DetailsOverviewRow; @@ -38,10 +39,10 @@ import cx.ring.tv.cards.iconcards.IconCard; import cx.ring.tv.cards.iconcards.IconCardHelper; import net.jami.utils.Log; -public class AboutDetailsFragment extends DetailsFragment { +public class AboutDetailsFragment extends DetailsSupportFragment { private static final String TAG = "AboutDetailsFragment"; - private final DetailsFragmentBackgroundController mDetailsBackground = - new DetailsFragmentBackgroundController(this); + private final DetailsSupportFragmentBackgroundController mDetailsBackground = + new DetailsSupportFragmentBackgroundController(this); @Override public void onCreate(Bundle savedInstanceState) { @@ -58,26 +59,23 @@ public class AboutDetailsFragment extends DetailsFragment { cardType = Card.Type.values()[ordinal]; } - IconCard card = IconCardHelper.getAboutCardByType(getActivity(), cardType); + Context context = requireContext(); + IconCard card = IconCardHelper.getAboutCardByType(context, cardType); ClassPresenterSelector selector = new ClassPresenterSelector(); FullWidthDetailsOverviewRowPresenter rowPresenter = new FullWidthDetailsOverviewRowPresenter( - new AboutDetailsPresenter(getActivity())) { - + new AboutDetailsPresenter(context)) { @Override protected RowPresenter.ViewHolder createRowViewHolder(ViewGroup parent) { // Customize Actionbar and Content by using custom colors. RowPresenter.ViewHolder viewHolder = super.createRowViewHolder(parent); - View actionsView = viewHolder.view. - findViewById(R.id.details_overview_actions_background); - actionsView.setBackgroundColor(getActivity().getResources(). - getColor(R.color.color_primary_dark)); + View actionsView = viewHolder.view.findViewById(R.id.details_overview_actions_background); + actionsView.setBackgroundColor(getResources().getColor(R.color.color_primary_dark)); View detailsView = viewHolder.view.findViewById(R.id.details_frame); - detailsView.setBackgroundColor( - getResources().getColor(R.color.color_primary_dark)); + detailsView.setBackgroundColor(getResources().getColor(R.color.color_primary_dark)); return viewHolder; } }; @@ -86,7 +84,7 @@ public class AboutDetailsFragment extends DetailsFragment { new ListRowPresenter()); ArrayObjectAdapter mRowsAdapter = new ArrayObjectAdapter(selector); - Resources res = getActivity().getResources(); + Resources res = getResources(); DetailsOverviewRow detailsOverview = new DetailsOverviewRow( card); diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountExport.java b/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountExport.java deleted file mode 100644 index db96ff525..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountExport.java +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Loïc Siret <loic.siret@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.tv.account; - -import android.app.AlertDialog; -import android.app.DownloadManager; -import android.app.ProgressDialog; -import android.content.Context; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.leanback.widget.GuidanceStylist; -import androidx.leanback.widget.GuidedAction; - -import android.text.Layout; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.style.AlignmentSpan; -import android.text.style.RelativeSizeSpan; -import android.text.style.StyleSpan; -import android.view.View; - -import java.io.File; -import java.util.List; -import java.util.Map; - -import cx.ring.R; -import net.jami.account.JamiAccountSummaryPresenter; -import net.jami.account.JamiAccountSummaryView; -import cx.ring.application.JamiApplication; -import net.jami.model.Account; -import cx.ring.utils.AndroidFileUtils; - -public class TVAccountExport extends JamiGuidedStepFragment<JamiAccountSummaryPresenter> - implements JamiAccountSummaryView { - - private static final long PASSWORD = 1L; - private static final long ACTION = 2L; - - private ProgressDialog mWaitDialog; - private String mIdAccount; - private boolean mHasPassword; - - public static TVAccountExport createInstance(String idAccount, boolean hasPassword) { - TVAccountExport fragment = new TVAccountExport(); - fragment.mIdAccount = idAccount; - fragment.mHasPassword = hasPassword; - return fragment; - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - - super.onViewCreated(view, savedInstanceState); - presenter.setAccountId(mIdAccount); - } - - @Override - @NonNull - public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { - String title = getString(R.string.account_export_title); - String breadcrumb = ""; - String description = getString(R.string.account_link_export_info_light); - Drawable icon = getContext().getDrawable(R.drawable.baseline_devices_24); - return new GuidanceStylist.Guidance(title, description, breadcrumb, icon); - } - - @Override - public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) { - if (mHasPassword) { - addPasswordAction(getActivity(), actions, PASSWORD, getString(R.string.account_enter_password), "", ""); - } else { - addAction(getContext(), actions, ACTION, R.string.account_start_export_button); - } - } - - @Override - public void onGuidedActionClicked(GuidedAction action) { - presenter.startAccountExport(""); - } - - @Override - public long onGuidedActionEditedAndProceed(GuidedAction action) { - presenter.startAccountExport(action.getDescription().toString()); - return GuidedAction.ACTION_ID_NEXT; - } - - @Override - public int onProvideTheme() { - return R.style.Theme_Ring_Leanback_GuidedStep_First; - } - - @Override - public void showExportingProgressDialog() { - mWaitDialog = ProgressDialog.show(getActivity(), - getString(R.string.export_account_wait_title), - getString(R.string.export_account_wait_message)); - } - - @Override - public void showPasswordProgressDialog() { - - } - - @Override - public void accountChanged(Account account) { - - } - - @Override - public void showNetworkError() { - mWaitDialog.dismiss(); - new AlertDialog.Builder(getActivity()) - .setTitle(R.string.account_export_end_network_title) - .setMessage(R.string.account_export_end_network_message) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - - @Override - public void showPasswordError() { - mWaitDialog.dismiss(); - new AlertDialog.Builder(getActivity()) - .setTitle(R.string.account_export_end_error_title) - .setMessage(R.string.account_export_end_decryption_message) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - - @Override - public void showGenericError() { - mWaitDialog.dismiss(); - new AlertDialog.Builder(getActivity()) - .setTitle(R.string.account_export_end_error_title) - .setMessage(R.string.account_export_end_error_message) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - - @Override - public void showPIN(final String pin) { - mWaitDialog.dismiss(); - String pined = getString(R.string.account_end_export_infos).replace("%%", pin); - final SpannableString styledResultText = new SpannableString(pined); - int pos = pined.lastIndexOf(pin); - styledResultText.setSpan(new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), pos, (pos + pin.length()), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - styledResultText.setSpan(new StyleSpan(Typeface.BOLD), pos, (pos + pin.length()), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - styledResultText.setSpan(new RelativeSizeSpan(2.8f), pos, (pos + pin.length()), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - - new AlertDialog.Builder(getActivity()) - .setMessage(styledResultText) - .setPositiveButton(android.R.string.ok, (dialog, which) -> getFragmentManager().popBackStack()) - .show(); - } - - @Override - public void passwordChangeEnded(boolean ok) { - - } - - public void displayCompleteArchive(File dest) { - DownloadManager downloadManager = (DownloadManager) getActivity().getSystemService(Context.DOWNLOAD_SERVICE); - if (downloadManager != null) { - downloadManager.addCompletedDownload(dest.getName(), - dest.getName(), - true, - AndroidFileUtils.getMimeType(dest.getAbsolutePath()), - dest.getAbsolutePath(), - dest.length(), - true); - } - } - - @Override - public void gotToImageCapture() { - - } - - @Override - public void askCameraPermission() { - - } - - @Override - public void goToGallery() { - - } - - @Override - public void askGalleryPermission() { - - } - - @Override - public void updateUserView(Account account) { - - } - - @Override - public void goToMedia(String accountId) { - - } - - @Override - public void goToSystem(String accountId) { - - } - - @Override - public void goToAdvanced(String accountId) { - - } - - @Override - public void goToAccount(String accountId) { - - } - - @Override - public void setSwitchStatus(Account account) { - - } - - @Override - public void showRevokingProgressDialog() { - - } - - @Override - public void deviceRevocationEnded(String device, int status) { - - } - - @Override - public void updateDeviceList(Map<String, String> devices, String currentDeviceId) { - - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountExport.kt b/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountExport.kt new file mode 100644 index 000000000..f5ecb6583 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountExport.kt @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Loïc Siret <loic.siret@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.tv.account + +import android.app.AlertDialog +import android.app.DownloadManager +import android.app.ProgressDialog +import android.content.Context +import android.graphics.Typeface +import android.os.Bundle +import android.text.Layout +import android.text.Spannable +import android.text.SpannableString +import android.text.style.AlignmentSpan +import android.text.style.RelativeSizeSpan +import android.text.style.StyleSpan +import android.view.View +import androidx.leanback.widget.GuidanceStylist.Guidance +import androidx.leanback.widget.GuidedAction +import cx.ring.R +import cx.ring.utils.AndroidFileUtils.getMimeType +import dagger.hilt.android.AndroidEntryPoint +import net.jami.account.JamiAccountSummaryPresenter +import net.jami.account.JamiAccountSummaryView +import net.jami.model.Account +import java.io.File + +@AndroidEntryPoint +class TVAccountExport : JamiGuidedStepFragment<JamiAccountSummaryPresenter>(), JamiAccountSummaryView { + private var mWaitDialog: ProgressDialog? = null + private lateinit var mIdAccount: String + private var mHasPassword = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + presenter.setAccountId(mIdAccount) + } + + override fun onCreateGuidance(savedInstanceState: Bundle): Guidance { + val title = getString(R.string.account_export_title) + val breadcrumb = "" + val description = getString(R.string.account_link_export_info_light) + val icon = requireContext().getDrawable(R.drawable.baseline_devices_24) + return Guidance(title, description, breadcrumb, icon) + } + + override fun onCreateActions(actions: List<GuidedAction>, savedInstanceState: Bundle) { + if (mHasPassword) { + addPasswordAction(activity, actions, PASSWORD, getString(R.string.account_enter_password), "", "") + } else { + addAction(context, actions, ACTION, R.string.account_start_export_button) + } + } + + override fun onGuidedActionClicked(action: GuidedAction) { + presenter.startAccountExport("") + } + + override fun onGuidedActionEditedAndProceed(action: GuidedAction): Long { + presenter.startAccountExport(action.description.toString()) + return GuidedAction.ACTION_ID_NEXT + } + + override fun onProvideTheme(): Int { + return R.style.Theme_Ring_Leanback_GuidedStep_First + } + + override fun showExportingProgressDialog() { + mWaitDialog = ProgressDialog.show(activity, + getString(R.string.export_account_wait_title), + getString(R.string.export_account_wait_message) + ) + } + + override fun showPasswordProgressDialog() {} + override fun accountChanged(account: Account) {} + override fun showNetworkError() { + mWaitDialog!!.dismiss() + AlertDialog.Builder(activity) + .setTitle(R.string.account_export_end_network_title) + .setMessage(R.string.account_export_end_network_message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + override fun showPasswordError() { + mWaitDialog!!.dismiss() + AlertDialog.Builder(activity) + .setTitle(R.string.account_export_end_error_title) + .setMessage(R.string.account_export_end_decryption_message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + override fun showGenericError() { + mWaitDialog!!.dismiss() + AlertDialog.Builder(activity) + .setTitle(R.string.account_export_end_error_title) + .setMessage(R.string.account_export_end_error_message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + override fun showPIN(pin: String) { + mWaitDialog!!.dismiss() + val pined = getString(R.string.account_end_export_infos).replace("%%", pin) + val styledResultText = SpannableString(pined) + val pos = pined.lastIndexOf(pin) + styledResultText.setSpan( + AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), + pos, + pos + pin.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + styledResultText.setSpan( + StyleSpan(Typeface.BOLD), + pos, + pos + pin.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + styledResultText.setSpan( + RelativeSizeSpan(2.8f), + pos, + pos + pin.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + AlertDialog.Builder(activity) + .setMessage(styledResultText) + .setPositiveButton(android.R.string.ok) { _, _ -> parentFragmentManager.popBackStack() } + .show() + } + + override fun passwordChangeEnded(ok: Boolean) {} + override fun displayCompleteArchive(dest: File) { + val downloadManager = requireContext().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + downloadManager.addCompletedDownload( + dest.name, + dest.name, + true, + getMimeType(dest.absolutePath), + dest.absolutePath, + dest.length(), + true + ) + } + + override fun gotToImageCapture() {} + override fun askCameraPermission() {} + override fun goToGallery() {} + override fun askGalleryPermission() {} + override fun updateUserView(account: Account) {} + override fun goToMedia(accountId: String) {} + override fun goToSystem(accountId: String) {} + override fun goToAdvanced(accountId: String) {} + override fun goToAccount(accountId: String) {} + override fun setSwitchStatus(account: Account) {} + override fun showRevokingProgressDialog() {} + override fun deviceRevocationEnded(device: String, status: Int) {} + override fun updateDeviceList(devices: Map<String, String>, currentDeviceId: String) {} + + companion object { + private const val PASSWORD = 1L + private const val ACTION = 2L + fun createInstance(idAccount: String, hasPassword: Boolean): TVAccountExport { + val fragment = TVAccountExport() + fragment.mIdAccount = idAccount + fragment.mHasPassword = hasPassword + return fragment + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountWizard.java b/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountWizard.java deleted file mode 100644 index 72a392148..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountWizard.java +++ /dev/null @@ -1,235 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.tv.account; - -import android.app.Activity; -import android.app.FragmentManager; -import android.app.ProgressDialog; -import android.content.Intent; -import android.os.Bundle; - -import androidx.leanback.app.GuidedStepSupportFragment; -import androidx.appcompat.app.AlertDialog; - -import android.widget.Toast; - -import java.io.File; - -import cx.ring.R; -import cx.ring.account.AccountCreationModelImpl; -import net.jami.account.AccountWizardPresenter; -import net.jami.account.AccountWizardView; -import cx.ring.application.JamiApplication; -import net.jami.model.Account; -import net.jami.model.AccountConfig; -import net.jami.mvp.AccountCreationModel; -import cx.ring.mvp.BaseActivity; -import net.jami.utils.VCardUtils; -import ezvcard.VCard; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class TVAccountWizard - extends BaseActivity<AccountWizardPresenter> - implements AccountWizardView { - static final String TAG = TVAccountWizard.class.getName(); - private TVHomeAccountCreationFragment mHomeFragment = new TVHomeAccountCreationFragment(); - - private ProgressDialog mProgress = null; - private boolean mLinkAccount = false; - private String mAccountType; - private AlertDialog mAlertDialog; - - @Override - public void onCreate(Bundle savedInstanceState) { - JamiApplication.getInstance().getInjectionComponent().inject(this); - super.onCreate(savedInstanceState); - JamiApplication.getInstance().startDaemon(); - - Intent intent = getIntent(); - if (intent != null) { - mAccountType = intent.getAction(); - } - if (mAccountType == null) { - mAccountType = AccountConfig.ACCOUNT_TYPE_RING; - } - - if (savedInstanceState == null) { - GuidedStepSupportFragment.addAsRoot(this, mHomeFragment, android.R.id.content); - } else { - mLinkAccount = savedInstanceState.getBoolean("mLinkAccount"); - } - - presenter.init(getIntent().getAction() != null ? getIntent().getAction() : AccountConfig.ACCOUNT_TYPE_RING); - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putBoolean("mLinkAccount", mLinkAccount); - } - - @Override - public void onDestroy() { - if (mProgress != null) { - mProgress.dismiss(); - mProgress = null; - } - super.onDestroy(); - } - - public void createAccount(AccountCreationModel accountCreationModel) { - if (accountCreationModel.isLink()) { - presenter.initJamiAccountLink(accountCreationModel, - getText(R.string.ring_account_default_name).toString()); - } else { - presenter.initJamiAccountCreation(accountCreationModel, - getText(R.string.ring_account_default_name).toString()); - } - } - - @Override - public void goToHomeCreation() { - - } - - @Override - public void goToSipCreation() { - - } - - @Override - public void onBackPressed() { - GuidedStepSupportFragment fragment = GuidedStepSupportFragment.getCurrentGuidedStepSupportFragment(getSupportFragmentManager()); - if (fragment instanceof TVProfileCreationFragment) - finish(); - else - super.onBackPressed(); - } - - @Override - public void goToProfileCreation(AccountCreationModel accountCreationModel) { - GuidedStepSupportFragment.add(getSupportFragmentManager(), TVProfileCreationFragment.newInstance((AccountCreationModelImpl) accountCreationModel)); - } - - @Override - public void displayProgress(boolean display) { - if (display) { - mProgress = new ProgressDialog(this); - mProgress.setTitle(R.string.dialog_wait_create); - mProgress.setMessage(getString(R.string.dialog_wait_create_details)); - mProgress.setCancelable(false); - mProgress.setCanceledOnTouchOutside(false); - mProgress.show(); - } else { - if (mProgress != null) { - if (mProgress.isShowing()) { - mProgress.dismiss(); - } - mProgress = null; - } - } - - } - - @Override - public void displayCreationError() { - Toast.makeText(TVAccountWizard.this, "Error creating account", Toast.LENGTH_SHORT).show(); - } - - @Override - public void blockOrientation() { - //Noop on TV - } - - @Override - public void finish(final boolean affinity) { - if (affinity) { - FragmentManager fm = getFragmentManager(); - if (fm.getBackStackEntryCount() >= 1) { - fm.popBackStack(); - } else { - finish(); - } - } else { - finishAffinity(); - } - } - - @Override - public Single<VCard> saveProfile(final Account account, final AccountCreationModel accountCreationModel) { - File filedir = getFilesDir(); - return accountCreationModel.toVCard() - .flatMap(vcard -> { - account.resetProfile(); - return VCardUtils.saveLocalProfileToDisk(vcard, account.getAccountID(), filedir); - }) - .subscribeOn(Schedulers.io()); - } - - @Override - public void displayGenericError() { - if (mAlertDialog != null && mAlertDialog.isShowing()) { - return; - } - mAlertDialog = new AlertDialog.Builder(TVAccountWizard.this) - .setPositiveButton(android.R.string.ok, null) - .setTitle(R.string.account_cannot_be_found_title) - .setMessage(R.string.account_cannot_be_found_message) - .show(); - } - - @Override - public void displayNetworkError() { - if (mAlertDialog != null && mAlertDialog.isShowing()) { - return; - } - mAlertDialog = new AlertDialog.Builder(TVAccountWizard.this) - .setPositiveButton(android.R.string.ok, null) - .setTitle(R.string.account_no_network_title) - .setMessage(R.string.account_no_network_message) - .show(); - } - - @Override - public void displayCannotBeFoundError() { - if (mAlertDialog != null && mAlertDialog.isShowing()) { - return; - } - mAlertDialog = new AlertDialog.Builder(TVAccountWizard.this) - .setPositiveButton(android.R.string.ok, null) - .setTitle(R.string.account_cannot_be_found_title) - .setMessage(R.string.account_cannot_be_found_message) - .show(); - } - - @Override - public void displaySuccessDialog() { - if (mAlertDialog != null && mAlertDialog.isShowing()) { - return; - } - setResult(Activity.RESULT_OK, new Intent()); - //startActivity(new Intent(this, HomeActivity.class)); - finish(); - } - - public void profileCreated(AccountCreationModel accountCreationModel, boolean saveProfile) { - presenter.profileCreated(accountCreationModel, saveProfile); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountWizard.kt b/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountWizard.kt new file mode 100644 index 000000000..3dc1262c2 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/account/TVAccountWizard.kt @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.tv.account + +import android.app.ProgressDialog +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.leanback.app.GuidedStepSupportFragment +import cx.ring.R +import cx.ring.account.AccountCreationModelImpl +import cx.ring.application.JamiApplication.Companion.instance +import cx.ring.mvp.BaseActivity +import cx.ring.tv.account.TVProfileCreationFragment.Companion.newInstance +import dagger.hilt.android.AndroidEntryPoint +import ezvcard.VCard +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.account.AccountWizardPresenter +import net.jami.account.AccountWizardView +import net.jami.model.Account +import net.jami.model.AccountConfig +import net.jami.mvp.AccountCreationModel +import net.jami.utils.VCardUtils + +@AndroidEntryPoint +class TVAccountWizard : BaseActivity<AccountWizardPresenter?>(), AccountWizardView { + private var mProgress: ProgressDialog? = null + private var mLinkAccount = false + private var mAccountType: String? = null + private var mAlertDialog: AlertDialog? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + instance?.startDaemon() + val intent = intent + if (intent != null) { + mAccountType = intent.action + } + if (mAccountType == null) { + mAccountType = AccountConfig.ACCOUNT_TYPE_RING + } + if (savedInstanceState == null) { + GuidedStepSupportFragment.addAsRoot( + this, + TVHomeAccountCreationFragment(), + android.R.id.content + ) + } else { + mLinkAccount = savedInstanceState.getBoolean("mLinkAccount") + } + presenter!!.init(if (getIntent().action != null) getIntent().action else AccountConfig.ACCOUNT_TYPE_RING) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean("mLinkAccount", mLinkAccount) + } + + override fun onDestroy() { + if (mProgress != null) { + mProgress!!.dismiss() + mProgress = null + } + super.onDestroy() + } + + fun createAccount(accountCreationModel: AccountCreationModel) { + if (accountCreationModel.isLink) { + presenter!!.initJamiAccountLink(accountCreationModel, getText(R.string.ring_account_default_name).toString()) + } else { + presenter!!.initJamiAccountCreation(accountCreationModel, getText(R.string.ring_account_default_name).toString()) + } + } + + override fun goToHomeCreation() {} + override fun goToSipCreation() {} + override fun onBackPressed() { + val fragment = GuidedStepSupportFragment.getCurrentGuidedStepSupportFragment(supportFragmentManager) + if (fragment is TVProfileCreationFragment) finish() else super.onBackPressed() + } + + override fun goToProfileCreation(accountCreationModel: AccountCreationModel) { + GuidedStepSupportFragment.add( + supportFragmentManager, + newInstance(accountCreationModel as AccountCreationModelImpl) + ) + } + + override fun displayProgress(display: Boolean) { + if (display) { + mProgress = ProgressDialog(this).apply { + setTitle(R.string.dialog_wait_create) + setMessage(getString(R.string.dialog_wait_create_details)) + setCancelable(false) + setCanceledOnTouchOutside(false) + show() + } + } else { + if (mProgress != null) { + if (mProgress!!.isShowing) { + mProgress!!.dismiss() + } + mProgress = null + } + } + } + + override fun displayCreationError() { + Toast.makeText(this@TVAccountWizard, "Error creating account", Toast.LENGTH_SHORT).show() + } + + override fun blockOrientation() { + //Noop on TV + } + + override fun finish(affinity: Boolean) { + if (affinity) { + val fm = fragmentManager + if (fm.backStackEntryCount >= 1) { + fm.popBackStack() + } else { + finish() + } + } else { + finishAffinity() + } + } + + override fun saveProfile(account: Account, accountCreationModel: AccountCreationModel): Single<VCard> { + val filedir = filesDir + return accountCreationModel.toVCard() + .flatMap { vcard -> + account.resetProfile() + VCardUtils.saveLocalProfileToDisk(vcard, account.accountID, filedir) + } + .subscribeOn(Schedulers.io()) + } + + override fun displayGenericError() { + if (mAlertDialog != null && mAlertDialog!!.isShowing) { + return + } + mAlertDialog = AlertDialog.Builder(this@TVAccountWizard) + .setPositiveButton(android.R.string.ok, null) + .setTitle(R.string.account_cannot_be_found_title) + .setMessage(R.string.account_cannot_be_found_message) + .show() + } + + override fun displayNetworkError() { + if (mAlertDialog != null && mAlertDialog!!.isShowing) { + return + } + mAlertDialog = AlertDialog.Builder(this@TVAccountWizard) + .setPositiveButton(android.R.string.ok, null) + .setTitle(R.string.account_no_network_title) + .setMessage(R.string.account_no_network_message) + .show() + } + + override fun displayCannotBeFoundError() { + if (mAlertDialog != null && mAlertDialog!!.isShowing) { + return + } + mAlertDialog = AlertDialog.Builder(this@TVAccountWizard) + .setPositiveButton(android.R.string.ok, null) + .setTitle(R.string.account_cannot_be_found_title) + .setMessage(R.string.account_cannot_be_found_message) + .show() + } + + override fun displaySuccessDialog() { + if (mAlertDialog != null && mAlertDialog!!.isShowing) { + return + } + setResult(RESULT_OK, Intent()) + //startActivity(new Intent(this, HomeActivity.class)); + finish() + } + + fun profileCreated(accountCreationModel: AccountCreationModel?, saveProfile: Boolean) { + presenter!!.profileCreated(accountCreationModel, saveProfile) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVHomeAccountCreationFragment.java b/ring-android/app/src/main/java/cx/ring/tv/account/TVHomeAccountCreationFragment.java index c92376c78..7aee786fd 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVHomeAccountCreationFragment.java +++ b/ring-android/app/src/main/java/cx/ring/tv/account/TVHomeAccountCreationFragment.java @@ -32,7 +32,9 @@ import cx.ring.account.AccountCreationModelImpl; import net.jami.account.HomeAccountCreationPresenter; import net.jami.account.HomeAccountCreationView; import cx.ring.application.JamiApplication; +import dagger.hilt.android.AndroidEntryPoint; +@AndroidEntryPoint public class TVHomeAccountCreationFragment extends JamiGuidedStepFragment<HomeAccountCreationPresenter> implements HomeAccountCreationView { @@ -40,13 +42,6 @@ public class TVHomeAccountCreationFragment private static final int LINK_ACCOUNT = 0; private static final int CREATE_ACCOUNT = 1; - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - - super.onViewCreated(view, savedInstanceState); - } - @Override public void goToAccountCreation() { AccountCreationModelImpl ringAccountViewModel = new AccountCreationModelImpl(); diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVJamiAccountCreationFragment.java b/ring-android/app/src/main/java/cx/ring/tv/account/TVJamiAccountCreationFragment.java index 7e655b010..7b1bd5c57 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVJamiAccountCreationFragment.java +++ b/ring-android/app/src/main/java/cx/ring/tv/account/TVJamiAccountCreationFragment.java @@ -36,10 +36,13 @@ import cx.ring.account.AccountCreationModelImpl; import net.jami.account.JamiAccountCreationPresenter; import net.jami.account.JamiAccountCreationView; import cx.ring.application.JamiApplication; +import dagger.hilt.android.AndroidEntryPoint; + import net.jami.mvp.AccountCreationModel; import net.jami.utils.Log; import net.jami.utils.StringUtils; +@AndroidEntryPoint public class TVJamiAccountCreationFragment extends JamiGuidedStepFragment<JamiAccountCreationPresenter> implements JamiAccountCreationView { @@ -56,7 +59,7 @@ public class TVJamiAccountCreationFragment private String mPassword; private String mPasswordConfirm; - private TextWatcher mUsernameWatcher = new TextWatcher() { + private final TextWatcher mUsernameWatcher = new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @@ -68,10 +71,10 @@ public class TVJamiAccountCreationFragment String newName = s.toString(); if (!newName.equals(getResources().getString(R.string.register_username))) { boolean empty = newName.isEmpty(); - /** If the username is empty make sure to set isRegisterUsernameChecked + /* If the username is empty make sure to set isRegisterUsernameChecked * to False, this allows to create an account with an empty username */ presenter.registerUsernameChanged(!empty); - /** Send the newName even when empty (in order to reset the views) */ + /* Send the newName even when empty (in order to reset the views) */ presenter.userNameChanged(newName); } } @@ -88,9 +91,6 @@ public class TVJamiAccountCreationFragment @Override public void onViewCreated(View view, Bundle savedInstanceState) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - - // Bind the presenter to the view super.onViewCreated(view, savedInstanceState); if (model == null) { diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVJamiLinkAccountFragment.java b/ring-android/app/src/main/java/cx/ring/tv/account/TVJamiLinkAccountFragment.java index dc30ed0b1..5ff65dcc1 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVJamiLinkAccountFragment.java +++ b/ring-android/app/src/main/java/cx/ring/tv/account/TVJamiLinkAccountFragment.java @@ -32,9 +32,12 @@ import cx.ring.account.AccountCreationModelImpl; import net.jami.account.JamiLinkAccountPresenter; import net.jami.account.JamiLinkAccountView; import cx.ring.application.JamiApplication; +import dagger.hilt.android.AndroidEntryPoint; + import net.jami.mvp.AccountCreationModel; import net.jami.utils.StringUtils; +@AndroidEntryPoint public class TVJamiLinkAccountFragment extends JamiGuidedStepFragment<JamiLinkAccountPresenter> implements JamiLinkAccountView { private static final long PASSWORD = 1L; @@ -53,9 +56,7 @@ public class TVJamiLinkAccountFragment extends JamiGuidedStepFragment<JamiLinkAc @Override public void onViewCreated(View view, Bundle savedInstanceState) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); super.onViewCreated(view, savedInstanceState); - presenter.init(model); if (model != null && model.getPhoto() != null) { getGuidanceStylist().getIconView().setImageBitmap(model.getPhoto()); diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileCreationFragment.java b/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileCreationFragment.java deleted file mode 100644 index e844557cc..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileCreationFragment.java +++ /dev/null @@ -1,266 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.tv.account; - -import android.Manifest; -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.ContentResolver; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.Uri; -import android.os.Bundle; -import android.provider.MediaStore; -import androidx.annotation.NonNull; -import androidx.leanback.app.GuidedStepSupportFragment; -import androidx.leanback.widget.GuidanceStylist; -import androidx.leanback.widget.GuidedAction; -import androidx.appcompat.app.AlertDialog; -import android.text.TextUtils; -import android.view.View; - -import java.io.FileNotFoundException; -import java.io.InputStream; -import java.util.List; - -import cx.ring.R; -import cx.ring.account.AccountCreationModelImpl; -import cx.ring.account.ProfileCreationFragment; -import net.jami.account.ProfileCreationPresenter; -import net.jami.account.ProfileCreationView; -import cx.ring.application.JamiApplication; -import net.jami.model.Account; -import net.jami.mvp.AccountCreationModel; -import cx.ring.tv.camera.CustomCameraActivity; -import cx.ring.utils.AndroidFileUtils; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.core.Single; - -public class TVProfileCreationFragment extends JamiGuidedStepFragment<ProfileCreationPresenter> - implements ProfileCreationView { - - private static final int USER_NAME = 1; - private static final int GALLERY = 2; - private static final int CAMERA = 3; - private static final int NEXT = 4; - - private AccountCreationModelImpl mModel; - private int iconSize = -1; - - public static GuidedStepSupportFragment newInstance(AccountCreationModelImpl ringAccountViewModel) { - TVProfileCreationFragment fragment = new TVProfileCreationFragment(); - fragment.mModel = ringAccountViewModel; - return fragment; - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent intent) { - switch (requestCode) { - case ProfileCreationFragment.REQUEST_CODE_PHOTO: - if (resultCode == Activity.RESULT_OK && intent != null && intent.getExtras() != null) { - Uri uri = (Uri) intent.getExtras().get((MediaStore.EXTRA_OUTPUT)); - ContentResolver cr = getActivity().getContentResolver(); - try { - InputStream is = cr.openInputStream(uri); - Bitmap image = BitmapFactory.decodeStream(is); - presenter.photoUpdated(Single.just(intent) - .map(i -> image)); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } - } - break; - case ProfileCreationFragment.REQUEST_CODE_GALLERY: - if (resultCode == Activity.RESULT_OK && intent != null) { - presenter.photoUpdated(AndroidFileUtils.loadBitmap(getActivity(), intent.getData()).map(b -> (Object)b)); - } - break; - default: - super.onActivityResult(requestCode, resultCode, intent); - break; - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - switch (requestCode) { - case ProfileCreationFragment.REQUEST_PERMISSION_CAMERA: - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - presenter.cameraPermissionChanged(true); - presenter.cameraClick(); - } - break; - case ProfileCreationFragment.REQUEST_PERMISSION_READ_STORAGE: - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - presenter.galleryClick(); - } - break; - } - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - super.onViewCreated(view, savedInstanceState); - - if (mModel == null) { - getActivity().finish(); - return; - } - - iconSize = (int) getResources().getDimension(R.dimen.tv_avatar_size); - presenter.initPresenter(mModel); - } - - @Override - @NonNull - public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { - String title = getString(R.string.account_create_title); - String breadcrumb = ""; - String description = getString(R.string.profile_message_warning); - return new GuidanceStylist.Guidance(title, description, breadcrumb, - new AvatarDrawable.Builder() - .withNameData(mModel == null ? null : mModel.getFullName(), mModel == null ? null : mModel.getUsername()) - .withCircleCrop(true) - .build(getContext()) - ); - } - - @Override - public int onProvideTheme() { - return R.style.Theme_Ring_Leanback_GuidedStep_First; - } - - @Override - public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) { - addEditTextAction(getActivity(), actions, USER_NAME, R.string.profile_name_hint, R.string.profile_name_hint); - addAction(getActivity(), actions, CAMERA, getActivity().getResources().getString(R.string.take_a_photo), ""); - addAction(getActivity(), actions, GALLERY, getActivity().getResources().getString(R.string.open_the_gallery), ""); - addAction(getActivity(), actions, NEXT, getActivity().getResources().getString(R.string.wizard_next), "", true); - } - - @Override - public void onGuidedActionClicked(GuidedAction action) { - if (action.getId() == CAMERA) { - presenter.cameraClick(); - } else if (action.getId() == GALLERY) { - presenter.galleryClick(); - } else if (action.getId() == NEXT) { - presenter.nextClick(); - } - } - - @Override - public void displayProfileName(String profileName) { - findActionById(USER_NAME).setEditDescription(profileName); - notifyActionChanged(findActionPositionById(USER_NAME)); - } - - @Override - public void goToGallery() { - try { - Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); - startActivityForResult(intent, ProfileCreationFragment.REQUEST_CODE_GALLERY); - } catch (ActivityNotFoundException e) { - new AlertDialog.Builder(getActivity()) - .setPositiveButton(android.R.string.ok, null) - .setTitle(R.string.gallery_error_title) - .setMessage(R.string.gallery_error_message) - .show(); - } - } - - @Override - public void goToPhotoCapture() { - Intent intent = new Intent(getActivity(), CustomCameraActivity.class); - startActivityForResult(intent, ProfileCreationFragment.REQUEST_CODE_PHOTO); - } - - @Override - public void askStoragePermission() { - requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, - ProfileCreationFragment.REQUEST_PERMISSION_READ_STORAGE); - } - - @Override - public void askPhotoPermission() { - requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, - ProfileCreationFragment.REQUEST_PERMISSION_CAMERA); - } - - @Override - public void goToNext(AccountCreationModel accountCreationModel, boolean saveProfile) { - Activity wizardActivity = getActivity(); - if (wizardActivity instanceof TVAccountWizard) { - TVAccountWizard wizard = (TVAccountWizard) wizardActivity; - wizard.profileCreated(accountCreationModel, saveProfile); - } - } - - @Override - public void setProfile(AccountCreationModel accountCreationModel) { - AccountCreationModelImpl model = ((AccountCreationModelImpl) accountCreationModel); - Account newAccount = model.getNewAccount(); - AvatarDrawable avatar = - new AvatarDrawable.Builder() - .withPhoto(model.getPhoto()) - .withNameData(accountCreationModel.getFullName(), accountCreationModel.getUsername()) - .withId(newAccount == null ? null : newAccount.getUsername()) - .withCircleCrop(true) - .build(getContext()); - avatar.setInSize(iconSize); - getGuidanceStylist().getIconView().setImageDrawable(avatar); - } - - public long onGuidedActionEditedAndProceed(GuidedAction action) { - switch ((int) action.getId()){ - case USER_NAME: - String username = action.getEditTitle().toString(); - presenter.fullNameUpdated(username); - if (username.isEmpty()) - action.setTitle(getString(R.string.profile_name_hint)); - else - action.setTitle(username); - break; - case CAMERA: - presenter.cameraClick(); - break; - case GALLERY: - presenter.galleryClick(); - break; - } - return super.onGuidedActionEditedAndProceed(action); - } - - @Override - public void onGuidedActionEditCanceled(GuidedAction action) { - if ((int) action.getId() == USER_NAME) { - String username = action.getEditTitle().toString(); - presenter.fullNameUpdated(username); - if (TextUtils.isEmpty(username)) - action.setTitle(getString(R.string.profile_name_hint)); - else - action.setTitle(username); - } - super.onGuidedActionEditCanceled(action); - } -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileCreationFragment.kt b/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileCreationFragment.kt new file mode 100644 index 000000000..4d1e32a33 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileCreationFragment.kt @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.account + +import android.Manifest +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.text.TextUtils +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.leanback.app.GuidedStepSupportFragment +import androidx.leanback.widget.GuidanceStylist.Guidance +import androidx.leanback.widget.GuidedAction +import cx.ring.R +import cx.ring.account.AccountCreationModelImpl +import cx.ring.account.ProfileCreationFragment +import cx.ring.tv.camera.CustomCameraActivity +import cx.ring.utils.AndroidFileUtils.loadBitmap +import cx.ring.views.AvatarDrawable +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.core.Single +import net.jami.account.ProfileCreationPresenter +import net.jami.account.ProfileCreationView +import net.jami.mvp.AccountCreationModel + +@AndroidEntryPoint +class TVProfileCreationFragment : JamiGuidedStepFragment<ProfileCreationPresenter>(), + ProfileCreationView { + private var mModel: AccountCreationModelImpl? = null + private var iconSize = -1 + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + when (requestCode) { + ProfileCreationFragment.REQUEST_CODE_PHOTO -> if (resultCode == Activity.RESULT_OK && intent != null && intent.extras != null) { + val uri = intent.extras!![MediaStore.EXTRA_OUTPUT] as Uri? + try { + requireContext().contentResolver.openInputStream(uri!!).use { iStream -> + val image = BitmapFactory.decodeStream(iStream) + presenter!!.photoUpdated(Single.just(intent).map { image }) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + ProfileCreationFragment.REQUEST_CODE_GALLERY -> if (resultCode == Activity.RESULT_OK && intent != null) { + presenter!!.photoUpdated( + loadBitmap(requireContext(), intent.data!!).map { b: Bitmap? -> b }) + } + else -> super.onActivityResult(requestCode, resultCode, intent) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) { + when (requestCode) { + ProfileCreationFragment.REQUEST_PERMISSION_CAMERA -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + presenter!!.cameraPermissionChanged(true) + presenter!!.cameraClick() + } + ProfileCreationFragment.REQUEST_PERMISSION_READ_STORAGE -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + presenter!!.galleryClick() + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (mModel == null) { + activity?.finish() + return + } + iconSize = resources.getDimension(R.dimen.tv_avatar_size).toInt() + presenter!!.initPresenter(mModel) + } + + override fun onCreateGuidance(savedInstanceState: Bundle): Guidance { + val title = getString(R.string.account_create_title) + val breadcrumb = "" + val description = getString(R.string.profile_message_warning) + return Guidance(title, description, breadcrumb, AvatarDrawable.Builder() + .withNameData( + if (mModel == null) null else mModel!!.fullName, + if (mModel == null) null else mModel!!.username + ) + .withCircleCrop(true) + .build(requireContext()) + ) + } + + override fun onProvideTheme(): Int { + return R.style.Theme_Ring_Leanback_GuidedStep_First + } + + override fun onCreateActions(actions: List<GuidedAction>, savedInstanceState: Bundle) { + addEditTextAction(activity, actions, USER_NAME.toLong(), R.string.profile_name_hint, R.string.profile_name_hint) + addAction(activity, actions, CAMERA.toLong(), getString(R.string.take_a_photo), "") + addAction(activity, actions, GALLERY.toLong(), getString(R.string.open_the_gallery), "") + addAction(activity, actions, NEXT.toLong(), getString(R.string.wizard_next), "", true) + } + + override fun onGuidedActionClicked(action: GuidedAction) { + when (action.id) { + CAMERA.toLong() -> presenter!!.cameraClick() + GALLERY.toLong() -> presenter!!.galleryClick() + NEXT.toLong() -> presenter!!.nextClick() + } + } + + override fun displayProfileName(profileName: String) { + findActionById(USER_NAME.toLong()).editDescription = profileName + notifyActionChanged(findActionPositionById(USER_NAME.toLong())) + } + + override fun goToGallery() { + try { + val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + startActivityForResult(intent, ProfileCreationFragment.REQUEST_CODE_GALLERY) + } catch (e: ActivityNotFoundException) { + AlertDialog.Builder(requireActivity()) + .setPositiveButton(android.R.string.ok, null) + .setTitle(R.string.gallery_error_title) + .setMessage(R.string.gallery_error_message) + .show() + } + } + + override fun goToPhotoCapture() { + val intent = Intent(activity, CustomCameraActivity::class.java) + startActivityForResult(intent, ProfileCreationFragment.REQUEST_CODE_PHOTO) + } + + override fun askStoragePermission() { + requestPermissions( + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + ProfileCreationFragment.REQUEST_PERMISSION_READ_STORAGE + ) + } + + override fun askPhotoPermission() { + requestPermissions( + arrayOf(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE), + ProfileCreationFragment.REQUEST_PERMISSION_CAMERA + ) + } + + override fun goToNext(accountCreationModel: AccountCreationModel, saveProfile: Boolean) { + val wizardActivity: Activity? = activity + if (wizardActivity is TVAccountWizard) { + wizardActivity.profileCreated(accountCreationModel, saveProfile) + } + } + + override fun setProfile(accountCreationModel: AccountCreationModel) { + val model = accountCreationModel as AccountCreationModelImpl + val newAccount = model.newAccount + val avatar = AvatarDrawable.Builder() + .withPhoto(model.photo) + .withNameData(accountCreationModel.getFullName(), accountCreationModel.getUsername()) + .withId(newAccount?.username) + .withCircleCrop(true) + .build(requireContext()) + avatar.setInSize(iconSize) + guidanceStylist.iconView.setImageDrawable(avatar) + } + + override fun onGuidedActionEditedAndProceed(action: GuidedAction): Long { + when (action.id.toInt()) { + USER_NAME -> { + val username = action.editTitle.toString() + presenter!!.fullNameUpdated(username) + if (username.isEmpty()) action.title = + getString(R.string.profile_name_hint) else action.title = username + } + CAMERA -> presenter!!.cameraClick() + GALLERY -> presenter!!.galleryClick() + } + return super.onGuidedActionEditedAndProceed(action) + } + + override fun onGuidedActionEditCanceled(action: GuidedAction) { + if (action.id.toInt() == USER_NAME) { + val username = action.editTitle.toString() + presenter!!.fullNameUpdated(username) + if (TextUtils.isEmpty(username)) action.title = + getString(R.string.profile_name_hint) else action.title = username + } + super.onGuidedActionEditCanceled(action) + } + + companion object { + private const val USER_NAME = 1 + private const val GALLERY = 2 + private const val CAMERA = 3 + private const val NEXT = 4 + + fun newInstance(ringAccountViewModel: AccountCreationModelImpl?): GuidedStepSupportFragment { + val fragment = TVProfileCreationFragment() + fragment.mModel = ringAccountViewModel + return fragment + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileEditingFragment.java b/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileEditingFragment.java deleted file mode 100644 index 646aa46eb..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileEditingFragment.java +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.tv.account; - -import android.Manifest; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.ActivityNotFoundException; -import android.content.ContentResolver; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.graphics.BitmapFactory; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.Bundle; -import android.provider.MediaStore; -import androidx.annotation.NonNull; -import androidx.leanback.widget.GuidanceStylist; -import androidx.leanback.widget.GuidedAction; -import android.text.TextUtils; -import android.util.Log; -import android.view.View; - -import java.util.List; - -import cx.ring.R; -import cx.ring.account.ProfileCreationFragment; -import cx.ring.application.JamiApplication; -import net.jami.model.Account; -import net.jami.navigation.HomeNavigationPresenter; -import net.jami.navigation.HomeNavigationView; -import net.jami.navigation.HomeNavigationViewModel; -import cx.ring.tv.camera.CustomCameraActivity; -import cx.ring.utils.AndroidFileUtils; -import cx.ring.utils.BitmapUtils; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class TVProfileEditingFragment extends JamiGuidedStepFragment<HomeNavigationPresenter> - implements HomeNavigationView { - - private static final int USER_NAME = 1; - private static final int GALLERY = 2; - private static final int CAMERA = 3; - - private List<GuidedAction> actions; - private int iconSize = -1; - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - switch (requestCode) { - case ProfileCreationFragment.REQUEST_CODE_PHOTO: - if (resultCode == Activity.RESULT_OK && data != null) { - Bundle extras = data.getExtras(); - if (extras == null) { - Log.e(TAG, "onActivityResult: Not able to get picture from extra"); - return; - } - Uri uri = (Uri) extras.get((MediaStore.EXTRA_OUTPUT)); - if (uri != null) { - ContentResolver cr = requireContext().getContentResolver(); - presenter.saveVCardPhoto(Single.fromCallable(() -> BitmapFactory.decodeStream(cr.openInputStream(uri))) - .map(BitmapUtils::bitmapToPhoto)); - } - } - break; - case ProfileCreationFragment.REQUEST_CODE_GALLERY: - if (resultCode == Activity.RESULT_OK && data != null) { - presenter.saveVCardPhoto(AndroidFileUtils - .loadBitmap(getActivity(), data.getData()) - .map(BitmapUtils::bitmapToPhoto)); - } - break; - default: - break; - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - switch (requestCode) { - case ProfileCreationFragment.REQUEST_PERMISSION_CAMERA: - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - presenter.cameraPermissionChanged(true); - presenter.cameraClicked(); - } - break; - case ProfileCreationFragment.REQUEST_PERMISSION_READ_STORAGE: - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - presenter.galleryClicked(); - } - break; - } - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - super.onViewCreated(view, savedInstanceState); - iconSize = (int) getResources().getDimension(R.dimen.tv_avatar_size); - } - - @Override - @NonNull - public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { - String title = getString(R.string.profile); - String breadcrumb = ""; - String description = getString(R.string.profile_message_warning); - Drawable icon = requireContext().getDrawable(R.drawable.ic_contact_picture_fallback); - return new GuidanceStylist.Guidance(title, description, breadcrumb, icon); - } - - @Override - public int onProvideTheme() { - return R.style.Theme_Ring_Leanback_GuidedStep_First; - } - - @Override - public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) { - addEditTextAction(getActivity(), actions, USER_NAME, R.string.account_edit_profile, R.string.profile_name_hint); - addAction(getActivity(), actions, CAMERA, R.string.take_a_photo); - addAction(getActivity(), actions, GALLERY, R.string.open_the_gallery); - this.actions = actions; - } - - public long onGuidedActionEditedAndProceed(GuidedAction action) { - if (action.getId() == USER_NAME) { - String username = action.getEditTitle().toString(); - presenter.saveVCardFormattedName(username); - } else if (action.getId() == CAMERA) { - presenter.cameraClicked(); - } else if (action.getId() == GALLERY) { - presenter.galleryClicked(); - } - return super.onGuidedActionEditedAndProceed(action); - } - - @Override - public void onGuidedActionClicked(GuidedAction action) { - if (action.getId() == CAMERA) { - presenter.cameraClicked(); - } else if (action.getId() == GALLERY) { - presenter.galleryClicked(); - } - } - - @Override - public void showViewModel(HomeNavigationViewModel viewModel) { - Account account = viewModel.getAccount(); - if (account == null) { - Log.e(TAG, "Not able to get current account"); - return; - } - - String alias = viewModel.getAlias(); - GuidedAction action = actions.isEmpty() ? null : actions.get(0); - if (action != null && action.getId() == USER_NAME) { - if (TextUtils.isEmpty(alias)) { - action.setEditTitle(""); - action.setTitle(getString(R.string.account_edit_profile)); - - } else { - action.setEditTitle(alias); - action.setTitle(alias); - } - notifyActionChanged(0); - } - - if (TextUtils.isEmpty(alias)) - getGuidanceStylist().getTitleView().setText(R.string.profile); - else - getGuidanceStylist().getTitleView().setText(alias); - - AvatarDrawable.load(getContext(), account) - .map(avatar -> { - avatar.setInSize(iconSize); - return avatar; - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(avatar -> getGuidanceStylist().getIconView().setImageDrawable(avatar)); - } - - @Override - public void updateModel(Account account) { - } - - @Override - public void gotToImageCapture() { - Intent intent = new Intent(getActivity(), CustomCameraActivity.class); - startActivityForResult(intent, ProfileCreationFragment.REQUEST_CODE_PHOTO); - } - - @Override - public void askCameraPermission() { - requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, - ProfileCreationFragment.REQUEST_PERMISSION_CAMERA); - } - - @Override - public void askGalleryPermission() { - requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, - ProfileCreationFragment.REQUEST_PERMISSION_READ_STORAGE); - } - - @Override - public void goToGallery() { - try { - Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); - startActivityForResult(intent, ProfileCreationFragment.REQUEST_CODE_GALLERY); - } catch (ActivityNotFoundException e) { - new AlertDialog.Builder(requireContext()) - .setPositiveButton(android.R.string.ok, null) - .setTitle(R.string.gallery_error_title) - .setMessage(R.string.gallery_error_message) - .show(); - } - } -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileEditingFragment.kt b/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileEditingFragment.kt new file mode 100644 index 000000000..7042d907a --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/account/TVProfileEditingFragment.kt @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.account + +import android.Manifest +import android.app.Activity +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.text.TextUtils +import android.util.Log +import android.view.View +import androidx.leanback.widget.GuidanceStylist.Guidance +import androidx.leanback.widget.GuidedAction +import cx.ring.R +import cx.ring.account.ProfileCreationFragment +import cx.ring.tv.camera.CustomCameraActivity +import cx.ring.utils.AndroidFileUtils.loadBitmap +import cx.ring.utils.BitmapUtils +import cx.ring.views.AvatarDrawable +import cx.ring.views.AvatarDrawable.Companion.load +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.model.Account +import net.jami.navigation.HomeNavigationPresenter +import net.jami.navigation.HomeNavigationView +import net.jami.navigation.HomeNavigationViewModel + +@AndroidEntryPoint +class TVProfileEditingFragment : JamiGuidedStepFragment<HomeNavigationPresenter>(), HomeNavigationView { + private var iconSize = -1 + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + ProfileCreationFragment.REQUEST_CODE_PHOTO -> if (resultCode == Activity.RESULT_OK && data != null) { + val extras = data.extras + if (extras == null) { + Log.e(TAG, "onActivityResult: Not able to get picture from extra") + return + } + val uri = extras[MediaStore.EXTRA_OUTPUT] as Uri? + if (uri != null) { + val cr = requireContext().contentResolver + presenter.saveVCardPhoto(Single.fromCallable { + cr.openInputStream(uri).use { BitmapFactory.decodeStream(it) } + }.map { obj: Bitmap -> BitmapUtils.bitmapToPhoto(obj) }) + } + } + ProfileCreationFragment.REQUEST_CODE_GALLERY -> if (resultCode == Activity.RESULT_OK && data != null) { + presenter.saveVCardPhoto(loadBitmap(requireContext(), data.data!!) + .map { obj: Bitmap -> BitmapUtils.bitmapToPhoto(obj) }) + } + else -> { + } + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) { + when (requestCode) { + ProfileCreationFragment.REQUEST_PERMISSION_CAMERA -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + presenter!!.cameraPermissionChanged(true) + presenter!!.cameraClicked() + } + ProfileCreationFragment.REQUEST_PERMISSION_READ_STORAGE -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + presenter!!.galleryClicked() + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + iconSize = resources.getDimension(R.dimen.tv_avatar_size).toInt() + } + + override fun onCreateGuidance(savedInstanceState: Bundle?): Guidance { + val title = getString(R.string.profile) + val breadcrumb = "" + val description = getString(R.string.profile_message_warning) + val icon = requireContext().getDrawable(R.drawable.ic_contact_picture_fallback) + return Guidance(title, description, breadcrumb, icon) + } + + override fun onProvideTheme(): Int { + return R.style.Theme_Ring_Leanback_GuidedStep_First + } + + override fun onCreateActions(actions: List<GuidedAction>, savedInstanceState: Bundle?) { + addEditTextAction(context, actions, USER_NAME, R.string.account_edit_profile, R.string.profile_name_hint) + addAction(context, actions, CAMERA, R.string.take_a_photo) + addAction(context, actions, GALLERY, R.string.open_the_gallery) + this.actions = actions + } + + override fun onGuidedActionEditedAndProceed(action: GuidedAction): Long { + when (action.id) { + USER_NAME -> presenter?.saveVCardFormattedName(action.editTitle.toString()) + CAMERA -> presenter?.cameraClicked() + GALLERY -> presenter?.galleryClicked() + } + return super.onGuidedActionEditedAndProceed(action) + } + + override fun onGuidedActionClicked(action: GuidedAction) { + when (action.id) { + CAMERA -> presenter?.cameraClicked() + GALLERY -> presenter?.galleryClicked() + } + } + + override fun showViewModel(viewModel: HomeNavigationViewModel) { + val action = actions?.let { if (it.isEmpty()) null else it[0] } + if (action != null && action.id == USER_NAME.toLong()) { + if (TextUtils.isEmpty(viewModel.alias)) { + action.editTitle = "" + action.title = getString(R.string.account_edit_profile) + } else { + action.editTitle = viewModel.alias + action.title = viewModel.alias + } + notifyActionChanged(0) + } + if (TextUtils.isEmpty(viewModel.alias)) + guidanceStylist.titleView.setText(R.string.profile) + else guidanceStylist.titleView.text = viewModel.alias + setPhoto(viewModel.account) + } + + override fun setPhoto(account: Account) { + load(requireContext(), account) + .map { avatar -> avatar.apply { setInSize(iconSize) } } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { avatar: AvatarDrawable? -> guidanceStylist.iconView.setImageDrawable(avatar) } + } + + override fun updateModel(account: Account) {} + override fun gotToImageCapture() { + val intent = Intent(activity, CustomCameraActivity::class.java) + startActivityForResult(intent, ProfileCreationFragment.REQUEST_CODE_PHOTO) + } + + override fun askCameraPermission() { + requestPermissions( + arrayOf(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE), + ProfileCreationFragment.REQUEST_PERMISSION_CAMERA + ) + } + + override fun askGalleryPermission() { + requestPermissions( + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + ProfileCreationFragment.REQUEST_PERMISSION_READ_STORAGE + ) + } + + override fun goToGallery() { + try { + val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + startActivityForResult(intent, ProfileCreationFragment.REQUEST_CODE_GALLERY) + } catch (e: ActivityNotFoundException) { + AlertDialog.Builder(requireContext()) + .setPositiveButton(android.R.string.ok, null) + .setTitle(R.string.gallery_error_title) + .setMessage(R.string.gallery_error_message) + .show() + } + } + + companion object { + private const val USER_NAME = 1L + private const val GALLERY = 2L + private const val CAMERA = 3L + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVShareActivity.java b/ring-android/app/src/main/java/cx/ring/tv/account/TVShareActivity.java index 8ff02f643..d398017b4 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVShareActivity.java +++ b/ring-android/app/src/main/java/cx/ring/tv/account/TVShareActivity.java @@ -21,8 +21,9 @@ package cx.ring.tv.account; import android.os.Bundle; import androidx.fragment.app.FragmentActivity; import cx.ring.R; +import dagger.hilt.android.AndroidEntryPoint; - +@AndroidEntryPoint public class TVShareActivity extends FragmentActivity { public static final String SHARED_ELEMENT_NAME = "photo"; diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVShareFragment.java b/ring-android/app/src/main/java/cx/ring/tv/account/TVShareFragment.java deleted file mode 100644 index 5dc6994b1..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVShareFragment.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.tv.account; - -import android.graphics.Bitmap; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.databinding.TvFragShareBinding; -import net.jami.model.Account; -import cx.ring.mvp.BaseSupportFragment; -import net.jami.mvp.GenericView; -import cx.ring.services.VCardServiceImpl; -import net.jami.share.SharePresenter; -import net.jami.share.ShareViewModel; -import net.jami.utils.Log; -import net.jami.utils.QRCodeUtils; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class TVShareFragment extends BaseSupportFragment<SharePresenter> implements GenericView<ShareViewModel> { - - private TvFragShareBinding binding; - private final CompositeDisposable disposable = new CompositeDisposable(); - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = TvFragShareBinding.inflate(inflater, container, false); - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - disposable.clear(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - disposable.dispose(); - } - - @Override - public void showViewModel(final ShareViewModel viewModel) { - if (binding == null) - return; - - final QRCodeUtils.QRCodeData qrCodeData = viewModel.getAccountQRCodeData(0x00000000, 0xFFFFFFFF); - getUserAvatar(viewModel.getAccount()); - - if (qrCodeData == null) { - binding.qrImage.setVisibility(View.INVISIBLE); - } else { - int pad = 56; - Bitmap bitmap = Bitmap.createBitmap(qrCodeData.getWidth() + (2 * pad), qrCodeData.getHeight() + (2 * pad), Bitmap.Config.ARGB_8888); - bitmap.setPixels(qrCodeData.getData(), 0, qrCodeData.getWidth(), pad, pad, qrCodeData.getWidth(), qrCodeData.getHeight()); - binding.qrImage.setImageBitmap(bitmap); - binding.shareQrInstruction.setText(R.string.share_message); - binding.qrImage.setVisibility(View.VISIBLE); - } - } - - private void getUserAvatar(Account account) { - disposable.add(VCardServiceImpl - .loadProfile(requireContext(), account) - .observeOn(AndroidSchedulers.mainThread()) - .doOnSuccess(profile -> { - if (binding != null) { - binding.shareUri.setVisibility(View.VISIBLE); - binding.shareUri.setText(account.getDisplayUsername()); - } - }) - .flatMap(p -> AvatarDrawable.load(requireContext(), account)) - .subscribe(a -> { - if (binding != null) { - binding.qrUserPhoto.setVisibility(View.VISIBLE); - binding.qrUserPhoto.setImageDrawable(a); - } - }, e-> Log.e(TVShareFragment.class.getSimpleName(), e.getMessage()))); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVShareFragment.kt b/ring-android/app/src/main/java/cx/ring/tv/account/TVShareFragment.kt new file mode 100644 index 000000000..b7b868719 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/account/TVShareFragment.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.tv.account + +import android.graphics.Bitmap +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import cx.ring.R +import cx.ring.databinding.TvFragShareBinding +import cx.ring.mvp.BaseSupportFragment +import cx.ring.services.VCardServiceImpl.Companion.loadProfile +import cx.ring.views.AvatarDrawable +import cx.ring.views.AvatarDrawable.Companion.build +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.model.Account +import net.jami.mvp.GenericView +import net.jami.share.SharePresenter +import net.jami.share.ShareViewModel +import net.jami.utils.Log + +@AndroidEntryPoint +class TVShareFragment : BaseSupportFragment<SharePresenter, GenericView<ShareViewModel>>(), GenericView<ShareViewModel> { + private var binding: TvFragShareBinding? = null + private val disposable = CompositeDisposable() + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = TvFragShareBinding.inflate(inflater, container, false) + return binding!!.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + disposable.clear() + } + + override fun onDestroy() { + super.onDestroy() + disposable.dispose() + } + + override fun showViewModel(viewModel: ShareViewModel) { + binding?.let { binding -> + val qrCodeData = viewModel.getAccountQRCodeData(0x00000000, -0x1) + getUserAvatar(viewModel.account) + if (qrCodeData == null) { + binding.qrImage.visibility = View.INVISIBLE + } else { + val pad = 56 + val bitmap = Bitmap.createBitmap(qrCodeData.width + 2 * pad, qrCodeData.height + 2 * pad, Bitmap.Config.ARGB_8888) + bitmap.setPixels(qrCodeData.data, 0, qrCodeData.width, pad, pad, qrCodeData.width, qrCodeData.height) + binding.qrImage.setImageBitmap(bitmap) + binding.shareQrInstruction.setText(R.string.share_message) + binding.qrImage.visibility = View.VISIBLE + } + } + } + + private fun getUserAvatar(account: Account) { + disposable.add(loadProfile(requireContext(), account) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + binding?.apply { + shareUri?.visibility = View.VISIBLE + shareUri?.text = account.displayUsername + } + } + .map { p -> build(requireContext(), account, p, true) } + .subscribe({ a: AvatarDrawable? -> + binding?.apply { + qrUserPhoto?.visibility = View.VISIBLE + qrUserPhoto?.setImageDrawable(a) + } + }) { e -> Log.e(TVShareFragment::class.simpleName, e.message) }) + } +} diff --git a/ring-android/app/src/main/java/cx/ring/tv/call/TVCallActivity.java b/ring-android/app/src/main/java/cx/ring/tv/call/TVCallActivity.java deleted file mode 100644 index eda7b028c..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/call/TVCallActivity.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Michel Schmit <michel.schmit@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.tv.call; - -import android.content.Intent; -import android.media.AudioManager; -import android.os.Build; -import android.os.Bundle; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.WindowManager; - -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentTransaction; - -import cx.ring.R; -import cx.ring.application.JamiApplication; - -import net.jami.call.CallView; - -import cx.ring.utils.ConversationPath; - -import net.jami.services.NotificationService; -import net.jami.utils.Log; - -public class TVCallActivity extends FragmentActivity { - - static final String TAG = TVCallActivity.class.getSimpleName(); - private static final String CALL_FRAGMENT_TAG = "CALL_FRAGMENT_TAG"; - - private TVCallFragment callFragment; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Intent intent = getIntent(); - if (intent == null) { - finish(); - return; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - setTurnScreenOn(true); - setShowWhenLocked(true); - } else { - getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED | - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON); - } - setContentView(R.layout.tv_activity_call); - setVolumeControlStream(AudioManager.STREAM_VOICE_CALL); - - // dependency injection - JamiApplication.getInstance().getInjectionComponent().inject(this); - JamiApplication.getInstance().startDaemon(); - - ConversationPath path = ConversationPath.fromIntent(intent); - - FragmentManager fragmentManager = getSupportFragmentManager(); - FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); - - if (path != null) { - Log.d(TAG, "onCreate: outgoing call " + path); - callFragment = TVCallFragment.newInstance(intent.getAction(), - path.getAccountId(), - path.getConversationId(), - intent.getExtras().getString(Intent.EXTRA_PHONE_NUMBER, path.getConversationId()), - false); - fragmentTransaction.replace(R.id.main_call_layout, callFragment, CALL_FRAGMENT_TAG).commit(); - } else { - Log.d(TAG, "onCreate: incoming call"); - - String confId = getIntent().getStringExtra(NotificationService.KEY_CALL_ID); - Log.d(TAG, "onCreate: conf " + confId); - - callFragment = TVCallFragment.newInstance(Intent.ACTION_VIEW, confId); - fragmentTransaction.replace(R.id.main_call_layout, callFragment, CALL_FRAGMENT_TAG).commit(); - } - } - - @Override - public void onUserLeaveHint() { - Fragment fragment = getSupportFragmentManager().findFragmentByTag(CALL_FRAGMENT_TAG); - if (fragment instanceof CallView) { - CallView callFragment = (CallView) fragment; - callFragment.onUserLeave(); - } - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - callFragment.onKeyDown(); - return super.onKeyDown(keyCode, event); - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - callFragment.onKeyDown(); - return super.onTouchEvent(event); - } -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/call/TVCallActivity.kt b/ring-android/app/src/main/java/cx/ring/tv/call/TVCallActivity.kt new file mode 100644 index 000000000..3887e845d --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/call/TVCallActivity.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Michel Schmit <michel.schmit@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.tv.call + +import dagger.hilt.android.AndroidEntryPoint +import androidx.fragment.app.FragmentActivity +import android.os.Bundle +import android.content.Intent +import android.os.Build +import android.view.WindowManager +import cx.ring.R +import android.media.AudioManager +import android.view.KeyEvent +import net.jami.services.NotificationService +import net.jami.call.CallView +import android.view.MotionEvent +import cx.ring.application.JamiApplication +import cx.ring.utils.ConversationPath +import net.jami.utils.Log + +@AndroidEntryPoint +class TVCallActivity : FragmentActivity() { + private var callFragment: TVCallFragment? = null + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val intent = intent + if (intent == null) { + finish() + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setTurnScreenOn(true) + setShowWhenLocked(true) + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON) + } + setContentView(R.layout.tv_activity_call) + volumeControlStream = AudioManager.STREAM_VOICE_CALL + JamiApplication.instance?.startDaemon() + val path = ConversationPath.fromIntent(intent) + val fragmentManager = supportFragmentManager + val fragmentTransaction = fragmentManager.beginTransaction() + if (path != null) { + Log.d(TAG, "onCreate: outgoing call $path ${intent.action}") + callFragment = TVCallFragment.newInstance(intent.action!!, path.accountId, path.conversationId, + intent.extras!!.getString(Intent.EXTRA_PHONE_NUMBER, path.conversationId), false) + fragmentTransaction.replace(R.id.main_call_layout, callFragment!!, CALL_FRAGMENT_TAG) + .commit() + } else { + Log.d(TAG, "onCreate: incoming call") + val confId = getIntent().getStringExtra(NotificationService.KEY_CALL_ID) + Log.d(TAG, "onCreate: conf $confId") + callFragment = TVCallFragment.newInstance(Intent.ACTION_VIEW, confId) + fragmentTransaction.replace(R.id.main_call_layout, callFragment!!, CALL_FRAGMENT_TAG) + .commit() + } + } + + public override fun onUserLeaveHint() { + val fragment = supportFragmentManager.findFragmentByTag(CALL_FRAGMENT_TAG) + if (fragment is CallView) { + val callFragment = fragment as CallView + callFragment.onUserLeave() + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + callFragment?.onKeyDown() + return super.onKeyDown(keyCode, event) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + callFragment?.onKeyDown() + return super.onTouchEvent(event) + } + + companion object { + val TAG = TVCallActivity::class.simpleName!! + private const val CALL_FRAGMENT_TAG = "CALL_FRAGMENT_TAG" + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.java b/ring-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.java deleted file mode 100644 index 39b372e7f..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.java +++ /dev/null @@ -1,828 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.tv.call; - -import android.Manifest; -import android.app.Activity; -import android.content.ComponentName; -import android.app.PictureInPictureParams; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.graphics.Matrix; -import android.graphics.PixelFormat; -import android.graphics.Rect; -import android.graphics.RectF; -import android.graphics.SurfaceTexture; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.PowerManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.view.menu.MenuBuilder; -import androidx.appcompat.view.menu.MenuPopupHelper; -import androidx.appcompat.widget.PopupMenu; -import androidx.percentlayout.widget.PercentFrameLayout; - -import android.support.v4.media.MediaMetadataCompat; -import android.support.v4.media.session.MediaSessionCompat; -import android.text.TextUtils; -import android.util.Log; -import android.util.Rational; -import android.view.LayoutInflater; -import android.view.Surface; -import android.view.SurfaceHolder; -import android.view.TextureView; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.AccelerateInterpolator; -import android.view.animation.AlphaAnimation; -import android.widget.RelativeLayout; - -import com.rodolfonavalon.shaperipplelibrary.model.Circle; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Set; - -import javax.inject.Inject; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import net.jami.call.CallPresenter; -import net.jami.call.CallView; -import cx.ring.client.ContactDetailsActivity; -import cx.ring.client.ConversationSelectionActivity; -import cx.ring.databinding.ItemParticipantLabelBinding; -import cx.ring.databinding.TvFragCallBinding; -import cx.ring.fragments.CallFragment; -import cx.ring.adapters.ConfParticipantAdapter; -import cx.ring.fragments.ConversationFragment; - -import net.jami.daemon.JamiService; -import net.jami.model.Contact; -import net.jami.model.Conference; -import net.jami.model.Call; -import cx.ring.mvp.BaseSupportFragment; - -import net.jami.model.Uri; -import net.jami.services.DeviceRuntimeService; -import net.jami.services.HardwareService; -import cx.ring.tv.main.HomeActivity; -import cx.ring.utils.ActionHelper; -import cx.ring.utils.ContentUriHandler; -import cx.ring.utils.ConversationPath; -import cx.ring.views.AvatarDrawable; - -public class TVCallFragment extends BaseSupportFragment<CallPresenter> implements CallView { - - public static final String TAG = TVCallFragment.class.getSimpleName(); - - public static final String KEY_ACTION = "action"; - public static final String KEY_CONF_ID = "confId"; - public static final String KEY_AUDIO_ONLY = "AUDIO_ONLY"; - - private static final int REQUEST_CODE_ADD_PARTICIPANT = 6; - private static final int REQUEST_PERMISSION_INCOMING = 1003; - private static final int REQUEST_PERMISSION_OUTGOING = 1004; - - private TvFragCallBinding binding; - - // Screen wake lock for incoming call - private Runnable runnable; - private int mPreviewWidth = 720, mPreviewHeight = 1280; - private int mPreviewWidthRot = 720, mPreviewHeightRot = 1280; - private PowerManager.WakeLock mScreenWakeLock; - - private boolean mBackstackLost = false; - private boolean mTextureAvailable = false; - private ConfParticipantAdapter confAdapter = null; - private boolean mConferenceMode = false; - - private int mVideoWidth = -1; - private int mVideoHeight = -1; - - private final AlphaAnimation fadeOutAnimation = new AlphaAnimation(1, 0); - - private MediaSessionCompat mSession; - - @Inject - DeviceRuntimeService mDeviceRuntimeService; - - public static TVCallFragment newInstance(@NonNull String action, @NonNull String accountId, @NonNull String conversationId, @NonNull String contactUri, boolean audioOnly) { - Bundle bundle = new Bundle(); - bundle.putString(KEY_ACTION, action); - bundle.putAll(ConversationPath.toBundle(accountId, conversationId)); - bundle.putString(Intent.EXTRA_PHONE_NUMBER, contactUri); - bundle.putBoolean(KEY_AUDIO_ONLY, audioOnly); - TVCallFragment fragment = new TVCallFragment(); - fragment.setArguments(bundle); - return fragment; - } - - public static TVCallFragment newInstance(@NonNull String action, @Nullable String confId) { - Bundle bundle = new Bundle(); - bundle.putString(KEY_ACTION, action); - bundle.putString(KEY_CONF_ID, confId); - TVCallFragment countDownFragment = new TVCallFragment(); - countDownFragment.setArguments(bundle); - return countDownFragment; - } - - public TVCallFragment() { - fadeOutAnimation.setInterpolator(new AccelerateInterpolator()); - fadeOutAnimation.setStartOffset(1000); - fadeOutAnimation.setDuration(1000); - } - - @Override - protected void initPresenter(CallPresenter presenter) { - super.initPresenter(presenter); - String action = getArguments().getString(KEY_ACTION); - if (action != null) { - if (action.equals(Intent.ACTION_CALL)) { - prepareCall(false); - } else if (action.equals(Intent.ACTION_VIEW)) { - presenter.initIncomingCall(getArguments().getString(KEY_CONF_ID), true); - } - } - } - - @Override - public void handleCallWakelock(boolean isAudioOnly) { } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - binding = TvFragCallBinding.inflate(inflater, container, false); - binding.setPresenter(this); - return binding.getRoot(); - } - - private final TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() { - @Override - public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { - configureTransform(width, height); - presenter.previewVideoSurfaceCreated(binding.previewSurface); - mTextureAvailable = true; - } - - @Override - public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { - configureTransform(width, height); - } - - @Override - public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { - presenter.previewVideoSurfaceDestroyed(); - mTextureAvailable = false; - return true; - } - - @Override - public void onSurfaceTextureUpdated(SurfaceTexture surface) { - } - }; - - @Override - public void onStart() { - super.onStart(); - if (mScreenWakeLock != null && !mScreenWakeLock.isHeld()) { - mScreenWakeLock.acquire(); - } - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - mSession = new MediaSessionCompat(requireContext(), TAG); - mSession.setMetadata(new MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, getString(R.string.pip_title)) - .build()); - - PowerManager powerManager = (PowerManager) requireContext().getSystemService(Context.POWER_SERVICE); - mScreenWakeLock = powerManager.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP | PowerManager.ON_AFTER_RELEASE, "ring:callLock"); - mScreenWakeLock.setReferenceCounted(false); - - binding.videoSurface.getHolder().setFormat(PixelFormat.RGBA_8888); - binding.videoSurface.getHolder().addCallback(new SurfaceHolder.Callback() { - @Override - public void surfaceCreated(SurfaceHolder holder) { - presenter.videoSurfaceCreated(holder); - } - - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - - } - - @Override - public void surfaceDestroyed(SurfaceHolder holder) { - presenter.videoSurfaceDestroyed(); - } - }); - - view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> - resetVideoSize(mVideoWidth, mVideoHeight)); - - binding.previewSurface.setSurfaceTextureListener(listener); - binding.shapeRipple.setRippleShape(new Circle()); - runnable = () -> presenter.uiVisibilityChanged(false); - } - - @Override - public void onResume() { - super.onResume(); - if(mTextureAvailable) - presenter.previewVideoSurfaceCreated(binding.previewSurface); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - if (mScreenWakeLock != null && mScreenWakeLock.isHeld()) { - mScreenWakeLock.release(); - } - mScreenWakeLock = null; - if (mSession != null) { - mSession.release(); - mSession = null; - } - presenter.hangupCall(); - runnable = null; - binding = null; - } - - @Override - public void onStop() { - super.onStop(); - if (mScreenWakeLock != null && mScreenWakeLock.isHeld()) { - mScreenWakeLock.release(); - } - View view = getView(); - Runnable r = runnable; - if (view != null && r != null) { - Handler handler = view.getHandler(); - if (handler != null) - handler.removeCallbacks(r); - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requestCode != REQUEST_PERMISSION_INCOMING && requestCode != REQUEST_PERMISSION_OUTGOING) - return; - for (int i = 0, n = permissions.length; i < n; i++) { - boolean audioGranted = mDeviceRuntimeService.hasAudioPermission(); - boolean granted = grantResults[i] == PackageManager.PERMISSION_GRANTED; - switch (permissions[i]) { - case Manifest.permission.CAMERA: - presenter.cameraPermissionChanged(granted); - if (audioGranted) { - initializeCall(requestCode == REQUEST_PERMISSION_INCOMING); - } - break; - case Manifest.permission.RECORD_AUDIO: - presenter.audioPermissionChanged(granted); - initializeCall(requestCode == REQUEST_PERMISSION_INCOMING); - break; - } - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (requestCode == REQUEST_CODE_ADD_PARTICIPANT) { - if (resultCode == Activity.RESULT_OK && data != null) { - ConversationPath path = ConversationPath.fromUri(data.getData()); - if (path != null) { - presenter.addConferenceParticipant(path.getAccountId(), path.getConversationUri()); - } - } - } - } - - @Override - public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) { - if(!isInPictureInPictureMode) { - mBackstackLost = true; - } - presenter.pipModeChanged(isInPictureInPictureMode); - } - - @Override - public void displayContactBubble(final boolean display) { - binding.contactBubbleLayout.setVisibility(display ? View.VISIBLE : View.GONE); - } - - @Override - public void displayVideoSurface(final boolean displayVideoSurface, final boolean displayPreviewContainer) { - binding.videoSurface.setVisibility(displayVideoSurface ? View.VISIBLE : View.GONE); - binding.previewContainer.setVisibility(displayPreviewContainer ? View.VISIBLE : View.GONE); - } - - @Override - public void displayPreviewSurface(boolean display) { - if (display) { - binding.videoSurface.setZOrderOnTop(false); - //mVideoPreview.setZOrderMediaOverlay(true); - binding.videoSurface.setZOrderMediaOverlay(false); - } else { - binding.videoSurface.setZOrderMediaOverlay(true); - binding.videoSurface.setZOrderOnTop(true); - } - } - - @Override - public void displayHangupButton(boolean display) { - binding.confControlGroup.setVisibility((mConferenceMode && display) ? View.VISIBLE : View.GONE); - - if (display) { - binding.callHangupBtn.setVisibility(View.VISIBLE); - binding.callAddBtn.setVisibility(View.VISIBLE); - } else { - binding.callHangupBtn.startAnimation(fadeOutAnimation); - binding.callAddBtn.startAnimation(fadeOutAnimation); - binding.callHangupBtn.setVisibility(View.GONE); - binding.callAddBtn.setVisibility(View.GONE); - } - if (mConferenceMode && display) { - binding.confControlGroup.setVisibility(View.VISIBLE); - } else { - binding.confControlGroup.startAnimation(fadeOutAnimation); - } - } - - @Override - public void displayDialPadKeyboard() { - } - - @Override - public void switchCameraIcon(boolean isFront) { - - } - - @Override - public void updateAudioState(HardwareService.AudioState state) { - - } - - @Override - public void updateMenu() { - - } - - @Override - public void updateTime(final long duration) { - if (binding != null) - binding.callStatusTxt.setText(String.format(Locale.getDefault(), "%d:%02d:%02d", duration / 3600, duration % 3600 / 60, duration % 60)); - } - - @Override - public void updateContactBubble(@NonNull final List<Call> calls) { - mConferenceMode = calls.size() > 1; - String username = mConferenceMode ? "Conference with " + calls.size() + " people" : calls.get(0).getContact().getRingUsername(); - String displayName = mConferenceMode ? null : calls.get(0).getContact().getDisplayName(); - - Contact contact = calls.get(0).getContact(); - String ringId = contact.getIds().get(0); - Log.d(TAG, "updateContactBubble: username=" + username + ", ringId=" + ringId + " photo:" + contact.getPhoto()); - - if (mSession != null) { - mSession.setMetadata(new MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayName) - .build()); - } - - boolean hasProfileName = displayName != null && !displayName.contentEquals(username); - if (hasProfileName) { - binding.contactBubbleNumTxt.setVisibility(View.VISIBLE); - binding.contactBubbleTxt.setText(displayName); - binding.contactBubbleNumTxt.setText(username); - } else { - binding.contactBubbleNumTxt.setVisibility(View.GONE); - binding.contactBubbleTxt.setText(username); - } - binding.contactBubble.setImageDrawable( - new AvatarDrawable.Builder() - .withContact(contact) - .withCircleCrop(true) - .build(getActivity()) - ); - - /*if (!mConferenceMode) { - binding.confControlGroup.setVisibility(View.GONE); - } else { - binding.confControlGroup.setVisibility(View.VISIBLE); - if (confAdapter == null) { - confAdapter = new ConfParticipantAdapter((view, call) -> { - Context context = requireContext(); - PopupMenu popup = new PopupMenu(context, view); - popup.inflate(R.menu.conference_participant_actions); - popup.setOnMenuItemClickListener(item -> { - int itemId = item.getItemId(); - if (itemId == R.id.conv_contact_details) { - presenter.openParticipantContact(call); - } else if (itemId == R.id.conv_contact_hangup) { - presenter.hangupParticipant(call); - } else { - return false; - } - return true; - }); - MenuPopupHelper menuHelper = new MenuPopupHelper(context, (MenuBuilder) popup.getMenu(), view); - menuHelper.setForceShowIcon(true); - menuHelper.show(); - }); - } - confAdapter.updateFromCalls(calls); - if (binding.confControlGroup.getAdapter() == null) - binding.confControlGroup.setAdapter(confAdapter); - }*/ - } - - @Override - public void updateCallStatus(final Call.CallStatus callStatus) { - switch (callStatus) { - case NONE: - binding.callStatusTxt.setText(""); - break; - default: - binding.callStatusTxt.setText(CallFragment.callStateToHumanState(callStatus)); - break; - } - } - - @Override - public void initMenu(boolean isSpeakerOn, boolean displayFlip, boolean canDial, - boolean showPluginBtn, boolean onGoingCall) { - - } - - @Override - public void initNormalStateDisplay(boolean audioOnly, boolean muted) { - mSession.setActive(true); - - binding.shapeRipple.stopRipple(); - - binding.callAcceptBtn.setVisibility(View.GONE); - binding.callRefuseBtn.setVisibility(View.GONE); - binding.callHangupBtn.setVisibility(View.VISIBLE); - - binding.contactBubbleLayout.setVisibility(audioOnly ? View.VISIBLE : View.INVISIBLE); - - getActivity().invalidateOptionsMenu(); - - handleVisibilityTimer(); - } - - @Override - public void initIncomingCallDisplay() { - mSession.setActive(true); - - binding.callAcceptBtn.setVisibility(View.VISIBLE); - binding.callAcceptBtn.requestFocus(); - binding.callRefuseBtn.setVisibility(View.VISIBLE); - binding.callHangupBtn.setVisibility(View.GONE); - } - - @Override - public void initOutGoingCallDisplay() { - binding.callAcceptBtn.setVisibility(View.GONE); - binding.callRefuseBtn.setVisibility(View.VISIBLE); - binding.callHangupBtn.setVisibility(View.GONE); - } - - @Override - public void resetPreviewVideoSize(int previewWidth, int previewHeight, int rot) { - if (previewWidth == -1 && previewHeight == -1) - return; - mPreviewWidth = previewWidth; - mPreviewHeight = previewHeight; - boolean flip = (rot % 180) != 0; - binding.previewSurface.setAspectRatio(flip ? mPreviewHeight : mPreviewWidth, flip ? mPreviewWidth : mPreviewHeight); - } - - @Override - public void resetPluginPreviewVideoSize(int previewWidth, int previewHeight, int rot) { - } - - @Override - public void resetVideoSize(final int videoWidth, final int videoHeight) { - Log.w(TAG, "resetVideoSize " + videoWidth + "x" + videoHeight); - ViewGroup rootView = (ViewGroup) getView(); - if (rootView == null) - return; - - double videoRatio = videoWidth / (double) videoHeight; - double screenRatio = getView().getWidth() / (double) getView().getHeight(); - - RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.videoSurface.getLayoutParams(); - int oldW = params.width; - int oldH = params.height; - if (videoRatio >= screenRatio) { - params.width = RelativeLayout.LayoutParams.MATCH_PARENT; - params.height = (int) (videoHeight * (double) rootView.getWidth() / (double) videoWidth); - } else { - params.height = RelativeLayout.LayoutParams.MATCH_PARENT; - params.width = (int) (videoWidth * (double) rootView.getHeight() / (double) videoHeight); - } - - if (oldW != params.width || oldH != params.height) { - binding.videoSurface.setLayoutParams(params); - } - mVideoWidth = videoWidth; - mVideoHeight = videoHeight; - } - - private void configureTransform(int viewWidth, int viewHeight) { - Activity activity = getActivity(); - if (null == binding || null == activity) { - return; - } - int rotation = activity.getWindowManager().getDefaultDisplay().getRotation(); - boolean rot = Surface.ROTATION_90 == rotation || Surface.ROTATION_270 == rotation; - Log.w(TAG, "configureTransform " + viewWidth + "x" + viewHeight + " rot=" + rot + " mPreviewWidth=" + mPreviewWidth + " mPreviewHeight=" + mPreviewHeight); - Matrix matrix = new Matrix(); - RectF viewRect = new RectF(0, 0, viewWidth, viewHeight); - float centerX = viewRect.centerX(); - float centerY = viewRect.centerY(); - if (rot) { - RectF bufferRect = new RectF(0, 0, mPreviewHeightRot, mPreviewWidthRot); - bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY()); - matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL); - float scale = Math.max( - (float) viewHeight / mPreviewHeightRot, - (float) viewWidth / mPreviewWidthRot); - matrix.postScale(scale, scale, centerX, centerY); - matrix.postRotate(90 * (rotation - 2), centerX, centerY); - } else if (Surface.ROTATION_180 == rotation) { - matrix.postRotate(180, centerX, centerY); - } - binding.previewSurface.setTransform(matrix); - } - - /** - * Checks if permissions are accepted for camera and microphone. Takes into account whether call is incoming and outgoing, and requests permissions if not available. - * Initializes the call if permissions are accepted. - * - * @param isIncoming true if call is incoming, false for outgoing - * @see #initializeCall(boolean) initializeCall - */ - @Override - public void prepareCall(boolean isIncoming) { - Bundle args = getArguments(); - boolean audioGranted = mDeviceRuntimeService.hasAudioPermission(); - boolean audioOnly; - int permissionType; - - if (isIncoming) { - audioOnly = presenter.isAudioOnly(); - permissionType = REQUEST_PERMISSION_INCOMING; - - } else { - audioOnly = args.getBoolean(KEY_AUDIO_ONLY); - permissionType = REQUEST_PERMISSION_OUTGOING; - } - if (!audioOnly) { - boolean videoGranted = mDeviceRuntimeService.hasVideoPermission(); - - if ((!audioGranted || !videoGranted) && android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - ArrayList<String> perms = new ArrayList<>(); - if (!videoGranted) { - perms.add(Manifest.permission.CAMERA); - } - if (!audioGranted) { - perms.add(Manifest.permission.RECORD_AUDIO); - } - requestPermissions(perms.toArray(new String[perms.size()]), permissionType); - } else if (audioGranted && videoGranted) { - initializeCall(isIncoming); - } - } else { - if (!audioGranted && android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, permissionType); - } else if (audioGranted) { - initializeCall(isIncoming); - } - } - } - - - - /** - * Starts a call. Takes into account whether call is incoming or outgoing. - * - * @param isIncoming true if call is incoming, false for outgoing - */ - public void initializeCall(boolean isIncoming) { - if (isIncoming) { - presenter.acceptCall(); - } else { - Bundle args; - args = getArguments(); - if (args != null) { - ConversationPath conversation = ConversationPath.fromBundle(args); - presenter.initOutGoing(conversation.getAccountId(), - conversation.getConversationUri(), - args.getString(Intent.EXTRA_PHONE_NUMBER), - args.getBoolean(KEY_AUDIO_ONLY)); - } - } - } - - @Override - public void goToContact(String accountId, Contact contact) { - startActivity(new Intent(Intent.ACTION_VIEW, android.net.Uri.withAppendedPath(android.net.Uri.withAppendedPath(ContentUriHandler.CONTACT_CONTENT_URI, accountId), contact.getPrimaryNumber())) - .setClass(requireContext(), ContactDetailsActivity.class)); - } - - @Override - public boolean displayPluginsButton() { - return false; - } - - @Override - public void updateConfInfo(List<Conference.ParticipantInfo> info) { - binding.participantLabelContainer.removeAllViews(); - if (!info.isEmpty()) { - LayoutInflater inflater = LayoutInflater.from(binding.participantLabelContainer.getContext()); - for (Conference.ParticipantInfo i : info) { - String displayName = i.contact.getDisplayName(); - if (!TextUtils.isEmpty(displayName)) { - ItemParticipantLabelBinding label = ItemParticipantLabelBinding.inflate(inflater); - PercentFrameLayout.LayoutParams params = new PercentFrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.getPercentLayoutInfo().leftMarginPercent = i.x / (float) mVideoWidth; - params.getPercentLayoutInfo().topMarginPercent = i.y / (float) mVideoHeight; - label.participantName.setText(displayName); - binding.participantLabelContainer.addView(label.getRoot(), params); - } - } - } - binding.participantLabelContainer.setVisibility(info.isEmpty() ? View.GONE : View.VISIBLE); - - if (!mConferenceMode) { - binding.confControlGroup.setVisibility(View.GONE); - } else { - binding.confControlGroup.setVisibility(View.VISIBLE); - if (confAdapter == null) { - confAdapter = new ConfParticipantAdapter((view, call) -> { - Context context = requireContext(); - PopupMenu popup = new PopupMenu(context, view); - popup.inflate(R.menu.conference_participant_actions); - popup.setOnMenuItemClickListener(item -> { - int itemId = item.getItemId(); - if (itemId == R.id.conv_contact_details) { - presenter.openParticipantContact(call); - } else if (itemId == R.id.conv_contact_hangup) { - presenter.hangupParticipant(call); - } else { - return false; - } - return true; - }); - MenuPopupHelper menuHelper = new MenuPopupHelper(context, (MenuBuilder) popup.getMenu(), view); - menuHelper.setForceShowIcon(true); - menuHelper.show(); - }); - } - confAdapter.updateFromCalls(info); - if (binding.confControlGroup.getAdapter() == null) - binding.confControlGroup.setAdapter(confAdapter); - } - } - - @Override - public void updateParticipantRecording(Set<Contact> contacts) { - - } - - @Override - public void toggleCallMediaHandler(String id, String callId, boolean toggle) { - JamiService.toggleCallMediaHandler(id, callId, toggle); - } - - @Override - public void goToConversation(String accountId, Uri conversationId) { - - } - - @Override - public void goToAddContact(Contact contact) { - startActivityForResult(ActionHelper.getAddNumberIntentForContact(contact), - ConversationFragment.REQ_ADD_CONTACT); - } - - @Override - public void startAddParticipant(String conferenceId) { - startActivityForResult( - new Intent(Intent.ACTION_PICK) - .setClass(requireActivity(), ConversationSelectionActivity.class) - .putExtra(KEY_CONF_ID, conferenceId), - REQUEST_CODE_ADD_PARTICIPANT); - } - - public void addParticipant() { - presenter.startAddParticipant(); - } - - public void hangUpClicked() { - presenter.hangupCall(); - } - - public void refuseClicked() { - presenter.refuseCall(); - } - - public void acceptClicked() { - prepareCall(true); - } - - @Override - public void finish() { - Activity activity = getActivity(); - if (mSession != null) { - mSession.setActive(false); - } - if (activity != null) { - if (mBackstackLost) { - activity.finishAndRemoveTask(); - startActivity( - Intent.makeMainActivity( - new ComponentName(activity, HomeActivity.class)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - } else { - activity.finish(); - } - } - } - - @Override - public void onUserLeave() { - presenter.requestPipMode(); - } - - @Override - public void enterPipMode(String callId) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - return; - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - PictureInPictureParams.Builder paramBuilder = new PictureInPictureParams.Builder(); - if (binding.videoSurface.getVisibility() == View.VISIBLE) { - int[] l = new int[2]; - binding.videoSurface.getLocationInWindow(l); - int x = l[0]; - int y = l[1]; - int w = binding.videoSurface.getWidth(); - int h = binding.videoSurface.getHeight(); - Rect videoBounds = new Rect(x, y, x + w, y + h); - paramBuilder.setAspectRatio(new Rational(w, h)); - paramBuilder.setSourceRectHint(videoBounds); - } - requireActivity().enterPictureInPictureMode(paramBuilder.build()); - } else { - requireActivity().enterPictureInPictureMode(); - } - } - - public void onKeyDown() { - handleVisibilityTimer(); - } - - private void handleVisibilityTimer() { - presenter.uiVisibilityChanged(true); - View view = getView(); - Runnable r = runnable; - if (view != null && r != null) { - Handler handler = view.getHandler(); - if (handler != null) { - handler.removeCallbacks(r); - handler.postDelayed(r, 5000); - } - } - } - -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.kt b/ring-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.kt new file mode 100644 index 000000000..5fca0317e --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/call/TVCallFragment.kt @@ -0,0 +1,744 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.call + +import android.Manifest +import android.app.Activity +import android.app.PictureInPictureParams +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.* +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.text.TextUtils +import android.util.Log +import android.util.Rational +import android.view.* +import android.view.TextureView.SurfaceTextureListener +import android.view.animation.AccelerateInterpolator +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.LinearInterpolator +import android.widget.RelativeLayout +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.view.menu.MenuPopupHelper +import androidx.appcompat.widget.PopupMenu +import androidx.percentlayout.widget.PercentFrameLayout +import com.rodolfonavalon.shaperipplelibrary.model.Circle +import cx.ring.R +import cx.ring.adapters.ConfParticipantAdapter +import cx.ring.adapters.ConfParticipantAdapter.ConfParticipantSelected +import cx.ring.client.CallActivity +import cx.ring.client.ContactDetailsActivity +import cx.ring.client.ConversationSelectionActivity +import cx.ring.databinding.ItemParticipantLabelBinding +import cx.ring.databinding.TvFragCallBinding +import cx.ring.fragments.CallFragment +import cx.ring.fragments.ConversationFragment +import cx.ring.mvp.BaseSupportFragment +import cx.ring.tv.main.HomeActivity +import cx.ring.utils.ActionHelper +import cx.ring.utils.ContentUriHandler +import cx.ring.utils.ConversationPath +import cx.ring.views.AvatarDrawable +import dagger.hilt.android.AndroidEntryPoint +import net.jami.call.CallPresenter +import net.jami.call.CallView +import net.jami.daemon.JamiService +import net.jami.model.Call +import net.jami.model.Call.CallStatus +import net.jami.model.Conference.ParticipantInfo +import net.jami.model.Contact +import net.jami.model.Uri +import net.jami.services.DeviceRuntimeService +import net.jami.services.HardwareService.AudioState +import java.util.* +import javax.inject.Inject +import kotlin.math.max + +@AndroidEntryPoint +class TVCallFragment : BaseSupportFragment<CallPresenter, CallView>(), CallView { + private var binding: TvFragCallBinding? = null + + // Screen wake lock for incoming call + private var runnable: Runnable? = null + private var mPreviewWidth = 720 + private var mPreviewHeight = 1280 + private val mPreviewWidthRot = 720 + private val mPreviewHeightRot = 1280 + private var mScreenWakeLock: PowerManager.WakeLock? = null + private var mBackstackLost = false + private var mTextureAvailable = false + private var confAdapter: ConfParticipantAdapter? = null + private var mConferenceMode = false + private var mVideoWidth = -1 + private var mVideoHeight = -1 + private val fadeOutAnimation: Animation by lazy { AlphaAnimation(1f, 0f).apply { + interpolator = AccelerateInterpolator() + startOffset = 1000 + duration = 1000 + }} + private val blinkingAnimation: Animation by lazy { AlphaAnimation(1f, 0f).apply { + duration = 400 + interpolator = LinearInterpolator() + repeatCount = Animation.INFINITE + repeatMode = Animation.REVERSE + }} + private var mSession: MediaSessionCompat? = null + + @Inject + lateinit var mDeviceRuntimeService: DeviceRuntimeService + + override fun initPresenter(presenter: CallPresenter) { + val args = requireArguments() + args.getString(CallFragment.KEY_ACTION)?.let { action -> + if (action == CallFragment.ACTION_PLACE_CALL || action == Intent.ACTION_CALL) + prepareCall(false) + else if (action == CallFragment.ACTION_GET_CALL || action == CallActivity.ACTION_CALL_ACCEPT) + presenter.initIncomingCall(args.getString(CallFragment.KEY_CONF_ID)!!, action == CallFragment.ACTION_GET_CALL) + } + } + + override fun handleCallWakelock(isAudioOnly: Boolean) {} + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return TvFragCallBinding.inflate(inflater, container, false).also { b -> + b.presenter = this + binding = b + }.root + } + + private val listener: SurfaceTextureListener = object : SurfaceTextureListener { + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + configureTransform(width, height) + presenter.previewVideoSurfaceCreated(binding!!.previewSurface) + mTextureAvailable = true + } + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + configureTransform(width, height) + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + presenter.previewVideoSurfaceDestroyed() + mTextureAvailable = false + return true + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {} + } + + override fun onStart() { + super.onStart() + mScreenWakeLock?.apply { + if (!isHeld) acquire() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Log.w(TAG, "onViewCreated"); + mSession = MediaSessionCompat(requireContext(), TAG).apply { + setMetadata(MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, getString(R.string.pip_title)) + .build()) + } + val powerManager = requireContext().getSystemService(Context.POWER_SERVICE) as PowerManager + mScreenWakeLock = powerManager.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.ON_AFTER_RELEASE, "ring:callLock") + .apply { setReferenceCounted(false) } + binding!!.videoSurface.holder.setFormat(PixelFormat.RGBA_8888) + binding!!.videoSurface.holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + presenter.videoSurfaceCreated(holder) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} + + override fun surfaceDestroyed(holder: SurfaceHolder) { + presenter.videoSurfaceDestroyed() + } + }) + view.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> resetVideoSize(mVideoWidth, mVideoHeight) } + binding!!.previewSurface.surfaceTextureListener = listener + binding!!.shapeRipple.rippleShape = Circle() + runnable = Runnable { presenter.uiVisibilityChanged(false) } + } + + override fun onResume() { + super.onResume() + if (mTextureAvailable) presenter.previewVideoSurfaceCreated(binding!!.previewSurface) + } + + override fun onDestroyView() { + super.onDestroyView() + if (mScreenWakeLock != null && mScreenWakeLock!!.isHeld) { + mScreenWakeLock!!.release() + } + mScreenWakeLock = null + if (mSession != null) { + mSession!!.release() + mSession = null + } + presenter.hangupCall() + runnable = null + binding = null + } + + override fun onStop() { + super.onStop() + if (mScreenWakeLock != null && mScreenWakeLock!!.isHeld) { + mScreenWakeLock!!.release() + } + val r = runnable + if (r != null) { + view?.handler?.removeCallbacks(r) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array<String>, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode != REQUEST_PERMISSION_INCOMING && requestCode != REQUEST_PERMISSION_OUTGOING) return + var i = 0 + val n = permissions.size + while (i < n) { + val audioGranted = mDeviceRuntimeService.hasAudioPermission() + val granted = grantResults[i] == PackageManager.PERMISSION_GRANTED + when (permissions[i]) { + Manifest.permission.CAMERA -> { + presenter.cameraPermissionChanged(granted) + if (audioGranted) { + initializeCall(requestCode == REQUEST_PERMISSION_INCOMING) + } + } + Manifest.permission.RECORD_AUDIO -> { + presenter.audioPermissionChanged(granted) + initializeCall(requestCode == REQUEST_PERMISSION_INCOMING) + } + } + i++ + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_CODE_ADD_PARTICIPANT) { + if (resultCode == Activity.RESULT_OK && data != null) { + val path = ConversationPath.fromUri(data.data) + if (path != null) { + presenter.addConferenceParticipant(path.accountId, path.conversationUri) + } + } + } + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + if (!isInPictureInPictureMode) { + mBackstackLost = true + } + presenter.pipModeChanged(isInPictureInPictureMode) + } + + override fun displayContactBubble(display: Boolean) { + binding!!.contactBubbleLayout.visibility = if (display) View.VISIBLE else View.GONE + } + + override fun displayVideoSurface( + displayVideoSurface: Boolean, + displayPreviewContainer: Boolean + ) { + binding!!.videoSurface.visibility = + if (displayVideoSurface) View.VISIBLE else View.GONE + binding!!.previewContainer.visibility = + if (displayPreviewContainer) View.VISIBLE else View.GONE + } + + override fun displayPreviewSurface(display: Boolean) { + if (display) { + binding!!.videoSurface.setZOrderOnTop(false) + //mVideoPreview.setZOrderMediaOverlay(true); + binding!!.videoSurface.setZOrderMediaOverlay(false) + } else { + binding!!.videoSurface.setZOrderMediaOverlay(true) + binding!!.videoSurface.setZOrderOnTop(true) + } + } + + override fun displayHangupButton(display: Boolean) { + binding?.apply { + confControlGroup!!.visibility = if (mConferenceMode && display) View.VISIBLE else View.GONE + if (display) { + callHangupBtn.visibility = View.VISIBLE + callAddBtn.visibility = View.VISIBLE + } else { + callHangupBtn.startAnimation(fadeOutAnimation) + callAddBtn.startAnimation(fadeOutAnimation) + callHangupBtn.visibility = View.GONE + callAddBtn.visibility = View.GONE + } + if (mConferenceMode && display) { + confControlGroup.visibility = View.VISIBLE + } else { + confControlGroup.startAnimation(fadeOutAnimation) + } + } + } + + override fun displayDialPadKeyboard() {} + override fun switchCameraIcon(isFront: Boolean) {} + override fun updateAudioState(state: AudioState) {} + override fun updateMenu() {} + override fun updateTime(duration: Long) { + binding?.callStatusTxt?.text = String.format( + Locale.getDefault(), + "%d:%02d:%02d", + duration / 3600, + duration % 3600 / 60, + duration % 60 + ) + } + + override fun updateContactBubble(calls: List<Call>) { + mConferenceMode = calls.size > 1 + val contact = calls[0].contact!! + val username = if (mConferenceMode) "Conference with " + calls.size + " people" else contact.ringUsername + val displayName = if (mConferenceMode) null else contact.displayName + Log.d(TAG, "updateContactBubble: username=" + username + ", uri=" + contact.uri + " photo:" + contact.photo) + mSession?.setMetadata(MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayName) + .build()) + val hasProfileName = displayName != null && !displayName.contentEquals(username) + if (hasProfileName) { + binding!!.contactBubbleNumTxt.visibility = View.VISIBLE + binding!!.contactBubbleTxt.text = displayName + binding!!.contactBubbleNumTxt.text = username + } else { + binding!!.contactBubbleNumTxt.visibility = View.GONE + binding!!.contactBubbleTxt.text = username + } + binding!!.contactBubble.setImageDrawable( + AvatarDrawable.Builder() + .withContact(contact) + .withCircleCrop(true) + .build(requireActivity()) + ) + + /*if (!mConferenceMode) { + binding.confControlGroup.setVisibility(View.GONE); + } else { + binding.confControlGroup.setVisibility(View.VISIBLE); + if (confAdapter == null) { + confAdapter = new ConfParticipantAdapter((view, call) -> { + Context context = requireContext(); + PopupMenu popup = new PopupMenu(context, view); + popup.inflate(R.menu.conference_participant_actions); + popup.setOnMenuItemClickListener(item -> { + int itemId = item.getItemId(); + if (itemId == R.id.conv_contact_details) { + presenter.openParticipantContact(call); + } else if (itemId == R.id.conv_contact_hangup) { + presenter.hangupParticipant(call); + } else { + return false; + } + return true; + }); + MenuPopupHelper menuHelper = new MenuPopupHelper(context, (MenuBuilder) popup.getMenu(), view); + menuHelper.setForceShowIcon(true); + menuHelper.show(); + }); + } + confAdapter.updateFromCalls(calls); + if (binding.confControlGroup.getAdapter() == null) + binding.confControlGroup.setAdapter(confAdapter); + }*/ + } + + override fun updateCallStatus(callStatus: CallStatus) { + when (callStatus) { + CallStatus.NONE -> binding!!.callStatusTxt.text = "" + else -> binding!!.callStatusTxt.setText(CallFragment.callStateToHumanState(callStatus)) + } + } + + override fun initMenu( + isSpeakerOn: Boolean, displayFlip: Boolean, canDial: Boolean, + showPluginBtn: Boolean, onGoingCall: Boolean + ) { + } + + override fun initNormalStateDisplay(audioOnly: Boolean, muted: Boolean) { + mSession!!.isActive = true + binding?.apply { + shapeRipple.stopRipple() + callAcceptBtn.visibility = View.GONE + callRefuseBtn.visibility = View.GONE + callHangupBtn.visibility = View.VISIBLE + contactBubbleLayout.visibility = if (audioOnly) View.VISIBLE else View.INVISIBLE + } + requireActivity().invalidateOptionsMenu() + handleVisibilityTimer() + } + + override fun initIncomingCallDisplay() { + mSession!!.isActive = true + binding?.apply { + callAcceptBtn.visibility = View.VISIBLE + callAcceptBtn.requestFocus() + callRefuseBtn.visibility = View.VISIBLE + callHangupBtn.visibility = View.GONE + } + } + + override fun initOutGoingCallDisplay() { + binding?.apply { + callAcceptBtn.visibility = View.GONE + callRefuseBtn.visibility = View.VISIBLE + callHangupBtn.visibility = View.GONE + } + } + + override fun resetPreviewVideoSize(previewWidth: Int, previewHeight: Int, rot: Int) { + if (previewWidth == -1 && previewHeight == -1) return + mPreviewWidth = previewWidth + mPreviewHeight = previewHeight + val flip = rot % 180 != 0 + binding!!.previewSurface.setAspectRatio( + if (flip) mPreviewHeight else mPreviewWidth, + if (flip) mPreviewWidth else mPreviewHeight + ) + } + + override fun resetPluginPreviewVideoSize(previewWidth: Int, previewHeight: Int, rot: Int) {} + + override fun resetVideoSize(videoWidth: Int, videoHeight: Int) { + Log.w(TAG, "resetVideoSize " + videoWidth + "x" + videoHeight) + val rootView = view as ViewGroup? ?: return + val videoRatio = videoWidth / videoHeight.toDouble() + val screenRatio = rootView.width / rootView.height.toDouble() + val params = binding!!.videoSurface.layoutParams as RelativeLayout.LayoutParams + val oldW = params.width + val oldH = params.height + if (videoRatio >= screenRatio) { + params.width = RelativeLayout.LayoutParams.MATCH_PARENT + params.height = (videoHeight * rootView.width.toDouble() / videoWidth.toDouble()).toInt() + } else { + params.height = RelativeLayout.LayoutParams.MATCH_PARENT + params.width = (videoWidth * rootView.height.toDouble() / videoHeight.toDouble()).toInt() + } + if (oldW != params.width || oldH != params.height) { + binding!!.videoSurface.layoutParams = params + } + mVideoWidth = videoWidth + mVideoHeight = videoHeight + } + + private fun configureTransform(viewWidth: Int, viewHeight: Int) { + val activity: Activity? = activity + if (null == binding || null == activity) { + return + } + val rotation = activity.windowManager.defaultDisplay.rotation + val rot = Surface.ROTATION_90 == rotation || Surface.ROTATION_270 == rotation + Log.w(TAG, "configureTransform " + viewWidth + "x" + viewHeight + " rot=" + rot + " mPreviewWidth=" + mPreviewWidth + " mPreviewHeight=" + mPreviewHeight) + val matrix = Matrix() + val viewRect = RectF(0f, 0f, viewWidth.toFloat(), viewHeight.toFloat()) + val centerX = viewRect.centerX() + val centerY = viewRect.centerY() + if (rot) { + val bufferRect = RectF(0f, 0f, mPreviewHeightRot.toFloat(), mPreviewWidthRot.toFloat()) + bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY()) + matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL) + val scale = max(viewHeight.toFloat() / mPreviewHeightRot, viewWidth.toFloat() / mPreviewWidthRot) + matrix.postScale(scale, scale, centerX, centerY) + matrix.postRotate((90 * (rotation - 2)).toFloat(), centerX, centerY) + } else if (Surface.ROTATION_180 == rotation) { + matrix.postRotate(180f, centerX, centerY) + } + binding!!.previewSurface.setTransform(matrix) + } + + /** + * Checks if permissions are accepted for camera and microphone. Takes into account whether call is incoming and outgoing, and requests permissions if not available. + * Initializes the call if permissions are accepted. + * + * @param isIncoming true if call is incoming, false for outgoing + * @see .initializeCall + */ + override fun prepareCall(isIncoming: Boolean) { + val audioGranted = mDeviceRuntimeService.hasAudioPermission() + val audioOnly: Boolean + val permissionType: Int + if (isIncoming) { + audioOnly = presenter.isAudioOnly + permissionType = REQUEST_PERMISSION_INCOMING + } else { + audioOnly = requireArguments().getBoolean(CallFragment.KEY_AUDIO_ONLY) + permissionType = REQUEST_PERMISSION_OUTGOING + } + if (!audioOnly) { + val videoGranted = mDeviceRuntimeService.hasVideoPermission() + if ((!audioGranted || !videoGranted) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val perms = ArrayList<String>() + if (!videoGranted) { + perms.add(Manifest.permission.CAMERA) + } + if (!audioGranted) { + perms.add(Manifest.permission.RECORD_AUDIO) + } + requestPermissions(perms.toTypedArray(), permissionType) + } else if (audioGranted && videoGranted) { + initializeCall(isIncoming) + } + } else { + if (!audioGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), permissionType) + } else if (audioGranted) { + initializeCall(isIncoming) + } + } + } + + /** + * Starts a call. Takes into account whether call is incoming or outgoing. + * + * @param isIncoming true if call is incoming, false for outgoing + */ + private fun initializeCall(isIncoming: Boolean) { + Log.w(TAG, "initializeCall $isIncoming") + if (isIncoming) { + presenter.acceptCall() + } else { + arguments?.let { args -> + Log.w(TAG, "initializeCall presenter.initOutGoing") + val conversation = ConversationPath.fromBundle(args)!! + presenter.initOutGoing( + conversation.accountId, + conversation.conversationUri, + args.getString(Intent.EXTRA_PHONE_NUMBER), + args.getBoolean(CallFragment.KEY_AUDIO_ONLY) + ) + } + } + } + + override fun goToContact(accountId: String, contact: Contact) { + startActivity(Intent(Intent.ACTION_VIEW, + android.net.Uri.withAppendedPath( + android.net.Uri.withAppendedPath( + ContentUriHandler.CONTACT_CONTENT_URI, + accountId + ), contact.primaryNumber)) + .setClass(requireContext(), ContactDetailsActivity::class.java) + ) + } + + override fun displayPluginsButton(): Boolean { + return false + } + + override fun updateConfInfo(info: List<ParticipantInfo>) { + val binding = binding!! + binding.participantLabelContainer.removeAllViews() + if (info.isNotEmpty()) { + val inflater = LayoutInflater.from(binding.participantLabelContainer.context) + for (i in info) { + val displayName = i.contact.displayName + if (!TextUtils.isEmpty(displayName)) { + val label = ItemParticipantLabelBinding.inflate(inflater) + val params = PercentFrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + params.percentLayoutInfo.leftMarginPercent = i.x / mVideoWidth.toFloat() + params.percentLayoutInfo.topMarginPercent = i.y / mVideoHeight.toFloat() + label.participantName.text = displayName + binding.participantLabelContainer.addView(label.root, params) + } + } + } + binding.participantLabelContainer.visibility = if (info.isEmpty()) View.GONE else View.VISIBLE + if (!mConferenceMode) { + binding.confControlGroup!!.visibility = View.GONE + } else { + binding.confControlGroup!!.visibility = View.VISIBLE + if (confAdapter == null) { + confAdapter = ConfParticipantAdapter(object : ConfParticipantSelected { + override fun onParticipantSelected(view: View, contact: ParticipantInfo) { + val context = requireContext() + val popup = PopupMenu(context, view) + popup.inflate(R.menu.conference_participant_actions) + popup.setOnMenuItemClickListener { item: MenuItem -> + when (item.itemId) { + R.id.conv_contact_details -> presenter.openParticipantContact(contact) + R.id.conv_contact_hangup -> presenter.hangupParticipant(contact) + else -> return@setOnMenuItemClickListener false + } + true + } + val menuHelper = MenuPopupHelper(context, (popup.menu as MenuBuilder), view) + menuHelper.setForceShowIcon(true) + menuHelper.show() + } + }) + } + confAdapter!!.updateFromCalls(info) + if (binding.confControlGroup.adapter == null) + binding.confControlGroup.adapter = confAdapter + } + } + + override fun updateParticipantRecording(contacts: Set<Contact>) { + binding?.let { binding -> + if (contacts.isEmpty()) { + binding.recordLayout.visibility = View.INVISIBLE + binding.recordIndicator.clearAnimation() + return + } + val names = StringBuilder() + val contact = contacts.iterator() + for (i in contacts.indices) { + names.append(" ").append(contact.next().displayName) + if (i != contacts.size - 1) { + names.append(",") + } + } + binding.recordLayout.visibility = View.VISIBLE + binding.recordIndicator.animation = blinkingAnimation + binding.recordName.text = getString(R.string.remote_recording, names) + } + } + + override fun toggleCallMediaHandler(id: String, callId: String, toggle: Boolean) { + JamiService.toggleCallMediaHandler(id, callId, toggle) + } + + override fun goToConversation(accountId: String, conversationId: Uri) {} + override fun goToAddContact(contact: Contact) { + startActivityForResult(ActionHelper.getAddNumberIntentForContact(contact), ConversationFragment.REQ_ADD_CONTACT) + } + + override fun startAddParticipant(conferenceId: String) { + startActivityForResult(Intent(Intent.ACTION_PICK) + .setClass(requireActivity(), ConversationSelectionActivity::class.java) + .putExtra(CallFragment.KEY_CONF_ID, conferenceId), + REQUEST_CODE_ADD_PARTICIPANT) + } + + fun addParticipant() { + presenter.startAddParticipant() + } + + fun hangUpClicked() { + presenter.hangupCall() + } + + fun refuseClicked() { + presenter.refuseCall() + } + + fun acceptClicked() { + prepareCall(true) + } + + override fun finish() { + mSession?.isActive = false + activity?.let { activity -> + if (mBackstackLost) { + activity.finishAndRemoveTask() + startActivity(Intent.makeMainActivity(ComponentName(activity, HomeActivity::class.java)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } else { + activity.finish() + } + } + } + + override fun onUserLeave() { + presenter.requestPipMode() + } + + override fun enterPipMode(callId: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val paramBuilder = PictureInPictureParams.Builder() + if (binding!!.videoSurface.visibility == View.VISIBLE) { + val l = IntArray(2) + binding!!.videoSurface.getLocationInWindow(l) + val x = l[0] + val y = l[1] + val w = binding!!.videoSurface.width + val h = binding!!.videoSurface.height + val videoBounds = Rect(x, y, x + w, y + h) + paramBuilder.setAspectRatio(Rational(w, h)) + paramBuilder.setSourceRectHint(videoBounds) + } + requireActivity().enterPictureInPictureMode(paramBuilder.build()) + } else { + requireActivity().enterPictureInPictureMode() + } + } + + fun onKeyDown() { + handleVisibilityTimer() + } + + private fun handleVisibilityTimer() { + presenter.uiVisibilityChanged(true) + val view = view + val r = runnable + if (view != null && r != null) { + val handler = view.handler + if (handler != null) { + handler.removeCallbacks(r) + handler.postDelayed(r, 5000) + } + } + } + + companion object { + private val TAG = TVCallFragment::class.simpleName!! + private const val REQUEST_CODE_ADD_PARTICIPANT = 6 + private const val REQUEST_PERMISSION_INCOMING = 1003 + private const val REQUEST_PERMISSION_OUTGOING = 1004 + + fun newInstance(action: String, accountId: String, conversationId: String, contactUri: String, audioOnly: Boolean): TVCallFragment { + return TVCallFragment().apply { arguments = Bundle().apply { + putString(CallFragment.KEY_ACTION, action) + putAll(ConversationPath.toBundle(accountId, conversationId)) + putString(Intent.EXTRA_PHONE_NUMBER, contactUri) + putBoolean(CallFragment.KEY_AUDIO_ONLY, audioOnly) + }} + } + + fun newInstance(action: String, confId: String?): TVCallFragment { + return TVCallFragment().apply { arguments = Bundle().apply { + putString(CallFragment.KEY_ACTION, action) + putString(CallFragment.KEY_CONF_ID, confId) + }} + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/camera/CustomCameraActivity.java b/ring-android/app/src/main/java/cx/ring/tv/camera/CustomCameraActivity.java deleted file mode 100644 index 2c503874d..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/camera/CustomCameraActivity.java +++ /dev/null @@ -1,290 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Loïc Siret <loic.siret@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ - -package cx.ring.tv.camera; - -import android.animation.Animator; -import android.app.Activity; -import android.content.Intent; -import android.hardware.Camera; -import android.media.CamcorderProfile; -import android.media.MediaRecorder; -import android.os.Build; -import android.os.Bundle; -import android.provider.MediaStore; -import android.util.Log; -import android.view.View; -import android.view.ViewAnimationUtils; -import android.widget.Toast; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; - -import cx.ring.R; -import cx.ring.databinding.CamerapickerBinding; -import cx.ring.utils.AndroidFileUtils; -import cx.ring.utils.ContentUriHandler; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -@SuppressWarnings("deprecation") -public class CustomCameraActivity extends Activity { - private static final String TAG = "CustomCameraActivity"; - public static final String TYPE_IMAGE = "image/jpeg"; - public static final String TYPE_VIDEO = "video"; - - private CamerapickerBinding binding; - private final CompositeDisposable mDisposableBag = new CompositeDisposable(); - - private int cameraFront = -1; - private int cameraBack = -1; - private int currentCamera = 0; - - private MediaRecorder recorder; - private boolean mRecording = false; - private boolean mActionVideo = false; - - private File mVideoFile; - - private Camera mCamera; - private CameraPreview mCameraPreview; - private final Camera.PictureCallback mPicture = (input, camera) -> mDisposableBag.add(Single.fromCallable(() -> { - if (mCameraPreview != null) - mCameraPreview.stop(); - File file = AndroidFileUtils.createImageFile(this); - try (OutputStream out = new FileOutputStream(file)) { - out.write(input); - out.flush(); - } - return ContentUriHandler.getUriForFile(this, ContentUriHandler.AUTHORITY_FILES, file); - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(uri -> { - setResult(RESULT_OK, new Intent() - .putExtra(MediaStore.EXTRA_OUTPUT, uri) - .setType(TYPE_IMAGE)); - finish(); - }, e -> { - Log.e(TAG, "Error saving picture", e); - setResult(RESULT_CANCELED); - finish(); - })); - - public void takePicture() { - if (mRecording) - releaseMediaRecorder(); - if (mCamera != null) { - binding.buttonPicture.setEnabled(false); - binding.buttonVideo.setVisibility(View.GONE); - try { - mCamera.takePicture(null, null, mPicture); - } catch (Exception e) { - Toast.makeText(this, "Error taking picture", Toast.LENGTH_LONG).show(); - finish(); - } - } - } - - public void takeVideo() { - if (mRecording) { - releaseMediaRecorder(); - mCameraPreview.stop(); - Intent intent = new Intent() - .putExtra(MediaStore.EXTRA_OUTPUT, ContentUriHandler.getUriForFile(this, ContentUriHandler.AUTHORITY_FILES, mVideoFile)) - .setType(TYPE_VIDEO); - setResult(RESULT_OK, intent); - binding.buttonVideo.setImageResource(R.drawable.baseline_videocam_24); - finish(); - } else { - if (mCamera != null) { - initRecorder(); - binding.buttonVideo.setImageResource(R.drawable.lb_ic_stop); - binding.buttonPicture.setVisibility(View.GONE); - } - } - mRecording = !mRecording; - } - - /** - * Called when the activity is first created. - */ - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = CamerapickerBinding.inflate(getLayoutInflater()); - - if (getIntent().getAction() != null) { - mActionVideo = getIntent().getAction().equals(MediaStore.ACTION_VIDEO_CAPTURE); - } - - binding.buttonVideo.setEnabled(false); - binding.buttonPicture.setEnabled(false); - if (mActionVideo) { - binding.buttonVideo.setVisibility(View.VISIBLE); - } - setContentView(binding.getRoot()); - } - - @Override - protected void onStart() { - super.onStart(); - mDisposableBag.add(Single.fromCallable(this::getCameraInstance) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(camera -> { - if (binding == null) { - camera.release(); - } else { - mCamera = camera; - mCameraPreview = new CameraPreview(this, mCamera); - binding.cameraPreview.addView(mCameraPreview, 0); - binding.buttonVideo.setEnabled(true); - binding.buttonPicture.setEnabled(true); - binding.buttonPicture.setOnClickListener(v -> takePicture()); - binding.buttonVideo.setOnClickListener(v -> takeVideo()); - - int endRadius = Math.max(binding.getRoot().getWidth(), binding.getRoot().getHeight()); - int x = binding.getRoot().getWidth()/2; - int y = binding.getRoot().getHeight()/2; - - if (binding.loadClip.getVisibility() == View.VISIBLE) { - Animator anim = ViewAnimationUtils.createCircularReveal(binding.loadClip, x, y, endRadius, 0); - anim.addListener(new Animator.AnimatorListener() { - @Override - public void onAnimationStart(Animator animator) { - } - - @Override - public void onAnimationEnd(Animator animator) { - binding.loadClip.setVisibility(View.GONE); - } - - @Override - public void onAnimationCancel(Animator animator) { - } - - @Override - public void onAnimationRepeat(Animator animator) { - } - }); - anim.setDuration(600); - anim.setStartDelay(50); - anim.start(); - } - } - }, e -> { - Toast.makeText(this, "Can't open camera", Toast.LENGTH_LONG).show(); - finish(); - })); - } - - @Override - protected void onStop() { - super.onStop(); - if (mCameraPreview != null) { - mCameraPreview.stop(); - mCameraPreview = null; - } - } - - @Override - protected void onDestroy() { - super.onDestroy(); - mDisposableBag.dispose(); - binding = null; - if (mCamera != null) { - mCamera.release(); - mCamera = null; - } - } - - public void initVideo() { - int numberCameras = Camera.getNumberOfCameras(); - if (numberCameras == 0) - return; - Camera.CameraInfo camInfo = new Camera.CameraInfo(); - for (int i = 0; i < numberCameras; i++) { - Camera.getCameraInfo(i, camInfo); - if (camInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { - cameraFront = i; - } else { - cameraBack = i; - } - } - currentCamera = cameraFront == -1 ? cameraBack : cameraFront; - } - - /** - * Helper method to access the camera returns null if it cannot get the - * camera or does not exist - */ - private Camera getCameraInstance() { - initVideo(); - return Camera.open(currentCamera); - } - - private void initRecorder() { - int videoWidth = mCamera.getParameters().getPreviewSize().width; - int videoHeight = mCamera.getParameters().getPreviewSize().height; - mCamera.unlock(); - recorder = new MediaRecorder(); - recorder.setCamera(mCamera); - recorder.setAudioSource(MediaRecorder.AudioSource.MIC); - recorder.setVideoSource(MediaRecorder.VideoSource.DEFAULT); - recorder.setProfile(CamcorderProfile.get(currentCamera, CamcorderProfile.QUALITY_HIGH)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - try { - mVideoFile = AndroidFileUtils.createVideoFile(this); - } catch (IOException e) { - e.printStackTrace(); - } - recorder.setOutputFile(mVideoFile); - } - recorder.setVideoSize(videoWidth, videoHeight); - - prepareRecorder(); - } - - private void prepareRecorder() { - recorder.setPreviewDisplay(mCameraPreview.getHolder().getSurface()); - try { - recorder.prepare(); - recorder.start(); - } catch (Exception e) { - Toast.makeText(this, "Error starting the recorder: " + e.getLocalizedMessage(), Toast.LENGTH_LONG).show(); - finish(); - } - } - - private void releaseMediaRecorder() { - if (recorder != null) { - recorder.reset(); - recorder.release(); - recorder = null; - } - } -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/camera/CustomCameraActivity.kt b/ring-android/app/src/main/java/cx/ring/tv/camera/CustomCameraActivity.kt new file mode 100644 index 000000000..a18724d0d --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/camera/CustomCameraActivity.kt @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Loïc Siret <loic.siret@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.camera + +import android.animation.Animator +import android.app.Activity +import android.media.MediaRecorder +import android.hardware.Camera.PictureCallback +import cx.ring.utils.ContentUriHandler +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import android.content.Intent +import android.hardware.Camera +import android.provider.MediaStore +import android.widget.Toast +import cx.ring.R +import android.os.Bundle +import android.view.ViewAnimationUtils +import android.hardware.Camera.CameraInfo +import android.media.CamcorderProfile +import android.net.Uri +import android.os.Build +import android.util.Log +import android.view.View +import cx.ring.databinding.CamerapickerBinding +import cx.ring.utils.AndroidFileUtils +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.lang.Exception +import kotlin.math.max + +class CustomCameraActivity : Activity() { + private var binding: CamerapickerBinding? = null + private val mDisposableBag = CompositeDisposable() + private var cameraFront = -1 + private var cameraBack = -1 + private var currentCamera = 0 + private var recorder: MediaRecorder? = null + private var mRecording = false + private var mActionVideo = false + private var mVideoFile: File? = null + private var mCamera: Camera? = null + private var mCameraPreview: CameraPreview? = null + + private val mPicture = PictureCallback { input: ByteArray, camera -> + mDisposableBag.add( + Single.fromCallable { + if (mCameraPreview != null) mCameraPreview!!.stop() + val file = AndroidFileUtils.createImageFile(this) + FileOutputStream(file).use { out -> + out.write(input) + out.flush() + } + ContentUriHandler.getUriForFile(this, ContentUriHandler.AUTHORITY_FILES, file) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ uri: Uri -> + setResult(RESULT_OK, Intent() + .putExtra(MediaStore.EXTRA_OUTPUT, uri) + .setType(TYPE_IMAGE)) + finish() + }) { e: Throwable -> + Log.e(TAG, "Error saving picture", e) + setResult(RESULT_CANCELED) + finish() + }) + } + + private fun takePicture() { + if (mRecording) releaseMediaRecorder() + if (mCamera != null) { + binding!!.buttonPicture.isEnabled = false + binding!!.buttonVideo.visibility = View.GONE + try { + mCamera!!.takePicture(null, null, mPicture) + } catch (e: Exception) { + Toast.makeText(this, "Error taking picture", Toast.LENGTH_LONG).show() + finish() + } + } + } + + private fun takeVideo() { + if (mRecording) { + releaseMediaRecorder() + mCameraPreview!!.stop() + val intent = Intent() + .putExtra(MediaStore.EXTRA_OUTPUT, ContentUriHandler.getUriForFile(this, ContentUriHandler.AUTHORITY_FILES, mVideoFile!!)) + .setType(TYPE_VIDEO) + setResult(RESULT_OK, intent) + binding!!.buttonVideo.setImageResource(R.drawable.baseline_videocam_24) + finish() + } else { + if (mCamera != null) { + initRecorder() + binding!!.buttonVideo.setImageResource(R.drawable.lb_ic_stop) + binding!!.buttonPicture.visibility = View.GONE + } + } + mRecording = !mRecording + } + + /** + * Called when the activity is first created. + */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = CamerapickerBinding.inflate(layoutInflater) + if (intent.action != null) { + mActionVideo = intent.action == MediaStore.ACTION_VIDEO_CAPTURE + } + binding!!.buttonVideo.isEnabled = false + binding!!.buttonPicture.isEnabled = false + if (mActionVideo) { + binding!!.buttonVideo.visibility = View.VISIBLE + } + setContentView(binding!!.root) + } + + override fun onStart() { + super.onStart() + mDisposableBag.add(Single.fromCallable { cameraInstance } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ camera -> + if (binding == null) { + camera.release() + } else { + mCamera = camera + mCameraPreview = CameraPreview(this, camera) + binding!!.cameraPreview.addView(mCameraPreview, 0) + binding!!.buttonVideo.isEnabled = true + binding!!.buttonPicture.isEnabled = true + binding!!.buttonPicture.setOnClickListener { takePicture() } + binding!!.buttonVideo.setOnClickListener { takeVideo() } + val endRadius = max(binding!!.root.width, binding!!.root.height) + val x = binding!!.root.width / 2 + val y = binding!!.root.height / 2 + if (binding!!.loadClip.visibility == View.VISIBLE) { + val anim = ViewAnimationUtils.createCircularReveal( + binding!!.loadClip, x, y, endRadius.toFloat(), 0f + ) + anim.addListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animator: Animator) {} + override fun onAnimationEnd(animator: Animator) { + binding!!.loadClip.visibility = View.GONE + } + + override fun onAnimationCancel(animator: Animator) {} + override fun onAnimationRepeat(animator: Animator) {} + }) + anim.duration = 600 + anim.startDelay = 50 + anim.start() + } + } + }) { e: Throwable -> + Toast.makeText(this, "Can't open camera", Toast.LENGTH_LONG).show() + finish() + }) + } + + override fun onStop() { + super.onStop() + if (mCameraPreview != null) { + mCameraPreview!!.stop() + mCameraPreview = null + } + } + + override fun onDestroy() { + super.onDestroy() + mDisposableBag.dispose() + binding = null + mCamera?.apply { + release() + mCamera = null + } + } + + private fun initVideo() { + val numberCameras = Camera.getNumberOfCameras() + if (numberCameras == 0) return + val camInfo = CameraInfo() + for (i in 0 until numberCameras) { + Camera.getCameraInfo(i, camInfo) + if (camInfo.facing == CameraInfo.CAMERA_FACING_FRONT) { + cameraFront = i + } else { + cameraBack = i + } + } + currentCamera = if (cameraFront == -1) cameraBack else cameraFront + } + + /** + * Helper method to access the camera returns null if it cannot get the + * camera or does not exist + */ + private val cameraInstance: Camera + get() { + initVideo() + return Camera.open(currentCamera) + } + + private fun initRecorder() { + val videoWidth = mCamera!!.parameters.previewSize.width + val videoHeight = mCamera!!.parameters.previewSize.height + mCamera?.unlock() + recorder = MediaRecorder().apply { + setCamera(mCamera) + setAudioSource(MediaRecorder.AudioSource.MIC) + setVideoSource(MediaRecorder.VideoSource.DEFAULT) + setProfile(CamcorderProfile.get(currentCamera, CamcorderProfile.QUALITY_HIGH)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + mVideoFile = AndroidFileUtils.createVideoFile(this@CustomCameraActivity) + } catch (e: IOException) { + e.printStackTrace() + } + setOutputFile(mVideoFile) + } + setVideoSize(videoWidth, videoHeight) + } + prepareRecorder() + } + + private fun prepareRecorder() { + recorder!!.setPreviewDisplay(mCameraPreview!!.holder.surface) + try { + recorder!!.prepare() + recorder!!.start() + } catch (e: Exception) { + Toast.makeText(this, "Error starting the recorder: " + e.localizedMessage, Toast.LENGTH_LONG).show() + finish() + } + } + + private fun releaseMediaRecorder() { + recorder?.apply { + reset() + release() + recorder = null + } + } + + companion object { + private const val TAG = "CustomCameraActivity" + const val TYPE_IMAGE = "image/jpeg" + const val TYPE_VIDEO = "video" + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/cards/Card.java b/ring-android/app/src/main/java/cx/ring/tv/cards/Card.java index e2b38635e..86c40d356 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/cards/Card.java +++ b/ring-android/app/src/main/java/cx/ring/tv/cards/Card.java @@ -136,20 +136,17 @@ public class Card { } public enum Type { - ABOUT_VERSION, DEFAULT, SEARCH_RESULT, - ABOUT_CONTRIBUTOR, ACCOUNT_ADD_DEVICE, ACCOUNT_EDIT_PROFILE, ACCOUNT_SHARE_ACCOUNT, - ABOUT_LICENCES, + ADD_CONTACT, CONTACT, CONTACT_ONLINE, CONTACT_WITH_USERNAME, CONTACT_WITH_USERNAME_ONLINE, CONTACT_REQUEST, - ACCOUNT_SETTINGS, CONTACT_REQUEST_WITH_USERNAME } diff --git a/ring-android/app/src/main/java/cx/ring/tv/cards/CardPresenterSelector.java b/ring-android/app/src/main/java/cx/ring/tv/cards/CardPresenterSelector.java index 14e21dd04..cfdc82d71 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/cards/CardPresenterSelector.java +++ b/ring-android/app/src/main/java/cx/ring/tv/cards/CardPresenterSelector.java @@ -46,18 +46,13 @@ public class CardPresenterSelector extends PresenterSelector { Presenter presenter = presenters.get(card.getType()); if (presenter == null) { switch (card.getType()) { - case ABOUT_VERSION: - case ABOUT_CONTRIBUTOR: - case ABOUT_LICENCES: case ACCOUNT_ADD_DEVICE: case ACCOUNT_EDIT_PROFILE: case ACCOUNT_SHARE_ACCOUNT: - case ACCOUNT_SETTINGS: + case ADD_CONTACT: presenter = new IconCardPresenter(mContext); break; case SEARCH_RESULT: - presenter = new ContactCardPresenter(mContext, R.style.SearchCardTheme); - break; case CONTACT: presenter = new ContactCardPresenter(mContext, R.style.ContactCardTheme); break; diff --git a/ring-android/app/src/main/java/cx/ring/tv/cards/CardView.java b/ring-android/app/src/main/java/cx/ring/tv/cards/CardView.java new file mode 100644 index 000000000..0bd5f0463 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/cards/CardView.java @@ -0,0 +1,313 @@ +package cx.ring.tv.cards; + +import android.animation.ObjectAnimator; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.ContextThemeWrapper; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.core.content.res.ResourcesCompat; +import androidx.core.view.ViewCompat; +import androidx.leanback.widget.BaseCardView; + +import androidx.leanback.R; + +public class CardView extends BaseCardView { + + public static final int CARD_TYPE_FLAG_IMAGE_ONLY = 0; + public static final int CARD_TYPE_FLAG_TITLE = 1; + public static final int CARD_TYPE_FLAG_CONTENT = 2; + public static final int CARD_TYPE_FLAG_ICON_RIGHT = 3; + + private static final int CARD_HEIGHT = 290; + private static final String ALPHA = "alpha"; + + private ImageView mImageView; + private ViewGroup mInfoArea; + private TextView mTitleView; + private TextView mContentView; + private ImageView mBadgeImage; + private boolean mAttachedToWindow; + private ObjectAnimator mFadeInAnimator; + + @Deprecated + public CardView(Context context, int themeResId) { + this(new ContextThemeWrapper(context, themeResId)); + } + + public CardView(Context context) { + this(context, null); + } + + public CardView(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.imageCardViewStyle); + } + + public CardView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + buildImageCardView(attrs, defStyleAttr, R.style.Widget_Leanback_ImageCardView); + } + + @SuppressLint("CustomViewStyleable") + private void buildImageCardView(AttributeSet attrs, int defStyleAttr, int defStyle) { + // Make sure the ImageCardView is focusable. + setFocusable(true); + setFocusableInTouchMode(true); + setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, CARD_HEIGHT)); + + LayoutInflater inflater = LayoutInflater.from(getContext()); + inflater.inflate(R.layout.lb_image_card_view, this); + TypedArray cardAttrs = getContext().obtainStyledAttributes(attrs, R.styleable.lbImageCardView, defStyleAttr, defStyle); + ViewCompat.saveAttributeDataForStyleable(this, getContext(), R.styleable.lbImageCardView, attrs, cardAttrs, defStyleAttr, defStyle); + int cardType = cardAttrs.getInt(R.styleable.lbImageCardView_lbImageCardViewType, CARD_TYPE_FLAG_IMAGE_ONLY); + + boolean hasImageOnly = cardType == CARD_TYPE_FLAG_IMAGE_ONLY; + boolean hasTitle = (cardType & CARD_TYPE_FLAG_TITLE) == CARD_TYPE_FLAG_TITLE; + boolean hasContent = (cardType & CARD_TYPE_FLAG_CONTENT) == CARD_TYPE_FLAG_CONTENT; + boolean hasIconRight = (cardType & CARD_TYPE_FLAG_ICON_RIGHT) == CARD_TYPE_FLAG_ICON_RIGHT; + + mImageView = findViewById(R.id.main_image); + if (mImageView.getDrawable() == null) { + mImageView.setVisibility(View.INVISIBLE); + } + + // Set Object Animator for image view. + mFadeInAnimator = ObjectAnimator.ofFloat(mImageView, ALPHA, 1f); + mFadeInAnimator.setDuration(mImageView.getResources().getInteger(android.R.integer.config_shortAnimTime)); + + mInfoArea = findViewById(R.id.info_field); + + Typeface mulishBold = ResourcesCompat.getFont(getContext(), cx.ring.R.font.mulish_semibold); + Typeface mulishRegular = ResourcesCompat.getFont(getContext(), cx.ring.R.font.mulish_regular); + + if (hasImageOnly) { + removeView(mInfoArea); + cardAttrs.recycle(); + return; + } + + if (hasTitle) { + mTitleView = (TextView) inflater.inflate(R.layout.lb_image_card_view_themed_title, mInfoArea, false); + mTitleView.setTextSize(12); + mTitleView.setTypeface(mulishBold); + mInfoArea.addView(mTitleView); + } + + if (hasContent) { + mContentView = (TextView) inflater.inflate(R.layout.lb_image_card_view_themed_content, mInfoArea, false); + mContentView.setTextSize(10); + mContentView.setTypeface(mulishRegular); + mContentView.setTextColor(getResources().getColor(cx.ring.R.color.white)); + mInfoArea.addView(mContentView, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + } + + if (hasIconRight) { + int layoutId = R.layout.lb_image_card_view_themed_badge_right; + mBadgeImage = (ImageView) inflater.inflate(layoutId, mInfoArea, false); + mInfoArea.addView(mBadgeImage); + } + + // Set up LayoutParams for children + if (mBadgeImage != null) { + RelativeLayout.LayoutParams relativeLayoutParams = + new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + if (hasTitle) { + relativeLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); + relativeLayoutParams.addRule(RelativeLayout.ALIGN_TOP, mTitleView.getId()); + relativeLayoutParams.addRule(RelativeLayout.ALIGN_BOTTOM, mTitleView.getId()); + relativeLayoutParams.setMargins(0,5,0,0); + } + mBadgeImage.setLayoutParams(relativeLayoutParams); + } + + if (hasTitle && mBadgeImage != null) { + RelativeLayout.LayoutParams relativeLayoutParams = + (RelativeLayout.LayoutParams) mTitleView.getLayoutParams(); + relativeLayoutParams.addRule(RelativeLayout.START_OF, mBadgeImage.getId()); + relativeLayoutParams.addRule(RelativeLayout.LEFT_OF, mBadgeImage.getId()); + mTitleView.setLayoutParams(relativeLayoutParams); + } + + if (hasContent) { + RelativeLayout.LayoutParams relativeLayoutParams = (RelativeLayout.LayoutParams) mContentView.getLayoutParams(); + if (!hasTitle) { + relativeLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); + } else { + relativeLayoutParams.addRule(RelativeLayout.BELOW, mTitleView.getId()); + } + mContentView.setLayoutParams(relativeLayoutParams); + } + + cardAttrs.recycle(); + } + + public final ImageView getMainImageView() { + return mImageView; + } + + public void setMainImageAdjustViewBounds(boolean adjustViewBounds) { + if (mImageView != null) { + mImageView.setAdjustViewBounds(adjustViewBounds); + } + } + + public void setMainImage(Drawable drawable) { + setMainImage(drawable, true); + } + + public void setMainImage(Drawable drawable, boolean fade) { + if (mImageView == null) { + return; + } + + mImageView.setImageDrawable(drawable); + if (drawable == null) { + mFadeInAnimator.cancel(); + mImageView.setAlpha(1f); + mImageView.setVisibility(View.INVISIBLE); + } else { + mImageView.setVisibility(View.VISIBLE); + if (fade) { + fadeIn(); + } else { + mFadeInAnimator.cancel(); + mImageView.setAlpha(1f); + } + } + } + + public void setMainImageDimensions(int width, int height) { + ViewGroup.LayoutParams lp = mImageView.getLayoutParams(); + lp.width = width; + lp.height = height; + mImageView.setLayoutParams(lp); + } + + public Drawable getMainImage() { + if (mImageView == null) { + return null; + } + + return mImageView.getDrawable(); + } + + public Drawable getInfoAreaBackground() { + if (mInfoArea != null) { + return mInfoArea.getBackground(); + } + return null; + } + + public void setInfoAreaBackground(Drawable drawable) { + if (mInfoArea != null) { + mInfoArea.setBackground(drawable); + } + } + + public void setInfoAreaBackgroundColor(@ColorInt int color) { + if (mInfoArea != null) { + mInfoArea.setBackgroundColor(color); + } + } + + public void setTitleText(CharSequence text) { + if (mTitleView == null) { + return; + } + mTitleView.setText(text); + } + + public CharSequence getTitleText() { + if (mTitleView == null) { + return null; + } + + return mTitleView.getText(); + } + + public TextView getTitleTextView() { + return mTitleView; + } + + public void setContentText(CharSequence text) { + if (mContentView == null) { + return; + } + mContentView.setText(text); + } + + public CharSequence getContentText() { + if (mContentView == null) { + return null; + } + + return mContentView.getText(); + } + + public void setBadgeImage(Drawable drawable) { + if (mBadgeImage == null) { + return; + } + mBadgeImage.setImageDrawable(drawable); + if (drawable != null) { + mBadgeImage.setVisibility(View.VISIBLE); + } else { + mBadgeImage.setVisibility(View.GONE); + } + } + + public Drawable getBadgeImage() { + if (mBadgeImage == null) { + return null; + } + + return mBadgeImage.getDrawable(); + } + + public void setTitleSingleLine(boolean singleLine) { + mTitleView.setSingleLine(singleLine); + mTitleView.setEllipsize(TextUtils.TruncateAt.END); + } + + private void fadeIn() { + mImageView.setAlpha(0f); + if (mAttachedToWindow) { + mFadeInAnimator.start(); + } + } + + @Override + public boolean hasOverlappingRendering() { + return false; + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mAttachedToWindow = true; + if (mImageView.getAlpha() == 0) { + fadeIn(); + } + } + + @Override + protected void onDetachedFromWindow() { + mAttachedToWindow = false; + mFadeInAnimator.cancel(); + mImageView.setAlpha(1f); + super.onDetachedFromWindow(); + } + +} diff --git a/ring-android/app/src/main/java/cx/ring/tv/cards/ShadowRowPresenterSelector.java b/ring-android/app/src/main/java/cx/ring/tv/cards/ShadowRowPresenterSelector.java index e968c731b..fcbdfcc40 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/cards/ShadowRowPresenterSelector.java +++ b/ring-android/app/src/main/java/cx/ring/tv/cards/ShadowRowPresenterSelector.java @@ -14,9 +14,17 @@ */ package cx.ring.tv.cards; +import android.content.Context; +import android.view.ViewGroup; + +import androidx.core.content.res.ResourcesCompat; import androidx.leanback.widget.ListRowPresenter; import androidx.leanback.widget.Presenter; import androidx.leanback.widget.PresenterSelector; +import androidx.leanback.widget.RowHeaderPresenter; +import androidx.leanback.widget.RowHeaderView; + +import cx.ring.R; /** * This {@link PresenterSelector} will return a {@link ListRowPresenter} which has shadow support @@ -24,8 +32,8 @@ import androidx.leanback.widget.PresenterSelector; */ public class ShadowRowPresenterSelector extends PresenterSelector { - private ListRowPresenter mShadowEnabledRowPresenter = new ListRowPresenter(); - private ListRowPresenter mShadowDisabledRowPresenter = new NoDimListRowPresenter(); + private CustomListRowPresenter mShadowEnabledRowPresenter = new CustomListRowPresenter(); + private CustomDimListRowPresenter mShadowDisabledRowPresenter = new CustomDimListRowPresenter(); public ShadowRowPresenterSelector() { mShadowEnabledRowPresenter.setNumRows(1); @@ -48,4 +56,30 @@ public class ShadowRowPresenterSelector extends PresenterSelector { mShadowEnabledRowPresenter }; } + + private static class CustomListRowPresenter extends ListRowPresenter { + public CustomListRowPresenter() { + super(); + setHeaderPresenter(new CustomRowHeaderPresenter()); + } + } + + private static class CustomDimListRowPresenter extends NoDimListRowPresenter { + public CustomDimListRowPresenter() { + super(); + setHeaderPresenter(new CustomRowHeaderPresenter()); + } + } + + private static class CustomRowHeaderPresenter extends RowHeaderPresenter { + @Override + public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) { + super.onBindViewHolder(viewHolder, item); + RowHeaderView titleView = viewHolder.view.findViewById(R.id.row_header); + titleView.setTypeface(ResourcesCompat.getFont(titleView.getContext(), R.font.ubuntu_medium)); + titleView.setTextSize(16); + viewHolder.view.setAlpha(1); + } + } + } diff --git a/ring-android/app/src/main/java/cx/ring/tv/cards/contacts/ContactCardPresenter.java b/ring-android/app/src/main/java/cx/ring/tv/cards/contacts/ContactCardPresenter.java index cc75834a1..c0d4e0336 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/cards/contacts/ContactCardPresenter.java +++ b/ring-android/app/src/main/java/cx/ring/tv/cards/contacts/ContactCardPresenter.java @@ -21,17 +21,17 @@ package cx.ring.tv.cards.contacts; import android.content.Context; -import androidx.leanback.widget.ImageCardView; import android.view.ContextThemeWrapper; -import cx.ring.R; - import net.jami.smartlist.SmartListViewModel; + +import cx.ring.R; import cx.ring.tv.cards.AbstractCardPresenter; import cx.ring.tv.cards.Card; +import cx.ring.tv.cards.CardView; import cx.ring.views.AvatarDrawable; -public class ContactCardPresenter extends AbstractCardPresenter<ImageCardView> { +public class ContactCardPresenter extends AbstractCardPresenter<CardView> { private static final String TAG = ContactCardPresenter.class.getSimpleName(); @@ -40,32 +40,21 @@ public class ContactCardPresenter extends AbstractCardPresenter<ImageCardView> { } @Override - protected ImageCardView onCreateView() { - return new ImageCardView(getContext()); + protected CardView onCreateView() { + return new CardView(getContext()); } @Override - public void onBindViewHolder(Card card, ImageCardView cardView) { + public void onBindViewHolder(Card card, CardView cardView) { ContactCard contact = (ContactCard) card; SmartListViewModel model = contact.getModel(); - /*String username = model.getContact().getUsername(); - - if (username == null) { - username = model.getUri().getRawUriString(); - } - - if (username != null && (username.isEmpty() || username.equals(model.getContactName()))) { - cardView.setTitleText(username); - cardView.setContentText(""); - } else { - cardView.setTitleText(model.getContactName()); - cardView.setContentText(username); - }*/ cardView.setTitleText(card.getTitle()); cardView.setContentText(card.getDescription()); + cardView.setTitleSingleLine(true); + cardView.setBackgroundColor(getContext().getResources().getColor(R.color.tv_transparent)); + cardView.setInfoAreaBackgroundColor(getContext().getResources().getColor(R.color.transparent)); - cardView.setBackgroundColor(cardView.getResources().getColor(R.color.color_primary_dark)); cardView.setMainImage( new AvatarDrawable.Builder() .withViewModel(model) diff --git a/ring-android/app/src/main/java/cx/ring/tv/cards/iconcards/IconCardHelper.java b/ring-android/app/src/main/java/cx/ring/tv/cards/iconcards/IconCardHelper.java index 3952a18ba..0bfbbfb65 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/cards/iconcards/IconCardHelper.java +++ b/ring-android/app/src/main/java/cx/ring/tv/cards/iconcards/IconCardHelper.java @@ -38,18 +38,10 @@ public final class IconCardHelper { public static IconCard getAboutCardByType(Context pContext, Card.Type type) { switch (type) { - case ABOUT_CONTRIBUTOR: - return getContributorCard(pContext); - case ABOUT_LICENCES: - return getLicencesCard(pContext); - case ABOUT_VERSION: - return getVersionCard(pContext); case ACCOUNT_ADD_DEVICE: return getAccountAddDeviceCard(pContext); case ACCOUNT_EDIT_PROFILE: return getAccountManagementCard(pContext); - case ACCOUNT_SETTINGS: - return getAccountSettingsCard(pContext); case ACCOUNT_SHARE_ACCOUNT: return getAccountShareCard(pContext, null); default: @@ -58,60 +50,19 @@ public final class IconCardHelper { } public static IconCard getAccountAddDeviceCard(Context pContext) { - return new IconCard(Card.Type.ACCOUNT_ADD_DEVICE, pContext.getString(R.string.account_link_export_button), "", R.drawable.baseline_add_24); + return new IconCard(Card.Type.ACCOUNT_ADD_DEVICE, pContext.getString(R.string.account_export_title), "", R.drawable.baseline_androidtv_link_device); } public static IconCard getAccountManagementCard(Context pContext) { - return new IconCard(Card.Type.ACCOUNT_EDIT_PROFILE, pContext.getString(R.string.account_edit_profile), "", R.drawable.baseline_account_card_details); - } - - public static IconCard getAccountSettingsCard(Context pContext) { - return new IconCard(Card.Type.ACCOUNT_SETTINGS, pContext.getString(R.string.menu_item_settings), "", R.drawable.baseline_settings_24); + return new IconCard(Card.Type.ACCOUNT_EDIT_PROFILE, pContext.getString(R.string.account_edit_profile), "", R.drawable.baseline_androidtv_account); } public static IconCard getAccountShareCard(Context pContext, BitmapDrawable bitmapDrawable) { return new IconCard(Card.Type.ACCOUNT_SHARE_ACCOUNT, pContext.getString(R.string.menu_item_share), "", bitmapDrawable); } - public static IconCard getVersionCard(Context pContext) { - return new IconCard(Card.Type.ABOUT_VERSION, pContext.getString(R.string.version_section) + " " + BuildConfig.VERSION_NAME, "", R.drawable.ic_ring_logo_white_vd); - } - - public static IconCard getLicencesCard(Context pContext) { - return new IconCard(Card.Type.ABOUT_LICENCES, pContext.getString(R.string.section_license), formatLicence(pContext), R.drawable.baseline_description_24); + public static IconCard getAddContactCard(Context pContext) { + return new IconCard(Card.Type.ADD_CONTACT, pContext.getString(R.string.account_tv_add_contact), "", R.drawable.baseline_androidtv_add_user); } - public static IconCard getContributorCard(Context pContext) { - return new IconCard(Card.Type.ABOUT_CONTRIBUTOR, pContext.getString(R.string.credits), formatContributors(pContext), R.drawable.baseline_face_24); - } - - private static CharSequence formatLicence(Context pContext) { - Resources res = pContext.getResources(); - - SpannableString version = new SpannableString(res.getString(R.string.version_section)); - version.setSpan(new UnderlineSpan(), 0, version.length(), 0); - CharSequence versioned = res.getString(R.string.app_release, BuildConfig.VERSION_NAME); - - SpannableString licence = new SpannableString(res.getString(R.string.section_license)); - licence.setSpan(new UnderlineSpan(), 0, licence.length(), 0); - CharSequence licenced = res.getString(R.string.license); - - SpannableString copyright = new SpannableString(res.getString(R.string.copyright_section)); - copyright.setSpan(new UnderlineSpan(), 0, copyright.length(), 0); - CharSequence copyrighted = res.getString(R.string.copyright); - - - return Html.fromHtml("<b><u>" + version + "</u></b><br/>" + versioned + "<BR/><BR/>" - + "<b><u>" + licence + "</u></b><br/>" + licenced + "<BR/><BR/>" - + "<b><u>" + copyright + "</u></b><br/>" + copyrighted); - } - - private static CharSequence formatContributors(Context pContext) { - Resources res = pContext.getResources(); - - SpannableString developedby = new SpannableString(res.getString(R.string.developed_by)); - developedby.setSpan(new UnderlineSpan(), 0, developedby.length(), 0); - CharSequence developed = res.getString(R.string.credits_developer).replaceAll("\n", "<br/>"); - return Html.fromHtml("<b><u>" + developedby + "</u></b><br/>" + developed); - } } diff --git a/ring-android/app/src/main/java/cx/ring/tv/cards/iconcards/IconCardPresenter.java b/ring-android/app/src/main/java/cx/ring/tv/cards/iconcards/IconCardPresenter.java index 6b534472c..f3c65634f 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/cards/iconcards/IconCardPresenter.java +++ b/ring-android/app/src/main/java/cx/ring/tv/cards/iconcards/IconCardPresenter.java @@ -2,6 +2,7 @@ * Copyright (C) 2004-2021 Savoir-faire Linux Inc. * * Author: Loïc Siret <loic.siret@savoirfairelinux.com> + * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,51 +20,44 @@ */ package cx.ring.tv.cards.iconcards; -import android.animation.ObjectAnimator; import android.content.Context; -import android.graphics.drawable.Drawable; -import androidx.leanback.widget.ImageCardView; +import android.graphics.PorterDuff; import android.view.ContextThemeWrapper; +import android.view.View; import android.widget.ImageView; - import cx.ring.R; -import cx.ring.application.JamiApplication; import cx.ring.tv.cards.AbstractCardPresenter; import cx.ring.tv.cards.Card; +import cx.ring.tv.cards.CardView; -public class IconCardPresenter extends AbstractCardPresenter<ImageCardView> { +public class IconCardPresenter extends AbstractCardPresenter<CardView> { - private static final int ANIMATION_DURATION = 200; + private static final int IMAGE_PADDING = 35; public IconCardPresenter(Context context) { - super(new ContextThemeWrapper(context, R.style.IconCardTheme)); + super(new ContextThemeWrapper(context, R.style.ContactCardTheme)); } @Override - protected ImageCardView onCreateView() { - JamiApplication.getInstance().getInjectionComponent().inject(this); - ImageCardView imageCardView = new ImageCardView(getContext()); - final ImageView image = imageCardView.getMainImageView(); - image.setBackgroundResource(R.drawable.icon_focused); - image.getBackground().setAlpha(0); - imageCardView.setOnFocusChangeListener((v, hasFocus) -> animateIconBackground(image.getBackground(), hasFocus)); - return imageCardView; + protected CardView onCreateView() { + CardView cardView = new CardView(getContext()); + cardView.setTitleSingleLine(false); + cardView.setBackgroundColor(getContext().getResources().getColor(R.color.tv_transparent)); + cardView.setInfoAreaBackgroundColor(getContext().getResources().getColor(R.color.transparent)); + ImageView image = cardView.getMainImageView(); + image.setPadding(IMAGE_PADDING, IMAGE_PADDING, IMAGE_PADDING, IMAGE_PADDING); + image.setColorFilter(getContext().getResources().getColor(android.R.color.white), PorterDuff.Mode.SRC_IN); + cardView.getTitleTextView().setTextAlignment(View.TEXT_ALIGNMENT_CENTER); + return cardView; } @Override - public void onBindViewHolder(Card card, ImageCardView cardView) { + public void onBindViewHolder(Card card, CardView cardView) { cardView.setTitleText(card.getTitle()); cardView.setContentText(card.getDescription()); cardView.setMainImage(card.getDrawable(cardView.getContext())); } - - private void animateIconBackground(Drawable drawable, boolean hasFocus) { - if (hasFocus) { - ObjectAnimator.ofInt(drawable, "alpha", 0, 255).setDuration(ANIMATION_DURATION).start(); - } else { - ObjectAnimator.ofInt(drawable, "alpha", 255, 0).setDuration(ANIMATION_DURATION).start(); - } - } + } diff --git a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactActivity.java b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactActivity.java index 97ff42355..86eac3fff 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactActivity.java +++ b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactActivity.java @@ -19,17 +19,97 @@ */ package cx.ring.tv.contact; +import android.content.Context; +import android.graphics.Bitmap; +import android.hardware.Camera; +import android.hardware.camera2.CameraManager; import android.os.Bundle; +import android.renderscript.Allocation; +import android.renderscript.Element; +import android.renderscript.RenderScript; +import android.renderscript.ScriptIntrinsicYuvToRGB; +import android.renderscript.Type; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentActivity; +import androidx.leanback.app.BackgroundManager; + import cx.ring.R; +import cx.ring.tv.camera.CameraPreview; +import dagger.hilt.android.AndroidEntryPoint; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.schedulers.Schedulers; +@AndroidEntryPoint public class TVContactActivity extends FragmentActivity { + + private static final String TAG = TVContactActivity.class.getSimpleName(); + + private final CompositeDisposable mDisposable = new CompositeDisposable(); public static final String SHARED_ELEMENT_NAME = "photo"; public static final String CONTACT_REQUEST_URI = "uri"; public static final String TYPE_CONTACT_REQUEST_INCOMING = "incoming"; public static final String TYPE_CONTACT_REQUEST_OUTGOING = "outgoing"; + private int mDisplayWidth, mDisplayHeight; + private BackgroundManager mBackgroundManager; + private ImageView mBackgroundImage; + private FrameLayout mPreviewView; + private Camera mCamera; + private CameraPreview mCameraPreview; + private CameraManager mCameraManager; + + private Bitmap mBackgroundBitmap; + private RenderScript rs; + private ScriptIntrinsicYuvToRGB yuvToRgbIntrinsic; + private Allocation in, out; + + private final Camera.ErrorCallback mErrorCallback = new Camera.ErrorCallback() { + @Override + public void onError(int error, Camera camera) { + mBackgroundImage.setVisibility(View.INVISIBLE); + mBackgroundManager.setDrawable(ContextCompat.getDrawable(TVContactActivity.this, R.drawable.tv_background)); + } + }; + + private final Object mCameraAvailabilityCallback = new CameraManager.AvailabilityCallback() { + @Override + public void onCameraAvailable(String cameraId) { + super.onCameraAvailable(cameraId); + if (mBackgroundImage.getVisibility() == View.INVISIBLE) { + setUpCamera(); + } + } + }; + + private final Camera.PreviewCallback mPreviewCallback = new Camera.PreviewCallback() { + @Override + public void onPreviewFrame(byte[] data, Camera camera) { + if (mBackgroundBitmap == null) { + mBackgroundBitmap = Bitmap.createBitmap(mDisplayWidth, mDisplayHeight, Bitmap.Config.ARGB_8888); + rs = RenderScript.create(TVContactActivity.this); + yuvToRgbIntrinsic = ScriptIntrinsicYuvToRGB.create(rs, Element.U8_4(rs)); + Type.Builder yuvType = new Type.Builder(rs, Element.U8(rs)).setX(data.length); + Type.Builder rgbaType = new Type.Builder(rs, Element.RGBA_8888(rs)).setX(mDisplayWidth).setY(mDisplayHeight); + in = Allocation.createTyped(rs, yuvType.create(), Allocation.USAGE_SCRIPT); + out = Allocation.createTyped(rs, rgbaType.create(), Allocation.USAGE_SCRIPT); + } + in.copyFrom(data); + yuvToRgbIntrinsic.setInput(in); + yuvToRgbIntrinsic.forEach(out); + out.copyTo(mBackgroundBitmap); + + mBackgroundImage.setImageBitmap(mBackgroundBitmap); + } + }; + /** * Called when the activity is first created. */ @@ -37,5 +117,87 @@ public class TVContactActivity extends FragmentActivity { public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.tv_frag_contact); + + mBackgroundManager = BackgroundManager.getInstance(this); + mBackgroundManager.attach(getWindow()); + mPreviewView = findViewById(R.id.previewView); + mBackgroundImage = findViewById(R.id.background); + + DisplayMetrics displayMetrics = new DisplayMetrics(); + getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + mDisplayHeight = displayMetrics.heightPixels; + mDisplayWidth = displayMetrics.widthPixels; } + + @Override + protected void onPostResume() { + super.onPostResume(); + setUpCamera(); + } + + @Override + protected void onPause() { + super.onPause(); + if (mCameraPreview != null) { + mCamera.setPreviewCallback(null); + mCameraPreview.stop(); + mCameraPreview = null; + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mDisposable.dispose(); + if (mCameraManager != null) { + mCameraManager.unregisterAvailabilityCallback((CameraManager.AvailabilityCallback) mCameraAvailabilityCallback); + } + if (mCamera != null) { + mCamera.release(); + mCamera = null; + } + if (mBackgroundBitmap != null){ + rs.destroy(); + in.destroy(); + out.destroy(); + yuvToRgbIntrinsic.destroy(); + mBackgroundBitmap.recycle(); + mBackgroundBitmap = null; + rs = null; + in = null; + out = null; + } + } + + private Camera getCameraInstance() { + try { + int currentCamera = 0; + mCamera = Camera.open(currentCamera); + } + catch (RuntimeException e) { + Log.e(TAG, "failed to open camera"); + } + return mCamera; + } + + private void setUpCamera() { + mDisposable.add(Single.fromCallable(this::getCameraInstance) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(camera -> { + mCamera.setErrorCallback(mErrorCallback); + mCamera.setPreviewCallback(mPreviewCallback); + mCameraPreview = new CameraPreview(this, mCamera); + mPreviewView.removeAllViews(); + mPreviewView.addView(mCameraPreview, 0); + mBackgroundImage.setVisibility(View.VISIBLE); + if (mCameraManager == null) { + mCameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE); + mCameraManager.registerAvailabilityCallback((CameraManager.AvailabilityCallback) mCameraAvailabilityCallback, null); + } + }, e -> { + mBackgroundManager.setDrawable(ContextCompat.getDrawable(TVContactActivity.this, R.drawable.tv_background)); + })); + } + } diff --git a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactDetailPresenter.java b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactDetailPresenter.java deleted file mode 100644 index d1a032064..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactDetailPresenter.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (C) 2004-2018 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.tv.contact; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.leanback.widget.Presenter; - -import cx.ring.R; -import net.jami.smartlist.SmartListViewModel; -import cx.ring.tv.conversation.TvConversationFragment; -import cx.ring.utils.ConversationPath; - -public class TVContactDetailPresenter extends Presenter { - - public static final String FRAGMENT_TAG = "conversation"; - - @Override - public ViewHolder onCreateViewHolder(ViewGroup viewGroup) { - return new CustomViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.tv, viewGroup, false)); - } - - @Override - public void onBindViewHolder(ViewHolder viewHolder, Object object) { - ((CustomViewHolder) viewHolder).bind((SmartListViewModel) object); - } - - @Override - public void onUnbindViewHolder(ViewHolder viewHolder) { - - } - - private static class CustomViewHolder extends Presenter.ViewHolder { - - CustomViewHolder(View view) { - super(view); - } - - void bind(SmartListViewModel object) { - Fragment fragment = TvConversationFragment.newInstance(ConversationPath.toBundle(object.getAccountId(), object.getUri())); - FragmentManager fragmentManager = ((TVContactActivity) view.getContext()).getSupportFragmentManager(); - - fragmentManager.beginTransaction() - .replace(R.id.content, fragment, FRAGMENT_TAG) - .commit(); - } - } - -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactDetailPresenter.kt b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactDetailPresenter.kt new file mode 100644 index 000000000..a31f2572a --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactDetailPresenter.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2004-2018 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.contact + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentActivity +import androidx.leanback.widget.Presenter +import cx.ring.R +import cx.ring.tv.conversation.TvConversationFragment +import cx.ring.utils.ConversationPath +import net.jami.smartlist.SmartListViewModel + +class TVContactDetailPresenter : Presenter() { + override fun onCreateViewHolder(viewGroup: ViewGroup): ViewHolder { + return CustomViewHolder( + LayoutInflater.from(viewGroup.context).inflate(R.layout.tv, viewGroup, false) + ) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) { + (viewHolder as CustomViewHolder).bind(item as SmartListViewModel) + } + + override fun onUnbindViewHolder(viewHolder: ViewHolder) {} + + private class CustomViewHolder(view: View) : ViewHolder(view) { + fun bind(item: SmartListViewModel) { + val fragment = TvConversationFragment.newInstance(ConversationPath.toBundle(item.accountId, item.uri)) + val fragmentManager = (view.context as FragmentActivity).supportFragmentManager + fragmentManager.beginTransaction() + .replace(R.id.content, fragment, FRAGMENT_TAG) + .commit() + } + } + + companion object { + const val FRAGMENT_TAG = "conversation" + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactFragment.java b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactFragment.java deleted file mode 100644 index 5db0ccb3c..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactFragment.java +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.tv.contact; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.res.Resources; -import android.os.Bundle; -import android.view.View; -import android.widget.Button; - -import androidx.appcompat.app.AlertDialog; -import androidx.core.content.ContextCompat; -import androidx.leanback.app.BackgroundManager; -import androidx.leanback.widget.Action; -import androidx.leanback.widget.ArrayObjectAdapter; -import androidx.leanback.widget.ClassPresenterSelector; -import androidx.leanback.widget.DetailsOverviewLogoPresenter; -import androidx.leanback.widget.DetailsOverviewRow; -import androidx.leanback.widget.FullWidthDetailsOverviewRowPresenter; -import androidx.leanback.widget.FullWidthDetailsOverviewSharedElementHelper; -import androidx.leanback.widget.ListRow; -import androidx.leanback.widget.ListRowPresenter; -import androidx.leanback.widget.SparseArrayObjectAdapter; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.client.CallActivity; -import cx.ring.client.HomeActivity; -import cx.ring.fragments.CallFragment; -import cx.ring.fragments.ConversationFragment; -import net.jami.model.Uri; -import net.jami.services.NotificationService; -import net.jami.smartlist.SmartListViewModel; -import cx.ring.tv.call.TVCallActivity; -import cx.ring.tv.contactrequest.TVContactRequestDetailPresenter; -import cx.ring.tv.main.BaseDetailFragment; -import cx.ring.utils.ConversationPath; -import cx.ring.views.AvatarDrawable; - -public class TVContactFragment extends BaseDetailFragment<TVContactPresenter> implements TVContactView { - - private static final int ACTION_CALL = 0; - private static final int ACTION_DELETE = 1; - private static final int ACTION_ACCEPT = 2; - private static final int ACTION_REFUSE = 3; - private static final int ACTION_BLOCK = 4; - private static final int ACTION_ADD_CONTACT = 5; - private static final int ACTION_CLEAR_HISTORY = 6; - - private static final int DIALOG_WIDTH = 900; - private static final int DIALOG_HEIGHT = 400; - - private ArrayObjectAdapter mAdapter; - private int iconSize = -1; - - private boolean isIncomingRequest = false; - private boolean isOutgoingRequest = false; - - @Override - public void onCreate(Bundle savedInstanceState) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - super.onCreate(savedInstanceState); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - String type = getActivity().getIntent().getType(); - if (type != null) { - switch (type) { - case TVContactActivity.TYPE_CONTACT_REQUEST_INCOMING: - isIncomingRequest = true; - break; - case TVContactActivity.TYPE_CONTACT_REQUEST_OUTGOING: - isOutgoingRequest = true; - break; - } - } - - // Override down navigation as we do not use it in this screen - // Only the detailPresenter will be displayed - - prepareBackgroundManager(); - setupAdapter(); - Resources res = getResources(); - iconSize = res.getDimensionPixelSize(R.dimen.tv_avatar_size); - presenter.setContact(ConversationPath.fromIntent(getActivity().getIntent())); - } - - private void prepareBackgroundManager() { - Activity activity = requireActivity(); - BackgroundManager mBackgroundManager = BackgroundManager.getInstance(activity); - mBackgroundManager.attach(activity.getWindow()); - } - - private void setupAdapter() { - // Set detail background and style. - FullWidthDetailsOverviewRowPresenter detailsPresenter; - if (isIncomingRequest || isOutgoingRequest) { - detailsPresenter = new FullWidthDetailsOverviewRowPresenter( - new TVContactRequestDetailPresenter(), - new DetailsOverviewLogoPresenter()); - } else { - detailsPresenter = new FullWidthDetailsOverviewRowPresenter( - new TVContactDetailPresenter(), - new DetailsOverviewLogoPresenter()); - } - - detailsPresenter.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.grey_900)); - detailsPresenter.setInitialState(FullWidthDetailsOverviewRowPresenter.STATE_HALF); - - // Hook up transition element. - Activity activity = getActivity(); - if (activity != null) { - FullWidthDetailsOverviewSharedElementHelper mHelper = new FullWidthDetailsOverviewSharedElementHelper(); - mHelper.setSharedElementEnterTransition(activity, TVContactActivity.SHARED_ELEMENT_NAME); - detailsPresenter.setListener(mHelper); - detailsPresenter.setParticipatingEntranceTransition(false); - prepareEntranceTransition(); - } - - detailsPresenter.setOnActionClickedListener(action -> { - if (action.getId() == ACTION_CALL) { - presenter.contactClicked(); - } else if (action.getId() == ACTION_DELETE) { - createDeleteDialog(); - } else if (action.getId() == ACTION_CLEAR_HISTORY) { - presenter.clearHistory(); - } else if (action.getId() == ACTION_ADD_CONTACT) { - presenter.onAddContact(); - } else if (action.getId() == ACTION_ACCEPT) { - presenter.acceptTrustRequest(); - } else if (action.getId() == ACTION_REFUSE) { - presenter.refuseTrustRequest(); - } else if (action.getId() == ACTION_BLOCK) { - presenter.blockTrustRequest(); - } - }); - - ClassPresenterSelector mPresenterSelector = new ClassPresenterSelector(); - mPresenterSelector.addClassPresenter(DetailsOverviewRow.class, detailsPresenter); - mPresenterSelector.addClassPresenter(ListRow.class, new ListRowPresenter()); - mAdapter = new ArrayObjectAdapter(mPresenterSelector); - setAdapter(mAdapter); - } - - public void showContact(SmartListViewModel model) { - final DetailsOverviewRow row = new DetailsOverviewRow(model); - AvatarDrawable avatar = - new AvatarDrawable.Builder() - .withViewModel(model) - //.withPresence(false) - .withCircleCrop(false) - .build(getActivity()); - avatar.setInSize(iconSize); - row.setImageDrawable(avatar); - - SparseArrayObjectAdapter adapter = new SparseArrayObjectAdapter(); - if (isIncomingRequest) { - adapter.set(ACTION_ACCEPT, new Action(ACTION_ACCEPT, getResources() - .getString(R.string.accept))); - adapter.set(ACTION_REFUSE, new Action(ACTION_REFUSE, getResources().getString(R.string.refuse))); - adapter.set(ACTION_BLOCK, new Action(ACTION_BLOCK, getResources().getString(R.string.block))); - } else if (isOutgoingRequest) { - adapter.set(ACTION_ADD_CONTACT, new Action(ACTION_ADD_CONTACT, getResources().getString(R.string.ab_action_contact_add))); - } else { - adapter.set(ACTION_CALL, new Action(ACTION_CALL, getResources().getString(R.string.ab_action_video_call), - null, requireContext().getDrawable(R.drawable.baseline_videocam_24))); - adapter.set(ACTION_DELETE, new Action(ACTION_DELETE, getResources().getString(R.string.conversation_action_remove_this))); - adapter.set(ACTION_CLEAR_HISTORY, new Action(ACTION_CLEAR_HISTORY, getResources().getString(R.string.conversation_action_history_clear))); - } - row.setActionsAdapter(adapter); - - mAdapter.add(row); - } - - @Override - public void callContact(String accountId, Uri conversationUri, Uri uri) { - startActivity(new Intent(Intent.ACTION_CALL) - .setClass(requireContext(), TVCallActivity.class) - .putExtras(ConversationPath.toBundle(accountId, conversationUri)) - .putExtra(Intent.EXTRA_PHONE_NUMBER, uri.getUri())); - } - - @Override - public void goToCallActivity(String id) { - startActivity(new Intent(requireContext(), TVCallActivity.class) - .putExtra(NotificationService.KEY_CALL_ID, id)); - } - - @Override - public void switchToConversationView() { - isIncomingRequest = false; - isOutgoingRequest = false; - setupAdapter(); - presenter.setContact(ConversationPath.fromIntent(getActivity().getIntent())); - } - - @Override - public void finishView() { - Activity activity = getActivity(); - if (activity != null) { - activity.finish(); - } - } - - private void createDeleteDialog() { - AlertDialog alertDialog = new MaterialAlertDialogBuilder(requireContext(), R.style.Theme_MaterialComponents_Dialog) - .setTitle(R.string.conversation_action_remove_this_title) - .setMessage("") - .setPositiveButton(R.string.menu_delete, (dialog, whichButton) -> presenter.removeContact()) - .setNegativeButton(android.R.string.cancel, null) - .create(); - alertDialog.getWindow().setLayout(DIALOG_WIDTH, DIALOG_HEIGHT); - alertDialog.setOwnerActivity(requireActivity()); - alertDialog.setOnShowListener(dialog -> { - Button positive = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); - positive.setFocusable(true); - positive.setFocusableInTouchMode(true); - positive.requestFocus(); - }); - - alertDialog.show(); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactFragment.kt b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactFragment.kt new file mode 100644 index 000000000..af1757d9f --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactFragment.kt @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.tv.contact + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.core.content.ContextCompat +import androidx.leanback.widget.* +import cx.ring.R +import cx.ring.tv.call.TVCallActivity +import cx.ring.tv.contact.more.TVContactMoreActivity +import cx.ring.tv.contact.more.TVContactMoreFragment +import cx.ring.tv.contactrequest.TVContactRequestDetailPresenter +import cx.ring.tv.main.BaseDetailFragment +import cx.ring.utils.ConversationPath +import cx.ring.views.AvatarDrawable +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.model.Uri +import net.jami.services.NotificationService +import net.jami.smartlist.SmartListViewModel + +@AndroidEntryPoint +class TVContactFragment : BaseDetailFragment<TVContactPresenter>(), TVContactView { + private val mDisposableBag = CompositeDisposable() + private var mAdapter: ArrayObjectAdapter? = null + private var iconSize = -1 + private var isIncomingRequest = false + private var isOutgoingRequest = false + private lateinit var mConversationPath: ConversationPath + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val type: String? + if (arguments != null) { + mConversationPath = ConversationPath.fromBundle(arguments)!! + type = arguments?.getString("type") + } else { + mConversationPath = ConversationPath.fromIntent(requireActivity().intent)!! + type = activity?.intent?.type + } + if (type != null) { + when (type) { + TVContactActivity.TYPE_CONTACT_REQUEST_INCOMING -> isIncomingRequest = true + TVContactActivity.TYPE_CONTACT_REQUEST_OUTGOING -> isOutgoingRequest = true + } + } + setupAdapter() + val res = resources + iconSize = res.getDimensionPixelSize(R.dimen.tv_avatar_size) + presenter!!.setContact(mConversationPath) + } + + private fun setupAdapter() { + // Set detail background and style. + val detailsPresenter: FullWidthDetailsOverviewRowPresenter = if (isIncomingRequest || isOutgoingRequest) { + FullWidthDetailsOverviewRowPresenter( + TVContactRequestDetailPresenter(), + DetailsOverviewLogoPresenter() + ) + } else { + FullWidthDetailsOverviewRowPresenter( + TVContactDetailPresenter(), + DetailsOverviewLogoPresenter() + ) + } + detailsPresenter.backgroundColor = + ContextCompat.getColor(requireContext(), R.color.tv_contact_background) + detailsPresenter.actionsBackgroundColor = + ContextCompat.getColor(requireContext(), R.color.tv_contact_row_background) + detailsPresenter.initialState = FullWidthDetailsOverviewRowPresenter.STATE_HALF + + // Hook up transition element. + val activity: Activity? = activity + if (activity != null) { + val mHelper = FullWidthDetailsOverviewSharedElementHelper() + mHelper.setSharedElementEnterTransition(activity, TVContactActivity.SHARED_ELEMENT_NAME) + detailsPresenter.setListener(mHelper) + detailsPresenter.isParticipatingEntranceTransition = false + prepareEntranceTransition() + } + detailsPresenter.onActionClickedListener = OnActionClickedListener { action: Action -> + if (action.id == ACTION_CALL.toLong()) { + presenter!!.contactClicked() + } else if (action.id == ACTION_ADD_CONTACT.toLong()) { + presenter!!.onAddContact() + } else if (action.id == ACTION_ACCEPT.toLong()) { + presenter!!.acceptTrustRequest() + } else if (action.id == ACTION_REFUSE.toLong()) { + presenter!!.refuseTrustRequest() + } else if (action.id == ACTION_BLOCK.toLong()) { + presenter!!.blockTrustRequest() + } else if (action.id == ACTION_MORE.toLong()) { + startActivityForResult( + Intent(getActivity(), TVContactMoreActivity::class.java) + .setDataAndType( + mConversationPath!!.toUri(), + TVContactMoreActivity.CONTACT_REQUEST_URI + ), + REQUEST_CODE + ) + } + } + val mPresenterSelector = ClassPresenterSelector() + mPresenterSelector.addClassPresenter(DetailsOverviewRow::class.java, detailsPresenter) + mPresenterSelector.addClassPresenter(ListRow::class.java, ListRowPresenter()) + mAdapter = ArrayObjectAdapter(mPresenterSelector) + adapter = mAdapter + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_CODE) { + if (resultCode == TVContactMoreFragment.DELETE) finishView() + } + } + + override fun showContact(model: SmartListViewModel) { + val context = requireContext() + val row = DetailsOverviewRow(model) + val avatar = AvatarDrawable.Builder() + .withViewModel(model) //.withPresence(false) + .withCircleCrop(false) + .build(context) + avatar.setInSize(iconSize) + row.imageDrawable = avatar + val adapter = SparseArrayObjectAdapter() + if (isIncomingRequest) { + adapter[ACTION_ACCEPT] = Action(ACTION_ACCEPT.toLong(), resources + .getString(R.string.accept)) + adapter[ACTION_REFUSE] = Action(ACTION_REFUSE.toLong(), resources.getString(R.string.refuse)) + adapter[ACTION_BLOCK] = Action(ACTION_BLOCK.toLong(), resources.getString(R.string.block)) + } else if (isOutgoingRequest) { + adapter[ACTION_ADD_CONTACT] = Action(ACTION_ADD_CONTACT.toLong(), resources.getString(R.string.ab_action_contact_add)) + } else { + adapter[ACTION_CALL] = Action(ACTION_CALL.toLong(), resources.getString(R.string.ab_action_video_call), null, context.getDrawable(R.drawable.baseline_videocam_24)) + adapter[ACTION_MORE] = Action(ACTION_MORE.toLong(), resources.getString(R.string.tv_action_more), null, context.getDrawable(R.drawable.baseline_more_vert_24)) + } + row.actionsAdapter = adapter + mAdapter!!.add(row) + } + + override fun callContact(accountId: String, conversationUri: Uri, uri: Uri) { + startActivity( + Intent(Intent.ACTION_CALL) + .setClass(requireContext(), TVCallActivity::class.java) + .putExtras(ConversationPath.toBundle(accountId, conversationUri)) + .putExtra(Intent.EXTRA_PHONE_NUMBER, uri.uri) + ) + } + + override fun goToCallActivity(id: String) { + startActivity( + Intent(requireContext(), TVCallActivity::class.java) + .putExtra(NotificationService.KEY_CALL_ID, id) + ) + } + + override fun switchToConversationView() { + isIncomingRequest = false + isOutgoingRequest = false + setupAdapter() + presenter!!.setContact(mConversationPath) + } + + override fun finishView() { + val activity: Activity? = activity + activity?.onBackPressed() + } + + override fun onDestroy() { + super.onDestroy() + mDisposableBag.dispose() + } + + companion object { + @JvmField + val TAG = TVContactFragment::class.simpleName!! + private const val ACTION_CALL = 0 + private const val ACTION_ACCEPT = 1 + private const val ACTION_REFUSE = 2 + private const val ACTION_BLOCK = 3 + private const val ACTION_ADD_CONTACT = 4 + private const val ACTION_MORE = 5 + private const val REQUEST_CODE = 100 + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactPresenter.java b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactPresenter.java deleted file mode 100644 index 410f956e0..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactPresenter.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright (C) 2004-2018 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.tv.contact; - -import javax.inject.Inject; - -import net.jami.daemon.Blob; -import net.jami.facades.ConversationFacade; -import net.jami.model.Account; -import net.jami.model.Conference; -import net.jami.model.Conversation; -import net.jami.model.Call; -import net.jami.model.Uri; -import net.jami.mvp.RootPresenter; -import net.jami.services.AccountService; -import net.jami.services.VCardService; -import net.jami.smartlist.SmartListViewModel; -import cx.ring.utils.ConversationPath; -import io.reactivex.rxjava3.core.Scheduler; - -import net.jami.utils.VCardUtils; - -public class TVContactPresenter extends RootPresenter<TVContactView> { - - private final AccountService mAccountService; - private final ConversationFacade mConversationService; - private final Scheduler mUiScheduler; - private final VCardService mVCardService; - - private String mAccountId; - private Uri mUri; - - @Inject - public TVContactPresenter(AccountService accountService, - ConversationFacade conversationService, - Scheduler uiScheduler, - VCardService vCardService) { - mAccountService = accountService; - mConversationService = conversationService; - mUiScheduler = uiScheduler; - mVCardService = vCardService; - } - - public void setContact(ConversationPath path) { - mAccountId = path.getAccountId(); - mUri = path.getConversationUri(); - mCompositeDisposable.clear(); - mCompositeDisposable.add(mConversationService - .getAccountSubject(path.getAccountId()) - .map(a -> new SmartListViewModel(a.getByUri(mUri), true)) - .observeOn(mUiScheduler) - .subscribe(c -> getView().showContact(c))); - } - - public void removeContact() { - mConversationService.removeConversation(mAccountId, mUri).subscribe(); - getView().finishView(); - } - - public void contactClicked() { - Account account = mAccountService.getAccount(mAccountId); - if (account != null) { - Conversation conversation = account.getByUri(mUri); - Conference conf = conversation.getCurrentCall(); - if (conf != null - && !conf.getParticipants().isEmpty() - && conf.getParticipants().get(0).getCallStatus() != Call.CallStatus.INACTIVE - && conf.getParticipants().get(0).getCallStatus() != Call.CallStatus.FAILURE) { - getView().goToCallActivity(conf.getId()); - } else { - if (conversation.isSwarm()) { - getView().callContact(mAccountId, mUri, conversation.getContact().getUri()); - } else { - getView().callContact(mAccountId, mUri, mUri); - } - } - } - } - - public void clearHistory() { - mConversationService.clearHistory(mAccountId, mUri).subscribe(); - } - - public void onAddContact() { - sendTrustRequest(mAccountId, mUri); - getView().switchToConversationView(); - } - - private void sendTrustRequest(String accountId, Uri conversationUri) { - Conversation conversation = mAccountService.getAccount(accountId).getByUri(conversationUri); - mVCardService.loadSmallVCardWithDefault(accountId, VCardService.MAX_SIZE_REQUEST) - .subscribe(vCard -> mAccountService.sendTrustRequest(conversation, conversationUri, Blob.fromString(VCardUtils.vcardToString(vCard))), - e -> mAccountService.sendTrustRequest(conversation, conversationUri, null)); - } - - public void acceptTrustRequest() { - mConversationService.acceptRequest(mAccountId, mUri); - getView().switchToConversationView(); - } - - public void refuseTrustRequest() { - mConversationService.discardRequest(mAccountId, mUri); - getView().finishView(); - } - - public void blockTrustRequest() { - mConversationService.discardRequest(mAccountId, mUri); - mAccountService.removeContact(mAccountId, mUri.getRawRingId(), true); - getView().finishView(); - } - - -} diff --git a/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactPresenter.kt b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactPresenter.kt new file mode 100644 index 000000000..31c10e90d --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/contact/TVContactPresenter.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2004-2018 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.contact + +import cx.ring.utils.ConversationPath +import ezvcard.VCard +import io.reactivex.rxjava3.core.Scheduler +import net.jami.daemon.Blob +import net.jami.services.ConversationFacade +import net.jami.model.Account +import net.jami.model.Call +import net.jami.model.Uri +import net.jami.mvp.RootPresenter +import net.jami.services.AccountService +import net.jami.services.VCardService +import net.jami.smartlist.SmartListViewModel +import net.jami.utils.VCardUtils.vcardToString +import javax.inject.Inject +import javax.inject.Named + +class TVContactPresenter @Inject constructor( + private val mAccountService: AccountService, + private val mConversationService: ConversationFacade, + @param:Named("UiScheduler") private val mUiScheduler: Scheduler, + private val mVCardService: VCardService +) : RootPresenter<TVContactView>() { + private var mAccountId: String? = null + private var mUri: Uri? = null + + fun setContact(path: ConversationPath) { + mAccountId = path.accountId + mUri = path.conversationUri + mCompositeDisposable.clear() + mCompositeDisposable.add(mConversationService + .getAccountSubject(path.accountId) + .map { a: Account -> SmartListViewModel(a.getByUri(mUri), true) } + .observeOn(mUiScheduler) + .subscribe { c: SmartListViewModel -> view!!.showContact(c) }) + } + + fun contactClicked() { + val account = mAccountService.getAccount(mAccountId!!) + if (account != null) { + val conversation = account.getByUri(mUri) + val conf = conversation!!.currentCall + if (conf != null && conf.participants.isNotEmpty() + && conf.participants[0].callStatus !== Call.CallStatus.INACTIVE && conf.participants[0].callStatus !== Call.CallStatus.FAILURE + ) { + view?.goToCallActivity(conf.id) + } else { + if (conversation.isSwarm) { + view?.callContact(mAccountId, mUri, conversation.contact!!.uri) + } else { + view?.callContact(mAccountId, mUri, mUri) + } + } + } + } + + fun onAddContact() { + mAccountId?.let { accountId -> mUri?.let { uri -> + sendTrustRequest(accountId, uri) + } } + view?.switchToConversationView() + } + + private fun sendTrustRequest(accountId: String, conversationUri: Uri) { + val conversation = mAccountService.getAccount(accountId)!!.getByUri(conversationUri) + mVCardService.loadSmallVCardWithDefault(accountId, VCardService.MAX_SIZE_REQUEST) + .subscribe({ vCard: VCard -> + mAccountService.sendTrustRequest(conversation!!, conversationUri, Blob.fromString(vcardToString(vCard))) }) + { mAccountService.sendTrustRequest(conversation!!, conversationUri, null) } + } + + fun acceptTrustRequest() { + mConversationService.acceptRequest(mAccountId!!, mUri!!) + view?.switchToConversationView() + } + + fun refuseTrustRequest() { + mConversationService.discardRequest(mAccountId!!, mUri!!) + view?.finishView() + } + + fun blockTrustRequest() { + mConversationService.discardRequest(mAccountId!!, mUri!!) + mAccountService.removeContact(mAccountId!!, mUri!!.rawRingId, true) + view?.finishView() + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVSettingsActivity.java b/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreActivity.java similarity index 69% rename from ring-android/app/src/main/java/cx/ring/tv/account/TVSettingsActivity.java rename to ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreActivity.java index e7fb5e6ef..40e8b2413 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVSettingsActivity.java +++ b/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreActivity.java @@ -1,7 +1,7 @@ /* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * Copyright (C) 2004-2020 Savoir-faire Linux Inc. * - * Author: Pierre Duchemin <pierre.duchemin@savoirfairelinux.com> + * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,18 +18,23 @@ * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ -package cx.ring.tv.account; +package cx.ring.tv.contact.more; import android.os.Bundle; import androidx.fragment.app.FragmentActivity; import cx.ring.R; +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint +public class TVContactMoreActivity extends FragmentActivity { + + public static final String CONTACT_REQUEST_URI = "uri"; -public class TVSettingsActivity extends FragmentActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.tv_activity_settings); + setContentView(R.layout.tv_activity_contact_more); } } diff --git a/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreFragment.kt b/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreFragment.kt new file mode 100644 index 000000000..e8996373d --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreFragment.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2004-2020 Savoir-faire Linux Inc. + * + * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.contact.more + +import android.app.Activity +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.leanback.preference.LeanbackSettingsFragmentCompat +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceScreen +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import cx.ring.R +import cx.ring.tv.account.JamiPreferenceFragment +import cx.ring.utils.ConversationPath.Companion.fromIntent +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TVContactMoreFragment : LeanbackSettingsFragmentCompat() { + override fun onPreferenceStartInitialScreen() { + startPreferenceFragment(PrefsFragment.newInstance()) + } + + override fun onPreferenceStartFragment( + preferenceFragment: PreferenceFragmentCompat, + preference: Preference + ): Boolean { + return false + } + + override fun onPreferenceStartScreen( + caller: PreferenceFragmentCompat, + pref: PreferenceScreen + ): Boolean { + return false + } + + @AndroidEntryPoint + class PrefsFragment : JamiPreferenceFragment<TVContactMorePresenter>(), TVContactMoreView { + override fun onCreatePreferences(savedInstanceState: Bundle, rootKey: String) { + setPreferencesFromResource(R.xml.tv_contact_more_pref, rootKey) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + presenter!!.setContact(fromIntent(requireActivity().intent)) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + if (preference.key == "Contact.clear") { + createDialog( + getString(R.string.conversation_action_history_clear_title), + getString(R.string.clear_history) + ) { dialog: DialogInterface?, whichButton: Int -> presenter!!.clearHistory() } + } else if (preference.key == "Contact.delete") { + createDialog( + getString(R.string.conversation_action_remove_this_title), + getString(R.string.menu_delete) + ) { dialog: DialogInterface?, whichButton: Int -> presenter!!.removeContact() } + } + return super.onPreferenceTreeClick(preference) + } + + private fun createDialog( + title: String, + buttonText: String, + onClickListener: DialogInterface.OnClickListener + ) { + val alertDialog = MaterialAlertDialogBuilder(requireContext(), R.style.Theme_MaterialComponents_Dialog) + .setTitle(title) + .setMessage("") + .setPositiveButton(buttonText, onClickListener) + .setNegativeButton(android.R.string.cancel, null) + .create() + alertDialog.window!!.setLayout(DIALOG_WIDTH, DIALOG_HEIGHT) + alertDialog.setOwnerActivity(requireActivity()) + alertDialog.setOnShowListener { + val positive = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) + positive.isFocusable = true + positive.isFocusableInTouchMode = true + positive.requestFocus() + } + alertDialog.show() + } + + override fun finishView(finishParent: Boolean) { + val activity: Activity? = activity + if (activity != null) { + activity.setResult(if (finishParent) DELETE else CLEAR) + activity.finish() + } + } + + companion object { + fun newInstance(): PrefsFragment { + return PrefsFragment() + } + } + } + + companion object { + const val CLEAR = 101 + const val DELETE = 102 + private const val DIALOG_WIDTH = 900 + private const val DIALOG_HEIGHT = 400 + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMorePresenter.java b/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMorePresenter.java new file mode 100644 index 000000000..f69283d56 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMorePresenter.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2004-2020 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.contact.more; + +import net.jami.services.ConversationFacade; +import net.jami.model.Uri; +import net.jami.mvp.RootPresenter; + +import javax.inject.Inject; + +import cx.ring.utils.ConversationPath; + +public class TVContactMorePresenter extends RootPresenter<TVContactMoreView> { + + private static final String TAG = TVContactMorePresenter.class.getSimpleName(); + + private final ConversationFacade mConversationService; + + private String mAccountId; + private Uri mUri; + + @Inject + TVContactMorePresenter(ConversationFacade conversationService) { + mConversationService = conversationService; + } + + public void setContact(ConversationPath path) { + mAccountId = path.getAccountId(); + mUri = path.getConversationUri(); + } + + public void clearHistory() { + mConversationService.clearHistory(mAccountId, mUri).subscribe(); + getView().finishView(false); + } + + public void removeContact() { + mConversationService.removeConversation(mAccountId, mUri).subscribe(); + getView().finishView(true); + } + +} diff --git a/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreView.java b/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreView.java new file mode 100644 index 000000000..ee9f67e1d --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/contact/more/TVContactMoreView.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.contact.more; + +public interface TVContactMoreView { + + void finishView(boolean finishParent); + +} diff --git a/ring-android/app/src/main/java/cx/ring/tv/conversation/TvConversationFragment.java b/ring-android/app/src/main/java/cx/ring/tv/conversation/TvConversationFragment.java deleted file mode 100644 index 23c5ff6c9..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/conversation/TvConversationFragment.java +++ /dev/null @@ -1,823 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.tv.conversation; - -import android.Manifest; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.media.MediaPlayer; -import android.media.MediaRecorder; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.transition.TransitionManager; - -import android.os.Environment; -import android.provider.MediaStore; -import android.speech.RecognizerIntent; -import android.text.TextUtils; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.widget.Button; -import android.widget.Toast; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.snackbar.Snackbar; - -import java.io.File; -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.client.MediaViewerActivity; -import cx.ring.views.AvatarFactory; -import net.jami.conversation.ConversationPresenter; -import net.jami.conversation.ConversationView; -import net.jami.model.Account; -import cx.ring.databinding.FragConversationTvBinding; -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.model.DataTransfer; -import net.jami.model.Error; -import net.jami.model.Interaction; -import cx.ring.mvp.BaseSupportFragment; -import cx.ring.service.DRingService; -import cx.ring.tv.camera.CustomCameraActivity; -import cx.ring.utils.AndroidFileUtils; -import cx.ring.utils.ContentUriHandler; -import cx.ring.utils.ConversationPath; -import net.jami.utils.StringUtils; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class TvConversationFragment extends BaseSupportFragment<ConversationPresenter> implements ConversationView { - private static final String TAG = TvConversationFragment.class.getSimpleName(); - - private static final String ARG_MODEL = "model"; - private static final String KEY_AUDIOFILE = "audiofile"; - - private static final int REQUEST_CODE_PHOTO = 101; - private static final int REQUEST_SPEECH_CODE = 102; - private static final int REQUEST_CODE_SAVE_FILE = 103; - - private static final int DIALOG_WIDTH = 900; - private static final int DIALOG_HEIGHT = 400; - - private ConversationPath mConversationPath; - - private int mSelectedPosition; - - private static final String[] permissions = { Manifest.permission.RECORD_AUDIO }; - private static final int REQUEST_RECORD_AUDIO_PERMISSION = 200; - private File fileName = null; - - private MediaRecorder recorder = null; - private MediaPlayer player = null; - - boolean mStartRecording = true; - boolean mStartPlaying = true; - - private TvConversationAdapter mAdapter = null; - private AvatarDrawable mConversationAvatar; - private final Map<String, AvatarDrawable> mParticipantAvatars = new HashMap<>(); - - private final CompositeDisposable mCompositeDisposable = new CompositeDisposable(); - private FragConversationTvBinding binding; - - private String mCurrentFileAbsolutePath = null; - - public static TvConversationFragment newInstance(Bundle args) { - TvConversationFragment fragment = new TvConversationFragment(); - fragment.setArguments(args); - return fragment; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (getArguments() != null) { - mConversationPath = ConversationPath.fromBundle(getArguments()); - } - String audiofile = savedInstanceState == null ? null : savedInstanceState.getString(KEY_AUDIOFILE); - if (audiofile != null) { - fileName = new File(audiofile); - } - } - - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - if (fileName != null) { - outState.putString(KEY_AUDIOFILE, fileName.getAbsolutePath()); - } - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragConversationTvBinding.inflate(inflater, container, false); - ((JamiApplication) requireActivity().getApplication()).getInjectionComponent().inject(this); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - // Create an intent that can start the Speech Recognizer activity - private void displaySpeechRecognizer() { - if (!checkAudioPermission()) - return; - try { - Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) - .putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) - .putExtra(RecognizerIntent.EXTRA_PROMPT, getText(R.string.conversation_input_speech_hint)); - startActivityForResult(intent, REQUEST_SPEECH_CODE); - } catch (Exception e) { - Snackbar.make(requireView(), "Can't get voice input", Snackbar.LENGTH_SHORT).show(); - } - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - //ConversationPath path = ConversationPath.fromIntent(requireActivity().getIntent()); - presenter.init(mConversationPath.getConversationUri(), mConversationPath.getAccountId()); - mAdapter = new TvConversationAdapter(this, presenter); - - binding.buttonText.setOnClickListener(v -> displaySpeechRecognizer()); - binding.buttonVideo.setOnClickListener(v -> { - Intent intent = new Intent(getActivity(), CustomCameraActivity.class) - .setAction(MediaStore.ACTION_VIDEO_CAPTURE); - startActivityForResult(intent, REQUEST_CODE_PHOTO); - }); - - binding.buttonAudio.setOnClickListener(v -> { - onRecord(mStartRecording); - mStartRecording = !mStartRecording; - }); - - binding.buttonText.setOnFocusChangeListener((v, hasFocus) -> { - TransitionManager.beginDelayedTransition(binding.textContainer); - binding.textText.setVisibility(hasFocus ? View.VISIBLE : View.GONE); - }); - - binding.buttonAudio.setOnFocusChangeListener((v, hasFocus) -> { - TransitionManager.beginDelayedTransition(binding.audioContainer); - binding.textAudio.setVisibility(hasFocus ? View.VISIBLE : View.GONE); - }); - - binding.buttonVideo.setOnFocusChangeListener((v, hasFocus) -> { - TransitionManager.beginDelayedTransition(binding.videoContainer); - binding.textVideo.setVisibility(hasFocus ? View.VISIBLE : View.GONE); - }); - - LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getContext()); - linearLayoutManager.setReverseLayout(true); - linearLayoutManager.setStackFromEnd(true); - binding.recyclerView.setLayoutManager(linearLayoutManager); - binding.recyclerView.setAdapter(mAdapter); - } - - private boolean checkAudioPermission() { - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { - requestPermissions(permissions, REQUEST_RECORD_AUDIO_PERMISSION); - return false; - } - return true; - } - - @Override - public boolean onContextItemSelected(@NonNull MenuItem item) { - if (mAdapter.onContextItemSelected(item)) - return true; - return super.onContextItemSelected(item); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - switch (requestCode) { - case REQUEST_CODE_PHOTO: - if (resultCode == Activity.RESULT_OK && data != null) { - Uri media = (Uri) data.getExtras().get(MediaStore.EXTRA_OUTPUT); - String type = data.getType(); - createMediaDialog(media, type); - } - break; - case REQUEST_SPEECH_CODE: - if (resultCode == Activity.RESULT_OK && data != null) { - List<String> results = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS); - String spokenText = results.get(0); - createTextDialog(spokenText); - } - break; - case REQUEST_CODE_SAVE_FILE: - if(resultCode == Activity.RESULT_OK) { - if (data != null && data.getData() != null) { - writeToFile(data.getData()); - } - } - default: - super.onActivityResult(requestCode, resultCode, data); - break; - } - } - - private void writeToFile(Uri data) { - File input = new File(mCurrentFileAbsolutePath); - if (requireContext().getContentResolver() != null) - mCompositeDisposable.add(AndroidFileUtils.copyFileToUri(requireContext().getContentResolver(), input, data). - observeOn(AndroidSchedulers.mainThread()). - subscribe(() -> Toast.makeText(getContext(), R.string.file_saved_successfully, Toast.LENGTH_SHORT).show(), - error -> Toast.makeText(getContext(), R.string.generic_error, Toast.LENGTH_SHORT).show())); - } - - private void createTextDialog(String spokenText) { - if (StringUtils.isEmpty(spokenText)) { - return; - } - - AlertDialog alertDialog = new MaterialAlertDialogBuilder(requireContext(), R.style.Theme_MaterialComponents_Dialog) - .setTitle(spokenText) - .setMessage("") - .setPositiveButton(R.string.tv_dialog_send, (dialog, whichButton) -> presenter.sendTextMessage(spokenText)) - .setNegativeButton(android.R.string.cancel, null) - .create(); - alertDialog.getWindow().setLayout(DIALOG_WIDTH, DIALOG_HEIGHT); - alertDialog.setOwnerActivity(requireActivity()); - alertDialog.setOnShowListener(dialog -> { - Button positive = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); - positive.setFocusable(true); - positive.setFocusableInTouchMode(true); - positive.requestFocus(); - }); - - alertDialog.show(); - } - - private void createMediaDialog(Uri media, String type) { - if (media == null) { - return; - } - Activity activity = getActivity(); - if (activity == null) - return; - - Single<File> file = AndroidFileUtils.getCacheFile(activity, media); - AlertDialog alertDialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_MaterialComponents_Dialog) - .setTitle(type.equals(CustomCameraActivity.TYPE_IMAGE) ? R.string.tv_send_image_dialog_message : R.string.tv_send_video_dialog_message) - .setMessage("") - .setPositiveButton(R.string.tv_dialog_send, (dialog, whichButton) -> - startFileSend(file.flatMapCompletable(TvConversationFragment.this::sendFile))) - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.tv_media_preview, null) - .create(); - alertDialog.getWindow().setLayout(DIALOG_WIDTH, DIALOG_HEIGHT); - alertDialog.setOwnerActivity(activity); - alertDialog.setOnShowListener(dialog -> { - Button positive = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); - positive.setFocusable(true); - positive.setFocusableInTouchMode(true); - positive.requestFocus(); - - Button button = alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL); - button.setOnClickListener(v -> { - if (type.equals(CustomCameraActivity.TYPE_IMAGE)) { - Intent i = new Intent(getContext(), MediaViewerActivity.class); - i.setAction(Intent.ACTION_VIEW).setDataAndType(media, "image/*").setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - startActivity(i); - } else { - Intent intent = new Intent(Intent.ACTION_VIEW, media); - intent.setDataAndType(media, "video/*").setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - startActivity(intent); - } - }); - }); - - alertDialog.show(); - } - - private void createAudioDialog() { - AlertDialog alertDialog = new MaterialAlertDialogBuilder(requireContext(), R.style.Theme_MaterialComponents_Dialog) - .setTitle(R.string.tv_send_audio_dialog_message) - .setMessage("") - .setPositiveButton(R.string.tv_dialog_send, (dialog, whichButton) -> sendAudio()) - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.tv_audio_play, null) - .create(); - alertDialog.getWindow().setLayout(DIALOG_WIDTH, DIALOG_HEIGHT); - alertDialog.setOwnerActivity(requireActivity()); - alertDialog.setOnShowListener(dialog -> { - Button positive = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); - positive.setFocusable(true); - positive.setFocusableInTouchMode(true); - positive.requestFocus(); - - Button button = alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL); - button.setOnClickListener(v -> { - onPlay(mStartPlaying); - if (mStartPlaying) { - button.setText(R.string.tv_audio_pause); - if (player != null) { - player.setOnCompletionListener(mp -> { - button.setText(R.string.tv_audio_play); - mStartPlaying = true; - }); - } - } else { - button.setText(R.string.tv_audio_play); - } - mStartPlaying = !mStartPlaying; - }); - }); - - alertDialog.show(); - } - - @Override - public void addElement(Interaction element) { - mAdapter.add(element); - scrollToTop(); - } - - @Override - public void shareFile(File path, String displayName) { - Context c = getContext(); - if (c == null) - return; - try { - android.net.Uri fileUri = ContentUriHandler.getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, path); - if (fileUri != null) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - String type = c.getContentResolver().getType(fileUri); - sendIntent.setDataAndType(fileUri, type); - sendIntent.putExtra(Intent.EXTRA_STREAM, fileUri); - startActivity(Intent.createChooser(sendIntent, null)); - } - } catch (Exception e) { - Snackbar.make(requireView(), "Error sharing file: " + e.getLocalizedMessage(), Snackbar.LENGTH_SHORT).show(); - } - } - - @Override - public void openFile(File path, String displayName) { - Context c = getContext(); - if (c == null) - return; - try { - android.net.Uri fileUri = ContentUriHandler.getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, path); - if (fileUri != null) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_VIEW); - sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - String type = c.getContentResolver().getType(fileUri); - sendIntent.setDataAndType(fileUri, type); - sendIntent.putExtra(Intent.EXTRA_STREAM, fileUri); - startActivity(Intent.createChooser(sendIntent, null)); - } - } catch (IllegalArgumentException e) { - Snackbar.make(requireView(), "Error opening file: " + e.getLocalizedMessage(), Snackbar.LENGTH_SHORT).show(); - } - } - - /** - * Creates an intent using Android Storage Access Framework - * This intent is then received by applications that can handle it like - * Downloads or Google drive - * @param file DataTransfer of the file that is going to be stored - * @param currentFileAbsolutePath absolute path of the file we want to save - */ - public void startSaveFile(DataTransfer file, String currentFileAbsolutePath) { - mCurrentFileAbsolutePath = currentFileAbsolutePath; - try { - // Use Android Storage File Access to download the file - Intent downloadFileIntent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - downloadFileIntent.setType(AndroidFileUtils.getMimeTypeFromExtension(file.getExtension())); - - downloadFileIntent.addCategory(Intent.CATEGORY_OPENABLE); - downloadFileIntent.putExtra(Intent.EXTRA_TITLE, file.getDisplayName()); - - startActivityForResult(downloadFileIntent, REQUEST_CODE_SAVE_FILE); - } catch (Exception e) { - Log.i(TAG, "No app detected for saving files."); - File directory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - if (!directory.exists()) { - directory.mkdirs(); - } - writeToFile(Uri.fromFile(new File(directory, file.getDisplayName()))); - } - } - - @Override - public void refreshView(List<Interaction> interactions) { - if (interactions == null) { - return; - } - if (mAdapter != null) { - mAdapter.updateDataset(interactions); - } - requireActivity().invalidateOptionsMenu(); - } - - @Override - public void onStop() { - releaseRecorder(); - super.onStop(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requestCode == REQUEST_RECORD_AUDIO_PERMISSION) { - // NOOP - } - } - - private void onRecord(boolean start) { - if (start) { - startRecording(); - } else { - stopRecording(); - } - } - - private void onPlay(boolean start) { - if (start) { - startPlaying(); - } else { - stopPlaying(); - } - } - - private void startPlaying() { - if (fileName == null) - return; - player = new MediaPlayer(); - try { - player.setDataSource(fileName.getAbsolutePath()); - player.prepare(); - player.start(); - } catch (IOException e) { - Log.e(TAG, "prepare() failed"); - } - } - - private void stopPlaying() { - player.release(); - player = null; - } - - private void startRecording() { - if (!checkAudioPermission()) - return; - if (recorder != null) { - return; - } - try { - fileName = AndroidFileUtils.createAudioFile(requireContext()); - recorder = new MediaRecorder(); - recorder.setAudioSource(MediaRecorder.AudioSource.MIC); - recorder.setOutputFile(fileName.getAbsolutePath()); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - recorder.setOutputFormat(MediaRecorder.OutputFormat.OGG); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS); - } else { - recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); - } - - recorder.prepare(); - recorder.start(); - } catch (Exception e) { - Toast.makeText(requireContext(), "Error starting recording: " + e.getLocalizedMessage(), Toast.LENGTH_LONG).show(); - if (recorder != null) { - recorder.release(); - recorder = null; - } - return; - } - - binding.buttonAudio.setImageResource(R.drawable.lb_ic_stop); - binding.textAudio.setText(R.string.tv_audio_recording); - Animation anim = new AlphaAnimation(0.0f, 1.0f); - anim.setDuration(500); - anim.setStartOffset(100); - anim.setRepeatMode(Animation.REVERSE); - anim.setRepeatCount(Animation.INFINITE); - binding.textAudio.startAnimation(anim); - } - - private void releaseRecorder() { - if (recorder != null) { - try { - recorder.stop(); - } catch (Exception e) { - Log.w(TAG, "Exception stopping recorder"); - } - recorder.release(); - recorder = null; - } - } - - private void stopRecording() { - releaseRecorder(); - binding.buttonAudio.setImageResource(R.drawable.baseline_mic_24); - binding.textAudio.setText(R.string.tv_send_audio); - binding.textAudio.clearAnimation(); - createAudioDialog(); - } - - private void sendAudio() { - if (fileName != null) { - Single<File> file = Single.just(fileName); - fileName = null; - startFileSend(file.flatMapCompletable(this::sendFile)); - } - } - - private void startFileSend(Completable op) { - op.observeOn(AndroidSchedulers.mainThread()) - .subscribe(() -> { - }, e -> { - Log.e(TAG, "startFileSend: not able to create cache file", e); - displayErrorToast(Error.INVALID_FILE); - }); - } - - private Completable sendFile(File file) { - return Completable.fromAction(() -> presenter.sendFile(file)); - } - - public void updatePosition(int position) { - mSelectedPosition = position; - } - - public void updateAdapterItem() { - if (mSelectedPosition != -1) { - mAdapter.notifyItemChanged(mSelectedPosition); - mSelectedPosition = -1; - } - } - - private void scrollToTop() { - if (mAdapter.getItemCount() > 0) { - binding.recyclerView.scrollToPosition(mAdapter.getItemCount() - 1); - } - } - - @Override - public void displayContact(Conversation conversation) { - List<Contact> contacts = conversation.getContacts(); - mCompositeDisposable.clear(); - mCompositeDisposable.add(AvatarFactory.getAvatar(requireContext(), conversation, true) - .doOnSuccess(d -> { - mConversationAvatar = (AvatarDrawable) d; - mParticipantAvatars.put(contacts.get(0).getPrimaryNumber(), - new AvatarDrawable((AvatarDrawable) d)); - }) - .flatMapObservable(d -> contacts.get(0).getUpdatesSubject()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(c -> { - String id = c.getRingUsername(); - String displayName = c.getDisplayName(); - binding.title.setText(displayName); - if (TextUtils.isEmpty(displayName) || !displayName.equals(id)) - binding.subtitle.setText(id); - else - binding.subtitle.setVisibility(View.GONE); - mConversationAvatar.update(c); - String uri = contacts.get(0).getPrimaryNumber(); - AvatarDrawable a = mParticipantAvatars.get(uri); - if (a != null) - a.update(c); - mAdapter.setPhoto(); - })); - } - - @Override - public void updateElement(Interaction element) { - mAdapter.update(element); - } - - @Override - public void removeElement(Interaction element) { - mAdapter.remove(element); - } - - public AvatarDrawable getConversationAvatar(String uri) { - return mParticipantAvatars.get(uri); - } - - public void askWriteExternalStoragePermission() { - requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, JamiApplication.PERMISSIONS_REQUEST); - } - - @Override - public void scrollToEnd() { - - } - - @Override - public void updateContact(Contact contact) { - mCompositeDisposable.add(AvatarFactory.getAvatar(requireContext(), contact, true) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(avatar -> { - mParticipantAvatars.put(contact.getPrimaryNumber(), (AvatarDrawable) avatar); - mAdapter.setPhoto(); - })); - } - - @Override - public void setComposingStatus(Account.ComposingStatus composingStatus) { - - } - - @Override - public void setLastDisplayed(Interaction interaction) { - - } - - @Override - public void setConversationColor(int integer) { - - } - - @Override - public void setConversationSymbol(CharSequence symbol) { - - } - - @Override - public void startShareLocation(String accountId, String contactId) { - - } - - @Override - public void showMap(String accountId, String contactId, boolean open) { - - } - - @Override - public void hideMap() { - - } - - public void showPluginListHandlers(String accountId, String contactId) { - - } - - @Override - public void hideErrorPanel() { - - } - - @Override - public void displayNetworkErrorPanel() { - - } - - @Override - public void displayAccountOfflineErrorPanel() { - - } - - @Override - public void setReadIndicatorStatus(boolean show) { - - } - - @Override - public void updateLastRead(String last) { - - } - - @Override - public void displayOnGoingCallPane(boolean display) { - - } - - @Override - public void displayNumberSpinner(Conversation conversation, net.jami.model.Uri number) { - - } - - @Override - public void hideNumberSpinner() { - - } - - @Override - public void clearMsgEdit() { - - } - - @Override - public void goToHome() { - - } - - @Override - public void goToAddContact(Contact contact) { - - } - - @Override - public void goToCallActivity(String conferenceId) { - - } - - @Override - public void goToCallActivityWithResult(String accountId, net.jami.model.Uri conversationUri, net.jami.model.Uri contactRingId, boolean audioOnly) { - - } - - @Override - public void goToContactActivity(String accountId, net.jami.model.Uri contactRingId) { - - } - - @Override - public void switchToUnknownView(String name) { - // todo - } - - @Override - public void switchToIncomingTrustRequestView(String message) { - // todo - } - - @Override - public void switchToConversationView() { - // todo - } - - @Override - public void openFilePicker() { - - } - - @Override - public void acceptFile(String accountId, net.jami.model.Uri conversationUri, DataTransfer transfer) { - File cacheDir = requireContext().getCacheDir(); - long spaceLeft = AndroidFileUtils.getSpaceLeft(cacheDir.toString()); - if (spaceLeft == -1L || transfer.getTotalSize() > spaceLeft) { - presenter.noSpaceLeft(); - return; - } - requireActivity().startService(new Intent(DRingService.ACTION_FILE_ACCEPT) - .setClass(requireContext(), DRingService.class) - .setData(ConversationPath.toUri(accountId, conversationUri)) - .putExtra(DRingService.KEY_MESSAGE_ID, transfer.getMessageId()) - .putExtra(DRingService.KEY_TRANSFER_ID, transfer.getFileId())); - } - - @Override - public void refuseFile(String accountId, net.jami.model.Uri conversationUri, DataTransfer transfer) { - requireActivity().startService(new Intent(DRingService.ACTION_FILE_CANCEL) - .setClass(requireContext(), DRingService.class) - .setData(ConversationPath.toUri(accountId, conversationUri)) - .putExtra(DRingService.KEY_MESSAGE_ID, transfer.getMessageId()) - .putExtra(DRingService.KEY_TRANSFER_ID, transfer.getFileId())); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/tv/conversation/TvConversationFragment.kt b/ring-android/app/src/main/java/cx/ring/tv/conversation/TvConversationFragment.kt new file mode 100644 index 000000000..59771ccbe --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/conversation/TvConversationFragment.kt @@ -0,0 +1,729 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.conversation + +import android.Manifest +import android.app.Activity +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.media.MediaPlayer +import android.media.MediaRecorder +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.MediaStore +import android.speech.RecognizerIntent +import android.text.TextUtils +import android.util.Log +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.transition.TransitionManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import cx.ring.R +import cx.ring.application.JamiApplication +import cx.ring.client.MediaViewerActivity +import cx.ring.databinding.FragConversationTvBinding +import cx.ring.mvp.BaseSupportFragment +import cx.ring.service.DRingService +import cx.ring.tv.camera.CustomCameraActivity +import cx.ring.utils.AndroidFileUtils.copyFileToUri +import cx.ring.utils.AndroidFileUtils.createAudioFile +import cx.ring.utils.AndroidFileUtils.getCacheFile +import cx.ring.utils.AndroidFileUtils.getMimeTypeFromExtension +import cx.ring.utils.AndroidFileUtils.getSpaceLeft +import cx.ring.utils.ContentUriHandler +import cx.ring.utils.ContentUriHandler.getUriForFile +import cx.ring.utils.ConversationPath +import cx.ring.views.AvatarDrawable +import cx.ring.views.AvatarFactory +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.conversation.ConversationPresenter +import net.jami.conversation.ConversationView +import net.jami.model.* +import net.jami.model.Account.ComposingStatus +import net.jami.utils.StringUtils +import java.io.File +import java.io.IOException +import java.util.* + +@AndroidEntryPoint +class TvConversationFragment : BaseSupportFragment<ConversationPresenter, ConversationView>(), + ConversationView { + private var mConversationPath: ConversationPath? = null + private var mSelectedPosition = 0 + private var fileName: File? = null + private var recorder: MediaRecorder? = null + private var player: MediaPlayer? = null + var mStartRecording = true + var mStartPlaying = true + private var mAdapter: TvConversationAdapter? = null + private var mConversationAvatar: AvatarDrawable? = null + private val mParticipantAvatars: MutableMap<String, AvatarDrawable> = HashMap() + private val mCompositeDisposable = CompositeDisposable() + private var binding: FragConversationTvBinding? = null + private var mCurrentFileAbsolutePath: String? = null + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (arguments != null) { + mConversationPath = ConversationPath.fromBundle(arguments) + } + savedInstanceState?.getString(KEY_AUDIOFILE)?.let { audioFile -> fileName = File(audioFile) } + } + + override fun onSaveInstanceState(outState: Bundle) { + fileName?.let { file -> outState.putString(KEY_AUDIOFILE, file.absolutePath) } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragConversationTvBinding.inflate(inflater, container, false) + return binding!!.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + mCompositeDisposable.dispose() + } + + // Create an intent that can start the Speech Recognizer activity + private fun displaySpeechRecognizer() { + if (!checkAudioPermission()) return + try { + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + .putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM + ) + .putExtra( + RecognizerIntent.EXTRA_PROMPT, + getText(R.string.conversation_input_speech_hint) + ) + startActivityForResult(intent, REQUEST_SPEECH_CODE) + } catch (e: Exception) { + Snackbar.make(requireView(), "Can't get voice input", Snackbar.LENGTH_SHORT).show() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + //ConversationPath path = ConversationPath.fromIntent(requireActivity().getIntent()); + presenter.init(mConversationPath!!.conversationUri, mConversationPath!!.accountId) + mAdapter = TvConversationAdapter(this, presenter) + + binding!!.let { binding -> + binding.buttonText.setOnClickListener { displaySpeechRecognizer() } + binding.buttonVideo.setOnClickListener { + val intent = Intent(activity, CustomCameraActivity::class.java) + .setAction(MediaStore.ACTION_VIDEO_CAPTURE) + startActivityForResult(intent, REQUEST_CODE_PHOTO) + } + + binding.buttonAudio.setOnClickListener { + onRecord(mStartRecording) + mStartRecording = !mStartRecording + } + binding.buttonText.onFocusChangeListener = + View.OnFocusChangeListener { _, hasFocus: Boolean -> + TransitionManager.beginDelayedTransition(binding.textContainer) + binding.textText.visibility = if (hasFocus) View.VISIBLE else View.GONE + } + binding.buttonAudio.onFocusChangeListener = + View.OnFocusChangeListener { _, hasFocus: Boolean -> + TransitionManager.beginDelayedTransition(binding.audioContainer) + binding.textAudio.visibility = if (hasFocus) View.VISIBLE else View.GONE + } + binding.buttonVideo.onFocusChangeListener = + View.OnFocusChangeListener { _, hasFocus: Boolean -> + TransitionManager.beginDelayedTransition(binding.videoContainer) + binding.textVideo.visibility = if (hasFocus) View.VISIBLE else View.GONE + } + val linearLayoutManager = LinearLayoutManager(context) + linearLayoutManager.reverseLayout = true + linearLayoutManager.stackFromEnd = true + binding.recyclerView.layoutManager = linearLayoutManager + binding.recyclerView.adapter = mAdapter + } + } + + private fun checkAudioPermission(): Boolean { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(permissions, REQUEST_RECORD_AUDIO_PERMISSION) + return false + } + return true + } + + override fun onContextItemSelected(item: MenuItem): Boolean { + return if (mAdapter!!.onContextItemSelected(item)) true + else super.onContextItemSelected(item) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + REQUEST_CODE_PHOTO -> if (resultCode == Activity.RESULT_OK && data != null) { + val media = data.extras!![MediaStore.EXTRA_OUTPUT] as Uri? + val type = data.type + createMediaDialog(media, type) + } + REQUEST_SPEECH_CODE -> if (resultCode == Activity.RESULT_OK && data != null) { + val results: List<String>? = + data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + val spokenText = results!![0] + createTextDialog(spokenText) + } + REQUEST_CODE_SAVE_FILE -> { + if (resultCode == Activity.RESULT_OK) { + data?.data?.let { writeToFile(it) } + } + super.onActivityResult(requestCode, resultCode, data) + } + else -> super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun writeToFile(data: Uri) { + val path = mCurrentFileAbsolutePath ?: return + val cr = context?.contentResolver ?: return + val input = File(path) + mCompositeDisposable.add( + copyFileToUri(cr, input, data) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ Toast.makeText(context, R.string.file_saved_successfully, Toast.LENGTH_SHORT).show() }) + { Toast.makeText(context, R.string.generic_error, Toast.LENGTH_SHORT).show() }) + } + + private fun createTextDialog(spokenText: String) { + if (StringUtils.isEmpty(spokenText)) { + return + } + val alertDialog = + MaterialAlertDialogBuilder(requireContext(), R.style.Theme_MaterialComponents_Dialog) + .setTitle(spokenText) + .setMessage("") + .setPositiveButton(R.string.tv_dialog_send) { dialog: DialogInterface?, whichButton: Int -> + presenter!!.sendTextMessage( + spokenText + ) + } + .setNegativeButton(android.R.string.cancel, null) + .create() + alertDialog.window!! + .setLayout(DIALOG_WIDTH, DIALOG_HEIGHT) + alertDialog.setOwnerActivity(requireActivity()) + alertDialog.setOnShowListener { dialog: DialogInterface? -> + val positive = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) + positive.isFocusable = true + positive.isFocusableInTouchMode = true + positive.requestFocus() + } + alertDialog.show() + } + + private fun createMediaDialog(media: Uri?, type: String?) { + if (media == null) { + return + } + val activity = activity ?: return + val file = getCacheFile(activity, media) + val alertDialog = + MaterialAlertDialogBuilder(activity, R.style.Theme_MaterialComponents_Dialog) + .setTitle(if (type == CustomCameraActivity.TYPE_IMAGE) R.string.tv_send_image_dialog_message else R.string.tv_send_video_dialog_message) + .setMessage("") + .setPositiveButton(R.string.tv_dialog_send) { dialog: DialogInterface?, whichButton: Int -> + startFileSend( + file.flatMapCompletable { file: File -> sendFile(file) }) + } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.tv_media_preview, null) + .create() + alertDialog.window!! + .setLayout(DIALOG_WIDTH, DIALOG_HEIGHT) + alertDialog.setOwnerActivity(activity) + alertDialog.setOnShowListener { dialog: DialogInterface? -> + val positive = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) + positive.isFocusable = true + positive.isFocusableInTouchMode = true + positive.requestFocus() + val button = alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL) + button.setOnClickListener { v: View? -> + if (type == CustomCameraActivity.TYPE_IMAGE) { + val i = Intent(context, MediaViewerActivity::class.java) + i.setAction(Intent.ACTION_VIEW).setDataAndType(media, "image/*").flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION + startActivity(i) + } else { + val intent = Intent(Intent.ACTION_VIEW, media) + intent.setDataAndType(media, "video/*").flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION + startActivity(intent) + } + } + } + alertDialog.show() + } + + private fun createAudioDialog() { + val alertDialog = + MaterialAlertDialogBuilder(requireContext(), R.style.Theme_MaterialComponents_Dialog) + .setTitle(R.string.tv_send_audio_dialog_message) + .setMessage("") + .setPositiveButton(R.string.tv_dialog_send) { dialog: DialogInterface?, whichButton: Int -> sendAudio() } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.tv_audio_play, null) + .create() + alertDialog.window!! + .setLayout(DIALOG_WIDTH, DIALOG_HEIGHT) + alertDialog.setOwnerActivity(requireActivity()) + alertDialog.setOnShowListener { dialog: DialogInterface? -> + val positive = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) + positive.isFocusable = true + positive.isFocusableInTouchMode = true + positive.requestFocus() + val button = alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL) + button.setOnClickListener { v: View? -> + onPlay(mStartPlaying) + if (mStartPlaying) { + button.setText(R.string.tv_audio_pause) + if (player != null) { + player!!.setOnCompletionListener { mp: MediaPlayer? -> + button.setText(R.string.tv_audio_play) + mStartPlaying = true + } + } + } else { + button.setText(R.string.tv_audio_play) + } + mStartPlaying = !mStartPlaying + } + } + alertDialog.show() + } + + override fun addElement(element: Interaction) { + mAdapter!!.add(element) + scrollToTop() + } + + override fun shareFile(path: File, displayName: String) { + val c = context ?: return + try { + val fileUri = getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, path) + if (fileUri != null) { + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_SEND + sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val type = c.contentResolver.getType(fileUri) + sendIntent.setDataAndType(fileUri, type) + sendIntent.putExtra(Intent.EXTRA_STREAM, fileUri) + startActivity(Intent.createChooser(sendIntent, null)) + } + } catch (e: Exception) { + Snackbar.make( + requireView(), + "Error sharing file: " + e.localizedMessage, + Snackbar.LENGTH_SHORT + ).show() + } + } + + override fun openFile(path: File, displayName: String) { + val c = context ?: return + try { + val fileUri = getUriForFile(c, ContentUriHandler.AUTHORITY_FILES, path) + if (fileUri != null) { + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_VIEW + sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val type = c.contentResolver.getType(fileUri) + sendIntent.setDataAndType(fileUri, type) + sendIntent.putExtra(Intent.EXTRA_STREAM, fileUri) + startActivity(Intent.createChooser(sendIntent, null)) + } + } catch (e: IllegalArgumentException) { + Snackbar.make( + requireView(), + "Error opening file: " + e.localizedMessage, + Snackbar.LENGTH_SHORT + ).show() + } + } + + /** + * Creates an intent using Android Storage Access Framework + * This intent is then received by applications that can handle it like + * Downloads or Google drive + * @param file DataTransfer of the file that is going to be stored + * @param currentFileAbsolutePath absolute path of the file we want to save + */ + override fun startSaveFile(file: DataTransfer, currentFileAbsolutePath: String) { + mCurrentFileAbsolutePath = currentFileAbsolutePath + try { + // Use Android Storage File Access to download the file + val downloadFileIntent = Intent(Intent.ACTION_CREATE_DOCUMENT) + downloadFileIntent.type = getMimeTypeFromExtension(file.extension) + downloadFileIntent.addCategory(Intent.CATEGORY_OPENABLE) + downloadFileIntent.putExtra(Intent.EXTRA_TITLE, file.displayName) + startActivityForResult(downloadFileIntent, REQUEST_CODE_SAVE_FILE) + } catch (e: Exception) { + Log.i(TAG, "No app detected for saving files.") + val directory = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!directory.exists()) { + directory.mkdirs() + } + writeToFile(Uri.fromFile(File(directory, file.displayName))) + } + } + + override fun refreshView(interactions: List<Interaction>) { + if (mAdapter != null) { + mAdapter!!.updateDataset(interactions) + } + requireActivity().invalidateOptionsMenu() + } + + override fun onStop() { + releaseRecorder() + super.onStop() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array<String>, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_RECORD_AUDIO_PERMISSION) { + // NOOP + } + } + + private fun onRecord(start: Boolean) { + if (start) { + startRecording() + } else { + stopRecording() + } + } + + private fun onPlay(start: Boolean) { + if (start) { + startPlaying() + } else { + stopPlaying() + } + } + + private fun startPlaying() { + if (fileName == null) return + player = MediaPlayer() + try { + player!!.setDataSource(fileName!!.absolutePath) + player!!.prepare() + player!!.start() + } catch (e: IOException) { + Log.e(TAG, "prepare() failed") + } + } + + private fun stopPlaying() { + player!!.release() + player = null + } + + private fun startRecording() { + if (!checkAudioPermission()) return + if (recorder != null) { + return + } + try { + fileName = createAudioFile(requireContext()) + recorder = MediaRecorder() + recorder!!.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder!!.setOutputFile(fileName!!.absolutePath) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + recorder!!.setOutputFormat(MediaRecorder.OutputFormat.OGG) + recorder!!.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS) + } else { + recorder!!.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder!!.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + } + recorder!!.prepare() + recorder!!.start() + } catch (e: Exception) { + Toast.makeText( + requireContext(), + "Error starting recording: " + e.localizedMessage, + Toast.LENGTH_LONG + ).show() + recorder?.let { rec -> + rec.release() + recorder = null + } + return + } + binding!!.buttonAudio.setImageResource(R.drawable.lb_ic_stop) + binding!!.textAudio.setText(R.string.tv_audio_recording) + val anim: Animation = AlphaAnimation(0.0f, 1.0f) + anim.duration = 500 + anim.startOffset = 100 + anim.repeatMode = Animation.REVERSE + anim.repeatCount = Animation.INFINITE + binding!!.textAudio.startAnimation(anim) + } + + private fun releaseRecorder() { + if (recorder != null) { + try { + recorder!!.stop() + } catch (e: Exception) { + Log.w(TAG, "Exception stopping recorder") + } + recorder!!.release() + recorder = null + } + } + + private fun stopRecording() { + releaseRecorder() + binding!!.buttonAudio.setImageResource(R.drawable.baseline_androidtv_message_audio) + binding!!.textAudio.setText(R.string.tv_send_audio) + binding!!.textAudio.clearAnimation() + createAudioDialog() + } + + private fun sendAudio() { + val file = fileName + if (file != null) { + val singleFile = Single.just(file) + fileName = null + startFileSend(singleFile.flatMapCompletable { f -> sendFile(f) }) + } + } + + private fun startFileSend(op: Completable) { + op.observeOn(AndroidSchedulers.mainThread()) + .subscribe({}) { e: Throwable? -> + Log.e(TAG, "startFileSend: not able to create cache file", e) + displayErrorToast(Error.INVALID_FILE) + } + } + + private fun sendFile(file: File): Completable { + return Completable.fromAction { presenter!!.sendFile(file) } + } + + fun updatePosition(position: Int) { + mSelectedPosition = position + } + + fun updateAdapterItem() { + if (mSelectedPosition != -1) { + mAdapter!!.notifyItemChanged(mSelectedPosition) + mSelectedPosition = -1 + } + } + + private fun scrollToTop() { + if (mAdapter!!.itemCount > 0) { + binding!!.recyclerView.scrollToPosition(mAdapter!!.itemCount - 1) + } + } + + override fun displayContact(conversation: Conversation) { + val contacts = conversation.contacts + mCompositeDisposable.clear() + mCompositeDisposable.add(AvatarFactory.getAvatar(requireContext(), conversation, true) + .doOnSuccess { d: Drawable? -> + mConversationAvatar = d as AvatarDrawable? + mParticipantAvatars[contacts[0].primaryNumber] = AvatarDrawable(d!!) + } + .flatMapObservable { d: Drawable? -> contacts[0].updatesSubject } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { c: Contact -> + val id = c.ringUsername + val displayName = c.displayName + if (binding != null) { + binding!!.title.text = displayName + if (TextUtils.isEmpty(displayName) || displayName != id) binding!!.subtitle.text = + id else binding!!.subtitle.visibility = View.GONE + } + mConversationAvatar!!.update(c) + val uri = contacts[0].primaryNumber + val a = mParticipantAvatars[uri] + a?.update(c) + if (mAdapter != null) mAdapter!!.setPhoto() + }) + } + + override fun updateElement(element: Interaction) { + mAdapter!!.update(element) + } + + override fun removeElement(element: Interaction) { + mAdapter!!.remove(element) + } + + fun getConversationAvatar(uri: String): AvatarDrawable? { + return mParticipantAvatars[uri] + } + + override fun askWriteExternalStoragePermission() { + requestPermissions( + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + JamiApplication.PERMISSIONS_REQUEST + ) + } + + override fun scrollToEnd() {} + override fun updateContact(contact: Contact) { + mCompositeDisposable.add(AvatarFactory.getAvatar(requireContext(), contact, true) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { avatar: Drawable -> + mParticipantAvatars[contact.primaryNumber] = avatar as AvatarDrawable + mAdapter!!.setPhoto() + }) + } + + override fun setComposingStatus(composingStatus: ComposingStatus) {} + override fun setLastDisplayed(interaction: Interaction) {} + override fun setConversationColor(integer: Int) {} + override fun setConversationSymbol(symbol: CharSequence) {} + override fun startShareLocation(accountId: String, contactId: String) {} + override fun showMap(accountId: String, contactId: String, open: Boolean) {} + override fun hideMap() {} + override fun showPluginListHandlers(accountId: String, contactId: String) {} + override fun hideErrorPanel() {} + override fun displayNetworkErrorPanel() {} + override fun displayAccountOfflineErrorPanel() {} + override fun setReadIndicatorStatus(show: Boolean) {} + override fun updateLastRead(last: String) {} + override fun displayOnGoingCallPane(display: Boolean) {} + override fun displayNumberSpinner(conversation: Conversation, number: net.jami.model.Uri) {} + override fun hideNumberSpinner() {} + override fun clearMsgEdit() {} + override fun goToHome() {} + override fun goToAddContact(contact: Contact) {} + override fun goToCallActivity(conferenceId: String) {} + override fun goToCallActivityWithResult( + accountId: String, + conversationUri: net.jami.model.Uri, + contactRingId: net.jami.model.Uri, + audioOnly: Boolean + ) { + } + + override fun goToContactActivity(accountId: String, contactRingId: net.jami.model.Uri) {} + override fun switchToUnknownView(name: String) { + // todo + } + + override fun switchToIncomingTrustRequestView(message: String) { + // todo + } + + override fun switchToConversationView() { + // todo + } + + override fun switchToSyncingView() { + // todo + } + + override fun switchToEndedView() { + // todo + } + + override fun openFilePicker() {} + override fun acceptFile( + accountId: String, + conversationUri: net.jami.model.Uri, + transfer: DataTransfer + ) { + val cacheDir = requireContext().cacheDir + val spaceLeft = getSpaceLeft(cacheDir.toString()) + if (spaceLeft == -1L || transfer.totalSize > spaceLeft) { + presenter!!.noSpaceLeft() + return + } + requireActivity().startService( + Intent(DRingService.ACTION_FILE_ACCEPT) + .setClass(requireContext(), DRingService::class.java) + .setData(ConversationPath.toUri(accountId, conversationUri)) + .putExtra(DRingService.KEY_MESSAGE_ID, transfer.messageId) + .putExtra(DRingService.KEY_TRANSFER_ID, transfer.fileId) + ) + } + + override fun refuseFile( + accountId: String, + conversationUri: net.jami.model.Uri, + transfer: DataTransfer + ) { + requireActivity().startService( + Intent(DRingService.ACTION_FILE_CANCEL) + .setClass(requireContext(), DRingService::class.java) + .setData(ConversationPath.toUri(accountId, conversationUri)) + .putExtra(DRingService.KEY_MESSAGE_ID, transfer.messageId) + .putExtra(DRingService.KEY_TRANSFER_ID, transfer.fileId) + ) + } + + companion object { + private val TAG = TvConversationFragment::class.java.simpleName + private const val ARG_MODEL = "model" + private const val KEY_AUDIOFILE = "audiofile" + private const val REQUEST_CODE_PHOTO = 101 + private const val REQUEST_SPEECH_CODE = 102 + private const val REQUEST_CODE_SAVE_FILE = 103 + private const val DIALOG_WIDTH = 900 + private const val DIALOG_HEIGHT = 400 + private val permissions = arrayOf(Manifest.permission.RECORD_AUDIO) + private const val REQUEST_RECORD_AUDIO_PERMISSION = 200 + + fun newInstance(args: Bundle?): TvConversationFragment { + return TvConversationFragment().apply { + arguments = args + } + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/main/BaseBrowseFragment.java b/ring-android/app/src/main/java/cx/ring/tv/main/BaseBrowseFragment.java index 67bfe3b32..55cb16974 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/main/BaseBrowseFragment.java +++ b/ring-android/app/src/main/java/cx/ring/tv/main/BaseBrowseFragment.java @@ -20,6 +20,8 @@ package cx.ring.tv.main; import android.os.Bundle; + +import androidx.annotation.NonNull; import androidx.leanback.app.BrowseSupportFragment; import android.view.View; import android.widget.Toast; @@ -39,7 +41,7 @@ public class BaseBrowseFragment<T extends RootPresenter> extends BrowseSupportFr protected T presenter; @Override - public void onViewCreated(View view, Bundle savedInstanceState) { + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); //Be sure to do the injection in onCreateView method presenter.bindView(this); diff --git a/ring-android/app/src/main/java/cx/ring/tv/main/HomeActivity.java b/ring-android/app/src/main/java/cx/ring/tv/main/HomeActivity.java index d1b351edc..61368718e 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/main/HomeActivity.java +++ b/ring-android/app/src/main/java/cx/ring/tv/main/HomeActivity.java @@ -4,6 +4,7 @@ * Author: Michel Schmit <michel.schmit@savoirfairelinux.com> * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,9 +21,24 @@ */ package cx.ring.tv.main; +import android.content.Context; import android.content.Intent; +import android.graphics.Bitmap; +import android.hardware.Camera; +import android.hardware.camera2.CameraManager; import android.os.Bundle; +import android.renderscript.Allocation; +import android.renderscript.Element; +import android.renderscript.RenderScript; +import android.renderscript.ScriptIntrinsicBlur; +import android.renderscript.ScriptIntrinsicYuvToRGB; +import android.renderscript.Type; +import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentActivity; import androidx.leanback.app.BackgroundManager; import androidx.leanback.app.GuidedStepSupportFragment; @@ -34,28 +50,111 @@ import javax.inject.Inject; import cx.ring.R; import cx.ring.application.JamiApplication; import cx.ring.tv.account.TVAccountWizard; +import dagger.hilt.android.AndroidEntryPoint; +import cx.ring.tv.camera.CameraPreview; +import cx.ring.tv.contact.TVContactFragment; +import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.schedulers.Schedulers; +@AndroidEntryPoint public class HomeActivity extends FragmentActivity { - private BackgroundManager mBackgroundManager; - private final CompositeDisposable mDisposable = new CompositeDisposable(); + + private static final String TAG = HomeActivity.class.getSimpleName(); + + private static final float BITMAP_SCALE = 0.4f; + private static final float BLUR_RADIUS = 7.5f; + + private final CompositeDisposable mDisposableBag = new CompositeDisposable(); + @Inject AccountService mAccountService; + private BackgroundManager mBackgroundManager; + private ImageView mBlurImage; + private FrameLayout mPreviewView; + private View mFadeView; + private Camera mCamera; + private CameraPreview mCameraPreview; + private CameraManager mCameraManager; + + private Bitmap mBlurOutputBitmap; + private RenderScript rs; + private ScriptIntrinsicYuvToRGB yuvToRgbIntrinsic; + private ScriptIntrinsicBlur blurIntrinsic; + private Allocation in, out, mBlurOut; + + private final Camera.ErrorCallback mErrorCallback = new Camera.ErrorCallback() { + @Override + public void onError(int error, Camera camera) { + mBlurImage.setVisibility(View.INVISIBLE); + mBackgroundManager.setDrawable(ContextCompat.getDrawable(HomeActivity.this, R.drawable.tv_background)); + } + }; + + private final Object mCameraAvailabilityCallback = new CameraManager.AvailabilityCallback() { + @Override + public void onCameraAvailable(String cameraId) { + if (mBlurImage.getVisibility() == View.INVISIBLE) { + setUpCamera(); + } + } + }; + + private final Camera.PreviewCallback mPreviewCallback = new Camera.PreviewCallback() { + @Override + public void onPreviewFrame(byte[] data, Camera camera) { + if (getSupportFragmentManager().findFragmentByTag(TVContactFragment.TAG) != null) { + mBlurImage.setVisibility(View.GONE); + mFadeView.setVisibility(View.GONE); + mPreviewView.setVisibility(View.VISIBLE); + return; + } + if (mBlurOutputBitmap == null) { + Camera.Size size = camera.getParameters().getPreviewSize(); + rs = RenderScript.create(HomeActivity.this); + Type yuvType = new Type.Builder(rs, Element.U8(rs)).setX(data.length).create(); + in = Allocation.createTyped(rs, yuvType, Allocation.USAGE_SCRIPT); + yuvToRgbIntrinsic = ScriptIntrinsicYuvToRGB.create(rs, Element.U8_4(rs)); + yuvToRgbIntrinsic.setInput(in); + Type rgbaType = new Type.Builder(rs, Element.RGBA_8888(rs)).setX(size.width).setY(size.height).create(); + out = Allocation.createTyped(rs, rgbaType, Allocation.USAGE_SCRIPT); + blurIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); + blurIntrinsic.setRadius(BLUR_RADIUS * size.width / 1080); + blurIntrinsic.setInput(out); + mBlurOutputBitmap = Bitmap.createBitmap(size.width, size.height, Bitmap.Config.ARGB_8888); + mBlurOut = Allocation.createFromBitmap(rs, mBlurOutputBitmap); + } + in.copyFrom(data); + yuvToRgbIntrinsic.forEach(out); + blurIntrinsic.forEach(mBlurOut); + mBlurOut.copyTo(mBlurOutputBitmap); + mBlurImage.setImageBitmap(mBlurOutputBitmap); + if (mBlurImage.getVisibility() == View.GONE) { + mPreviewView.setVisibility(View.INVISIBLE); + mBlurImage.setVisibility(View.VISIBLE); + mFadeView.setVisibility(View.VISIBLE); + } + } + }; + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); JamiApplication.getInstance().startDaemon(); - JamiApplication.getInstance().getInjectionComponent().inject(this); setContentView(R.layout.tv_activity_home); mBackgroundManager = BackgroundManager.getInstance(this); mBackgroundManager.attach(getWindow()); + mPreviewView = findViewById(R.id.previewView); + mBlurImage = findViewById(R.id.blur); + mFadeView = findViewById(R.id.fade); } @Override public void onBackPressed() { if (GuidedStepSupportFragment.getCurrentGuidedStepSupportFragment(getSupportFragmentManager()) != null) { +// mIsContactFragmentVisible = false; getSupportFragmentManager().popBackStack(); } else { super.onBackPressed(); @@ -65,9 +164,8 @@ public class HomeActivity extends FragmentActivity { @Override protected void onResume() { super.onResume(); - mBackgroundManager.setDrawable(getDrawable(R.drawable.tv_background)); - mDisposable.clear(); - mDisposable.add(mAccountService.getObservableAccountList() + //mDisposable.clear(); + mDisposableBag.add(mAccountService.getObservableAccountList() .observeOn(AndroidSchedulers.mainThread()) .firstElement() .subscribe(accounts -> { @@ -76,4 +174,94 @@ public class HomeActivity extends FragmentActivity { } })); } + + @Override + protected void onPostResume() { + super.onPostResume(); + setUpCamera(); + } + + @Override + protected void onPause() { + super.onPause(); + if (mCameraPreview != null) { + mCamera.setPreviewCallback(null); + mCameraPreview.stop(); + mCameraPreview = null; + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mDisposableBag.dispose(); + if (mCameraManager != null) { + mCameraManager.unregisterAvailabilityCallback((CameraManager.AvailabilityCallback) mCameraAvailabilityCallback); + } + if (mCamera != null) { + mCamera.release(); + mCamera = null; + } + if (mBlurOutputBitmap != null){ + in.destroy(); + in = null; + out.destroy(); + out = null; + blurIntrinsic.destroy(); + blurIntrinsic = null; + mBlurOut.destroy(); + mBlurOut = null; + yuvToRgbIntrinsic.destroy(); + yuvToRgbIntrinsic = null; + mBlurOutputBitmap.recycle(); + mBlurOutputBitmap = null; + rs.destroy(); + rs = null; + } + } + + private Camera getCameraInstance() { + try { + int currentCamera = 0; + mCamera = Camera.open(currentCamera); + } + catch (RuntimeException e) { + Log.e(TAG, "failed to open camera"); + } + return mCamera; + } + + private void setUpCamera() { + mDisposableBag.add(Single.fromCallable(this::getCameraInstance) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(camera -> { + Log.w(TAG, "setUpCamera()"); + Camera.Parameters params = camera.getParameters(); + Camera.Size selectSize = null; + for (Camera.Size size : params.getSupportedPictureSizes()) { + if (size.width == 1280 && size.height == 720) { + selectSize = size; + break; + } + } + if (selectSize == null) + throw new IllegalStateException("No supported size"); + Log.w(TAG, "setUpCamera() selectSize " + selectSize.width + "x" + selectSize.height); + params.setPictureSize(selectSize.width, selectSize.height); + params.setPreviewSize(selectSize.width, selectSize.height); + camera.setParameters(params); + mBlurImage.setVisibility(View.VISIBLE); + if (mCameraManager == null) { + mCameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE); + mCameraManager.registerAvailabilityCallback((CameraManager.AvailabilityCallback) mCameraAvailabilityCallback, null); + } + mCameraPreview = new CameraPreview(this, camera); + mPreviewView.removeAllViews(); + mPreviewView.addView(mCameraPreview, 0); + camera.setErrorCallback(mErrorCallback); + camera.setPreviewCallback(mPreviewCallback); + }, e -> mBackgroundManager.setDrawable(ContextCompat.getDrawable(HomeActivity.this, R.drawable.tv_background)))); + } + } \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/main/MainFragment.java b/ring-android/app/src/main/java/cx/ring/tv/main/MainFragment.java deleted file mode 100644 index 93a909e58..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/main/MainFragment.java +++ /dev/null @@ -1,496 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com>s - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.tv.main; - -import android.app.Activity; -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.net.Uri; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; - -import androidx.annotation.NonNull; -import androidx.core.app.ActivityOptionsCompat; -import androidx.core.content.FileProvider; -import androidx.leanback.app.GuidedStepSupportFragment; -import androidx.leanback.widget.ArrayObjectAdapter; -import androidx.leanback.widget.HeaderItem; -import androidx.leanback.widget.ImageCardView; -import androidx.leanback.widget.ListRow; -import androidx.leanback.widget.OnItemViewClickedListener; -import androidx.leanback.widget.Presenter; -import androidx.leanback.widget.Row; -import androidx.leanback.widget.RowPresenter; -import androidx.tvprovider.media.tv.Channel; -import androidx.tvprovider.media.tv.ChannelLogoUtils; -import androidx.tvprovider.media.tv.PreviewProgram; -import androidx.tvprovider.media.tv.TvContractCompat; - -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.views.AvatarFactory; - -import net.jami.model.Account; -import net.jami.navigation.HomeNavigationViewModel; -import cx.ring.services.VCardServiceImpl; -import net.jami.smartlist.SmartListViewModel; -import cx.ring.tv.about.AboutActivity; -import cx.ring.tv.account.TVAccountExport; -import cx.ring.tv.account.TVProfileEditingFragment; -import cx.ring.tv.account.TVSettingsActivity; -import cx.ring.tv.account.TVShareActivity; -import cx.ring.tv.call.TVCallActivity; -import cx.ring.tv.cards.Card; -import cx.ring.tv.cards.CardListRow; -import cx.ring.tv.cards.CardPresenterSelector; -import cx.ring.tv.cards.CardRow; -import cx.ring.tv.cards.ShadowRowPresenterSelector; -import cx.ring.tv.cards.contacts.ContactCard; -import cx.ring.tv.cards.iconcards.IconCard; -import cx.ring.tv.cards.iconcards.IconCardHelper; -import cx.ring.tv.contact.TVContactActivity; -import cx.ring.tv.search.SearchActivity; -import cx.ring.tv.views.CustomTitleView; -import cx.ring.utils.AndroidFileUtils; -import cx.ring.utils.BitmapUtils; -import cx.ring.utils.ContentUriHandler; -import cx.ring.utils.ConversationPath; -import net.jami.utils.QRCodeUtils; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class MainFragment extends BaseBrowseFragment<MainPresenter> implements MainView { - - private static final String TAG = MainFragment.class.getSimpleName(); - // Sections headers ids - private static final long HEADER_CONTACTS = 0; - private static final long HEADER_MISC = 1; - private static final int TRUST_REQUEST_ROW_POSITION = 1; - private static final int QR_ITEM_POSITION = 2; - - private static final String PREFERENCES_CHANNELS = "channels"; - private static final String KEY_CHANNEL_CONVERSATIONS = "conversations"; - - private static final Uri HOME_URI = new Uri.Builder() - .scheme(ContentUriHandler.SCHEME_TV) - .authority(ContentUriHandler.AUTHORITY) - .appendPath(ContentUriHandler.PATH_TV_HOME) - .build(); - - private SpinnerFragment mSpinnerFragment; - private ArrayObjectAdapter mRowsAdapter; - private ArrayObjectAdapter cardRowAdapter; - private ArrayObjectAdapter contactRequestRowAdapter; - private CustomTitleView titleView; - private CardListRow requestsRow; - private CardPresenterSelector selector; - private IconCard qrCard = null; - private ListRow myAccountRow; - private final CompositeDisposable mDisposable = new CompositeDisposable(); - private final CompositeDisposable mHomeChannelDisposable = new CompositeDisposable(); - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - return super.onCreateView(inflater, container, savedInstanceState); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - titleView = view.findViewById(R.id.browse_title_group); - super.onViewCreated(view, savedInstanceState); - setupUIElements(requireActivity()); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - mDisposable.clear(); - } - - private void setupUIElements(@NonNull Activity activity) { - selector = new CardPresenterSelector(activity); - // over title - setHeadersState(HEADERS_ENABLED); - setHeadersTransitionOnBackEnabled(true); - - // set fastLane (or headers) background color - setBrandColor(getResources().getColor(R.color.color_primary_dark)); - // set search icon color - setSearchAffordanceColor(getResources().getColor(R.color.color_primary_light)); - - mRowsAdapter = new ArrayObjectAdapter(new ShadowRowPresenterSelector()); - - /* Contact Presenter */ - CardRow contactRow = new CardRow( - CardRow.TYPE_DEFAULT, - true, - getString(R.string.tv_contact_row_header), - new ArrayList<>()); - HeaderItem cardPresenterHeader = new HeaderItem(HEADER_CONTACTS, getString(R.string.tv_contact_row_header)); - cardRowAdapter = new ArrayObjectAdapter(selector); - - CardListRow contactListRow = new CardListRow(cardPresenterHeader, cardRowAdapter, contactRow); - - /* CardPresenter */ - mRowsAdapter.add(contactListRow); - myAccountRow = createMyAccountRow(activity); - mRowsAdapter.add(myAccountRow); - mRowsAdapter.add(createAboutCardRow(activity)); - setAdapter(mRowsAdapter); - - // listeners - setOnSearchClickedListener(view -> startActivity(new Intent(getActivity(), SearchActivity.class))); - setOnItemViewClickedListener(new ItemViewClickedListener()); - } - - private ListRow createRow(String titleSection, List<Card> cards, boolean shadow) { - CardRow row = new CardRow( - CardRow.TYPE_DEFAULT, - shadow, - titleSection, - cards); - - ArrayObjectAdapter listRowAdapter = new ArrayObjectAdapter(selector); - for (Card card : cards) { - listRowAdapter.add(card); - } - - return new CardListRow(new HeaderItem(HEADER_MISC, titleSection), listRowAdapter, row); - } - - private ListRow createMyAccountRow(@NonNull Context context) { - qrCard = IconCardHelper.getAccountShareCard(context, null); - List<Card> cards = new ArrayList<>(4); - cards.add(IconCardHelper.getAccountAddDeviceCard(context)); - cards.add(IconCardHelper.getAccountManagementCard(context)); - cards.add(qrCard); - cards.add(IconCardHelper.getAccountSettingsCard(context)); - return createRow(getString(R.string.ring_account), cards, false); - } - - private CardListRow createContactRequestRow() { - CardRow contactRequestRow = new CardRow( - CardRow.TYPE_DEFAULT, - true, - getString(R.string.menu_item_contact_request), - new ArrayList<ContactCard>()); - - contactRequestRowAdapter = new ArrayObjectAdapter(selector); - - return new CardListRow(new HeaderItem(HEADER_MISC, getString(R.string.menu_item_contact_request)), - contactRequestRowAdapter, - contactRequestRow); - } - - private Row createAboutCardRow(@NonNull Context context) { - List<Card> cards = new ArrayList<>(3); - cards.add(IconCardHelper.getVersionCard(context)); - cards.add(IconCardHelper.getLicencesCard(context)); - cards.add(IconCardHelper.getContributorCard(context)); - return createRow(getString(R.string.menu_item_about), cards, false); - } - - @Override - public void showLoading(final boolean show) { - if (show) { - mSpinnerFragment = new SpinnerFragment(); - getParentFragmentManager().beginTransaction().replace(R.id.main_browse_fragment, mSpinnerFragment).commitAllowingStateLoss(); - } else { - getParentFragmentManager().beginTransaction().remove(mSpinnerFragment).commitAllowingStateLoss(); - } - } - - @Override - public void refreshContact(final int index, final SmartListViewModel contact) { - ContactCard contactCard = (ContactCard) cardRowAdapter.get(index); - contactCard.setModel(contact); - cardRowAdapter.replace(index, contactCard); - } - - @Override - public void showContacts(final List<SmartListViewModel> contacts) { - List<ContactCard> cards = new ArrayList<>(contacts.size()); - for (SmartListViewModel contact : contacts) - cards.add(new ContactCard(contact)); - cardRowAdapter.setItems(cards, null); - buildHomeChannel(requireContext().getApplicationContext(), contacts); - } - - private static long createHomeChannel(Context context) { - Channel channel = new Channel.Builder() - .setType(TvContractCompat.Channels.TYPE_PREVIEW) - .setDisplayName(context.getString(R.string.navigation_item_conversation)) - .setAppLinkIntentUri(HOME_URI) - .build(); - ContentResolver cr = context.getContentResolver(); - SharedPreferences sharedPref = context.getSharedPreferences(PREFERENCES_CHANNELS, Context.MODE_PRIVATE); - long channelId = sharedPref.getLong(KEY_CHANNEL_CONVERSATIONS, -1); - if (channelId == -1) { - Uri channelUri = cr.insert(TvContractCompat.Channels.CONTENT_URI, channel.toContentValues()); - channelId = ContentUris.parseId(channelUri); - sharedPref.edit().putLong(KEY_CHANNEL_CONVERSATIONS, channelId).apply(); - int targetSize = (int) (AvatarFactory.SIZE_NOTIF * context.getResources().getDisplayMetrics().density); - int targetPaddingSize = (int) (AvatarFactory.SIZE_PADDING * context.getResources().getDisplayMetrics().density); - ChannelLogoUtils.storeChannelLogo(context, channelId, BitmapUtils.drawableToBitmap(context.getDrawable(R.drawable.ic_jami_48), targetSize, targetPaddingSize)); - TvContractCompat.requestChannelBrowsable(context, channelId); - } else { - cr.update(TvContractCompat.buildChannelUri(channelId), channel.toContentValues(), null, null); - } - return channelId; - } - - private static Single<PreviewProgram> buildProgram(Context context, SmartListViewModel vm, String launcherName, long channelId) { - return new AvatarDrawable.Builder() - .withViewModel(vm) - .withPresence(false) - .buildAsync(context) - .map(avatar -> { - File file = AndroidFileUtils.createImageFile(context); - Bitmap bitmapAvatar = BitmapUtils.drawableToBitmap(avatar, 256); - try (OutputStream os = new BufferedOutputStream(new FileOutputStream(file))) { - bitmapAvatar.compress(Bitmap.CompressFormat.PNG, 100, os); - } - bitmapAvatar.recycle(); - Uri uri = FileProvider.getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, file); - - // Grant permission to launcher - if (launcherName != null) - context.grantUriPermission(launcherName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - - PreviewProgram.Builder contactBuilder = new PreviewProgram.Builder() - .setChannelId(channelId) - .setType(TvContractCompat.PreviewPrograms.TYPE_CLIP) - .setTitle(vm.getContactName()) - .setAuthor(vm.getContacts().get(0).getRingUsername()) - .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_1_1) - .setPosterArtUri(uri) - .setIntentUri(new Uri.Builder() - .scheme(ContentUriHandler.SCHEME_TV) - .authority(ContentUriHandler.AUTHORITY) - .appendPath(ContentUriHandler.PATH_TV_CONVERSATION) - .appendPath(vm.getAccountId()) - .appendPath(vm.getUri().getUri()) - .build()) - .setInternalProviderId(vm.getUuid()); - return contactBuilder.build(); - }); - } - - private void buildHomeChannel(Context context, List<SmartListViewModel> contacts) { - if (contacts.isEmpty()) - return; - - // Get launcher package name - ResolveInfo resolveInfo = context.getPackageManager().resolveActivity( - new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), PackageManager.MATCH_DEFAULT_ONLY); - String launcherName = resolveInfo == null ? null : resolveInfo.activityInfo.packageName; - - ContentResolver cr = context.getContentResolver(); - - mHomeChannelDisposable.clear(); - mHomeChannelDisposable.add(Single.fromCallable(() -> createHomeChannel(context)) - .doOnSuccess(channelId -> cr.delete(TvContractCompat.buildPreviewProgramsUriForChannel(channelId), null, null)) - .flatMapObservable(channelId -> Observable.fromIterable(contacts) - .concatMapEager(contact -> buildProgram(context, contact, launcherName, channelId) - .toObservable() - .subscribeOn(Schedulers.io()), 8, 1)) - .subscribeOn(Schedulers.io()) - .subscribe(program -> cr.insert(TvContractCompat.PreviewPrograms.CONTENT_URI, program.toContentValues()), - e -> Log.w(TAG, "Error updating home channel", e))); - } - - @Override - public void showContactRequests(final List<SmartListViewModel> contacts) { - CardListRow row = (CardListRow) mRowsAdapter.get(TRUST_REQUEST_ROW_POSITION); - boolean isRowDisplayed = row != null && row == requestsRow; - - List<ContactCard> cards = new ArrayList<>(contacts.size()); - for (SmartListViewModel contact : contacts) - cards.add(new ContactCard(contact)); - - if (isRowDisplayed && contacts.isEmpty()) { - mRowsAdapter.removeItems(TRUST_REQUEST_ROW_POSITION, 1); - } else if (!contacts.isEmpty()) { - if (requestsRow == null) - requestsRow = createContactRequestRow(); - contactRequestRowAdapter.setItems(cards, null); - if (!isRowDisplayed) - mRowsAdapter.add(TRUST_REQUEST_ROW_POSITION, requestsRow); - } - } - - @Override - public void callContact(String accountID, String number) { - Intent intent = new Intent(Intent.ACTION_CALL, ConversationPath.toUri(accountID, number), getActivity(), TVCallActivity.class); - startActivity(intent, null); - } - - static private BitmapDrawable prepareAccountQr(Context context, String accountId) { - Log.w(TAG, "prepareAccountQr " + accountId); - if (TextUtils.isEmpty(accountId)) - return null; - int pad = 16; - QRCodeUtils.QRCodeData qrCodeData = QRCodeUtils.encodeStringAsQRCodeData(accountId, 0X00000000, 0xFFFFFFFF); - Bitmap bitmap = Bitmap.createBitmap(qrCodeData.getWidth() + 2 * pad, qrCodeData.getHeight() + 2 * pad, Bitmap.Config.ARGB_8888); - bitmap.setPixels(qrCodeData.getData(), 0, qrCodeData.getWidth(), pad, pad, qrCodeData.getWidth(), qrCodeData.getHeight()); - return new BitmapDrawable(context.getResources(), bitmap); - } - - @Override - public void displayAccountInfos(final HomeNavigationViewModel viewModel) { - Account account = viewModel.getAccount(); - if (account != null) - updateModel(account); - } - - @Override - public void updateModel(Account account) { - Context context = requireContext(); - String address = account.getDisplayUsername(); - mDisposable.clear(); - mDisposable.add(VCardServiceImpl - .loadProfile(context, account) - .observeOn(AndroidSchedulers.mainThread()) - .doOnSuccess(profile -> { - if (profile.first != null && !profile.first.isEmpty()) { - titleView.setAlias(profile.first); - if (address != null) { - setTitle(address); - } else { - setTitle(""); - } - } else { - titleView.setAlias(address); - } - }) - .flatMap(p -> AvatarDrawable.load(context, account)) - .subscribe(a -> { - titleView.getLogoView().setVisibility(View.VISIBLE); - titleView.getLogoView().setImageDrawable(a); - })); - qrCard.setDrawable(prepareAccountQr(context, account.getUri())); - myAccountRow.getAdapter().notifyItemRangeChanged(QR_ITEM_POSITION, 1); - } - - @Override - public void showExportDialog(String pAccountID, boolean hasPassword) { - GuidedStepSupportFragment wizard = TVAccountExport.createInstance(pAccountID, hasPassword); - GuidedStepSupportFragment.add(getParentFragmentManager(), wizard, R.id.main_browse_fragment); - } - - @Override - public void showProfileEditing() { - GuidedStepSupportFragment.add(getParentFragmentManager(), new TVProfileEditingFragment(), R.id.main_browse_fragment); - } - - @Override - public void showAccountShare() { - Intent intent = new Intent(getActivity(), TVShareActivity.class); - startActivity(intent); - } - - @Override - public void showLicence(int aboutType) { - Intent intent = new Intent(getActivity(), AboutActivity.class); - intent.putExtra("abouttype", aboutType); - startActivity(intent); - } - - @Override - public void showSettings() { - startActivity(new Intent(getActivity(), TVSettingsActivity.class)); - } - - private final class ItemViewClickedListener implements OnItemViewClickedListener { - @Override - public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item, - RowPresenter.ViewHolder rowViewHolder, Row row) { - - if (item instanceof ContactCard) { - SmartListViewModel model = ((ContactCard) item).getModel(); - if (row == requestsRow) { - Intent intent = new Intent(Intent.ACTION_VIEW, null, requireContext(), TVContactActivity.class) - .setDataAndType(ConversationPath.toUri(model.getAccountId(), model.getUri()), TVContactActivity.TYPE_CONTACT_REQUEST_INCOMING); - Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation( - requireActivity(), - ((ImageCardView) itemViewHolder.view).getMainImageView(), - TVContactActivity.SHARED_ELEMENT_NAME).toBundle(); - startActivity(intent, bundle); - } else { - Intent intent = new Intent(Intent.ACTION_VIEW, ConversationPath.toUri(model.getAccountId(), model.getUri()), getActivity(), TVContactActivity.class); - - Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), - ((ImageCardView) itemViewHolder.view).getMainImageView(), - TVContactActivity.SHARED_ELEMENT_NAME).toBundle(); - startActivity(intent, bundle); - } - } else if (item instanceof IconCard) { - IconCard card = (IconCard) item; - switch (card.getType()) { - case ABOUT_CONTRIBUTOR: - case ABOUT_LICENCES: - presenter.onLicenceClicked(card.getType().ordinal()); - break; - case ACCOUNT_ADD_DEVICE: - presenter.onExportClicked(); - break; - case ACCOUNT_EDIT_PROFILE: - presenter.onEditProfileClicked(); - break; - case ACCOUNT_SHARE_ACCOUNT: - ImageView view = ((ImageCardView) itemViewHolder.view).getMainImageView(); - Intent intent = new Intent(getActivity(), TVShareActivity.class); - Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, TVShareActivity.SHARED_ELEMENT_NAME).toBundle(); - requireActivity().startActivity(intent, bundle); - break; - case ACCOUNT_SETTINGS: - presenter.onSettingsClicked(); - break; - default: - break; - } - } - } - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/tv/main/MainFragment.kt b/ring-android/app/src/main/java/cx/ring/tv/main/MainFragment.kt new file mode 100644 index 000000000..44eceaf33 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/main/MainFragment.kt @@ -0,0 +1,423 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com>s + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.tv.main + +import android.content.* +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.FileProvider +import androidx.leanback.app.GuidedStepSupportFragment +import androidx.leanback.widget.* +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.ChannelLogoUtils +import androidx.tvprovider.media.tv.PreviewProgram +import androidx.tvprovider.media.tv.TvContractCompat +import cx.ring.R +import cx.ring.services.VCardServiceImpl.Companion.loadProfile +import cx.ring.tv.account.TVAccountExport +import cx.ring.tv.account.TVProfileEditingFragment +import cx.ring.tv.account.TVShareActivity +import cx.ring.tv.call.TVCallActivity +import cx.ring.tv.cards.* +import cx.ring.tv.cards.contacts.ContactCard +import cx.ring.tv.cards.iconcards.IconCard +import cx.ring.tv.cards.iconcards.IconCardHelper +import cx.ring.tv.contact.TVContactActivity +import cx.ring.tv.contact.TVContactFragment +import cx.ring.tv.search.SearchActivity +import cx.ring.tv.settings.TVSettingsActivity +import cx.ring.tv.views.CustomTitleView +import cx.ring.utils.AndroidFileUtils.createImageFile +import cx.ring.utils.BitmapUtils.drawableToBitmap +import cx.ring.utils.ContentUriHandler +import cx.ring.utils.ConversationPath +import cx.ring.views.AvatarDrawable +import cx.ring.views.AvatarFactory +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.model.Account +import net.jami.navigation.HomeNavigationViewModel +import net.jami.smartlist.SmartListViewModel +import net.jami.utils.QRCodeUtils +import java.io.BufferedOutputStream +import java.io.FileOutputStream +import java.util.* +import cx.ring.tv.cards.ShadowRowPresenterSelector + +import androidx.leanback.widget.ArrayObjectAdapter + +@AndroidEntryPoint +class MainFragment : BaseBrowseFragment<MainPresenter>(), MainView { + //private TVContactFragment mContactFragment; + private var mSpinnerFragment: SpinnerFragment? = null + private var cardRowAdapter: ArrayObjectAdapter? = null + private var contactRequestRowAdapter: ArrayObjectAdapter? = null + private var mTitleView: CustomTitleView? = null + private var requestsRow: CardListRow? = null + private var selector: CardPresenterSelector? = null + private var qrCard: IconCard? = null + private var accountSettingsRow: ListRow? = null + private val mDisposable = CompositeDisposable() + private val mHomeChannelDisposable = CompositeDisposable() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + headersState = HEADERS_DISABLED + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + mTitleView = view.findViewById(R.id.browse_title_group) + super.onViewCreated(view, savedInstanceState) + setupUIElements(requireActivity()) + } + + override fun onDestroyView() { + super.onDestroyView() + mDisposable.clear() + } + + private fun setupUIElements(context: Context) { + selector = CardPresenterSelector(context) + cardRowAdapter = ArrayObjectAdapter(selector) + + /* Contact Presenter */ + val contactRow = CardRow(CardRow.TYPE_DEFAULT, false, getString(R.string.tv_contact_row_header), ArrayList()) + val cardPresenterHeader = HeaderItem(HEADER_CONTACTS, getString(R.string.tv_contact_row_header)) + val contactListRow = CardListRow(cardPresenterHeader, cardRowAdapter, contactRow) + accountSettingsRow = createAccountSettingsRow(context) + adapter = ArrayObjectAdapter(ShadowRowPresenterSelector()).apply { + add(contactListRow) + add(accountSettingsRow) + } + + // listeners + setOnSearchClickedListener { startActivity(Intent(context, SearchActivity::class.java)) } + onItemViewClickedListener = ItemViewClickedListener() + mTitleView!!.settingsButton.setOnClickListener { presenter.onSettingsClicked() } + } + + private fun createRow(titleSection: String, cards: List<Card>, shadow: Boolean): ListRow { + val row = CardRow(CardRow.TYPE_DEFAULT, shadow, titleSection, cards) + val listRowAdapter = ArrayObjectAdapter(selector) + for (card in cards) { + listRowAdapter.add(card) + } + return CardListRow(HeaderItem(HEADER_MISC, titleSection), listRowAdapter, row) + } + + private fun createAccountSettingsRow(context: Context): ListRow { + val cards = ArrayList<Card>(3).apply { + add(IconCardHelper.getAccountManagementCard(context)) + add(IconCardHelper.getAccountAddDeviceCard(context)) + add(IconCardHelper.getAccountShareCard(context, null).apply { qrCard = this }) + } + return createRow(getString(R.string.account_tv_settings_header), cards, false) + } + + private fun createContactRequestRow(): CardListRow { + val contactRequestRow = CardRow(CardRow.TYPE_DEFAULT, false, getString(R.string.menu_item_contact_request), ArrayList<ContactCard>()) + contactRequestRowAdapter = ArrayObjectAdapter(selector) + return CardListRow(HeaderItem(HEADER_MISC, getString(R.string.menu_item_contact_request)), contactRequestRowAdapter, contactRequestRow) + } + + override fun showLoading(show: Boolean) { + if (show) { + mSpinnerFragment = SpinnerFragment() + parentFragmentManager.beginTransaction() + .replace(R.id.main_browse_fragment, mSpinnerFragment!!).commitAllowingStateLoss() + } else { + parentFragmentManager.beginTransaction().remove(mSpinnerFragment!!) + .commitAllowingStateLoss() + } + } + + override fun refreshContact(index: Int, contact: SmartListViewModel) { + val contactCard = cardRowAdapter!![index] as ContactCard + contactCard.model = contact + cardRowAdapter!!.replace(index, contactCard) + } + + override fun showContacts(contacts: List<SmartListViewModel>) { + val cards: MutableList<Card?> = ArrayList(contacts.size + 1) + cards.add(IconCardHelper.getAddContactCard(requireContext())) + for (contact in contacts) cards.add(ContactCard(contact)) + cardRowAdapter!!.setItems(cards, null) + buildHomeChannel(requireContext().applicationContext, contacts) + } + + private fun buildHomeChannel(context: Context, contacts: List<SmartListViewModel>) { + if (contacts.isEmpty()) return + + // Get launcher package name + val resolveInfo = context.packageManager.resolveActivity( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), + PackageManager.MATCH_DEFAULT_ONLY + ) + val launcherName = resolveInfo?.activityInfo?.packageName + val cr = context.contentResolver + mHomeChannelDisposable.clear() + mHomeChannelDisposable.add(Single.fromCallable { createHomeChannel(context) } + .doOnEvent { channelId: Long, error: Throwable? -> + if (error != null) { + Log.w(TAG, "Error creating home channel", error) + } else { + cr.delete(TvContractCompat.buildPreviewProgramsUriForChannel(channelId), null, null) + } + } + .flatMapObservable { channelId: Long -> + Observable.fromIterable(contacts) + .concatMapEager({ contact: SmartListViewModel -> + buildProgram(context, contact, launcherName, channelId) + .toObservable() + .subscribeOn(Schedulers.io()) + }, 8, 1) + } + .subscribeOn(Schedulers.io()) + .subscribe({ program -> cr.insert(TvContractCompat.PreviewPrograms.CONTENT_URI, program.toContentValues()) } + ) { e: Throwable? -> Log.w(TAG, "Error updating home channel", e) }) + } + + override fun showContactRequests(contacts: List<SmartListViewModel>) { + val adapter = adapter as ArrayObjectAdapter + val row = adapter[TRUST_REQUEST_ROW_POSITION] as CardListRow? + val isRowDisplayed = row === requestsRow + val cards: MutableList<ContactCard> = ArrayList(contacts.size) + for (contact in contacts) + cards.add(ContactCard(contact)) + if (isRowDisplayed && contacts.isEmpty()) { + adapter.removeItems(TRUST_REQUEST_ROW_POSITION, 1) + } else if (contacts.isNotEmpty()) { + if (requestsRow == null) + requestsRow = createContactRequestRow() + contactRequestRowAdapter!!.setItems(cards, null) + if (!isRowDisplayed) + adapter.add(TRUST_REQUEST_ROW_POSITION, requestsRow) + } + } + + override fun callContact(accountID: String, number: String) { + val intent = Intent(Intent.ACTION_CALL, ConversationPath.toUri(accountID, number), activity, TVCallActivity::class.java) + startActivity(intent, null) + } + + override fun displayAccountInfo(viewModel: HomeNavigationViewModel) { + updateModel(viewModel.account) + } + + override fun updateModel(account: Account) { + val context = requireContext() + val address = account.displayUsername + mDisposable.clear() + mDisposable.add(loadProfile(context, account) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { profile -> + val name = profile.first + if (name != null && name.isNotEmpty()) { + mTitleView?.setAlias(name) + title = address ?: "" + } else { + mTitleView?.setAlias(address) + } + } + .map { p -> AvatarDrawable.build(context, account, p, true) } + .subscribe { a -> + mTitleView?.apply { + settingsButton.visibility = View.VISIBLE + logoView.visibility = View.VISIBLE + logoView.setImageDrawable(a) + } + }) + qrCard!!.setDrawable(prepareAccountQr(context, account.uri)) + accountSettingsRow!!.adapter.notifyItemRangeChanged(QR_ITEM_POSITION, 1) + } + + override fun showExportDialog(pAccountID: String, hasPassword: Boolean) { + val wizard: GuidedStepSupportFragment = + TVAccountExport.createInstance(pAccountID, hasPassword) + GuidedStepSupportFragment.add(parentFragmentManager, wizard, R.id.main_browse_fragment) + } + + override fun showProfileEditing() { + GuidedStepSupportFragment.add( + parentFragmentManager, + TVProfileEditingFragment(), + R.id.main_browse_fragment + ) + } + + override fun showAccountShare() { + val intent = Intent(activity, TVShareActivity::class.java) + startActivity(intent) + } + + override fun showSettings() { + startActivity(Intent(activity, TVSettingsActivity::class.java)) + } + + private inner class ItemViewClickedListener : OnItemViewClickedListener { + override fun onItemClicked(itemViewHolder: Presenter.ViewHolder, item: Any, rowViewHolder: RowPresenter.ViewHolder, row: Row) { + if (item is ContactCard) { + val model = item.model + val bundle = ConversationPath.toBundle(model.accountId, model.uri) + if (row === requestsRow) { + bundle.putString("type", TVContactActivity.TYPE_CONTACT_REQUEST_INCOMING) + } + val mContactFragment = TVContactFragment() + mContactFragment.arguments = bundle + parentFragmentManager.beginTransaction() + .hide(this@MainFragment) + .add(R.id.fragment_container, mContactFragment, TVContactFragment.TAG) + .addToBackStack(TVContactFragment.TAG) + .commit() + } else if (item is IconCard) { + when (item.type) { + Card.Type.ACCOUNT_ADD_DEVICE -> presenter!!.onExportClicked() + Card.Type.ACCOUNT_EDIT_PROFILE -> presenter!!.onEditProfileClicked() + Card.Type.ACCOUNT_SHARE_ACCOUNT -> { + val view = (itemViewHolder.view as CardView).mainImageView + val intent = Intent(activity, TVShareActivity::class.java) + val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation( + requireActivity(), + view, + TVShareActivity.SHARED_ELEMENT_NAME + ).toBundle() + requireActivity().startActivity(intent, bundle) + } + Card.Type.ADD_CONTACT -> startActivity(Intent(activity, SearchActivity::class.java)) + else -> { + } + } + } + } + } + + companion object { + private val TAG = MainFragment::class.java.simpleName + + // Sections headers ids + private const val HEADER_CONTACTS: Long = 0 + private const val HEADER_MISC: Long = 1 + private const val TRUST_REQUEST_ROW_POSITION = 1 + private const val QR_ITEM_POSITION = 2 + private const val PREFERENCES_CHANNELS = "channels" + private const val KEY_CHANNEL_CONVERSATIONS = "conversations" + private val HOME_URI = Uri.Builder() + .scheme(ContentUriHandler.SCHEME_TV) + .authority(ContentUriHandler.AUTHORITY) + .appendPath(ContentUriHandler.PATH_TV_HOME) + .build() + + private fun createHomeChannel(context: Context): Long { + val channel = Channel.Builder() + .setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setDisplayName(context.getString(R.string.navigation_item_conversation)) + .setAppLinkIntentUri(HOME_URI) + .build() + val cr = context.contentResolver + val sharedPref = context.getSharedPreferences(PREFERENCES_CHANNELS, Context.MODE_PRIVATE) + var channelId = sharedPref.getLong(KEY_CHANNEL_CONVERSATIONS, -1) + if (channelId == -1L) { + val channelUri = cr.insert(TvContractCompat.Channels.CONTENT_URI, channel.toContentValues()) + channelId = ContentUris.parseId(channelUri!!) + sharedPref.edit().putLong(KEY_CHANNEL_CONVERSATIONS, channelId).apply() + val targetSize = (AvatarFactory.SIZE_NOTIF * context.resources.displayMetrics.density).toInt() + val targetPaddingSize = (AvatarFactory.SIZE_PADDING * context.resources.displayMetrics.density).toInt() + ChannelLogoUtils.storeChannelLogo( + context, channelId, drawableToBitmap(context.getDrawable(R.drawable.ic_jami_48)!!, targetSize, targetPaddingSize)) + TvContractCompat.requestChannelBrowsable(context, channelId) + } else { + cr.update(TvContractCompat.buildChannelUri(channelId), channel.toContentValues(), null, null) + } + return channelId + } + + private fun buildProgram( + context: Context, + vm: SmartListViewModel, + launcherName: String?, + channelId: Long + ): Single<PreviewProgram> { + return AvatarDrawable.Builder() + .withViewModel(vm) + .withPresence(false) + .buildAsync(context) + .map { avatar: AvatarDrawable? -> + val file = createImageFile(context) + val bitmapAvatar = drawableToBitmap(avatar!!, 256) + BufferedOutputStream(FileOutputStream(file)).use { os -> + bitmapAvatar.compress(Bitmap.CompressFormat.PNG, 100, os) + } + bitmapAvatar.recycle() + val uri = FileProvider.getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, file) + + // Grant permission to launcher + if (launcherName != null) context.grantUriPermission( + launcherName, + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + val contactBuilder = PreviewProgram.Builder() + .setChannelId(channelId) + .setType(TvContractCompat.PreviewPrograms.TYPE_CLIP) + .setTitle(vm.contactName) + .setAuthor(vm.contacts[0].ringUsername) + .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_1_1) + .setPosterArtUri(uri) + .setIntentUri( + Uri.Builder() + .scheme(ContentUriHandler.SCHEME_TV) + .authority(ContentUriHandler.AUTHORITY) + .appendPath(ContentUriHandler.PATH_TV_CONVERSATION) + .appendPath(vm.accountId) + .appendPath(vm.uri.uri) + .build() + ) + .setInternalProviderId(vm.uuid) + contactBuilder.build() + } + } + + private fun prepareAccountQr(context: Context, accountId: String?): BitmapDrawable? { + Log.w(TAG, "prepareAccountQr $accountId") + if (accountId == null || accountId.isEmpty()) return null + val pad = 16 + val qrCodeData = QRCodeUtils.encodeStringAsQRCodeData(accountId, 0X00000000, -0x1) + val bitmap = Bitmap.createBitmap(qrCodeData.width + 2 * pad, qrCodeData.height + 2 * pad, Bitmap.Config.ARGB_8888) + bitmap.setPixels( + qrCodeData.data, + 0, + qrCodeData.width, + pad, + pad, + qrCodeData.width, + qrCodeData.height + ) + return BitmapDrawable(context.resources, bitmap) + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/main/MainPresenter.java b/ring-android/app/src/main/java/cx/ring/tv/main/MainPresenter.java index c3515f2a8..98951ac65 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/main/MainPresenter.java +++ b/ring-android/app/src/main/java/cx/ring/tv/main/MainPresenter.java @@ -29,7 +29,7 @@ import java.util.concurrent.TimeUnit; import javax.inject.Inject; import javax.inject.Named; -import net.jami.facades.ConversationFacade; +import net.jami.services.ConversationFacade; import net.jami.mvp.RootPresenter; import net.jami.navigation.HomeNavigationViewModel; import net.jami.services.AccountService; @@ -59,7 +59,7 @@ public class MainPresenter extends RootPresenter<MainView> { public void bindView(MainView view) { super.bindView(view); loadConversations(); - reloadAccountInfos(); + reloadAccountInfo(); } private void loadConversations() { @@ -97,11 +97,11 @@ public class MainPresenter extends RootPresenter<MainView> { }, e -> Log.w(TAG, "showConversations error ", e))); } - public void reloadAccountInfos() { + public void reloadAccountInfo() { mCompositeDisposable.add(mAccountService.getCurrentAccountSubject() .observeOn(mUiScheduler) .subscribe( - account -> getView().displayAccountInfos(new HomeNavigationViewModel(account, null)), + account -> getView().displayAccountInfo(new HomeNavigationViewModel(account, null)), e-> Log.d(TAG, "reloadAccountInfos getProfileAccountList onError", e))); mCompositeDisposable.add(mAccountService.getObservableAccounts() .observeOn(mUiScheduler) @@ -116,10 +116,6 @@ public class MainPresenter extends RootPresenter<MainView> { getView().showExportDialog(mAccountService.getCurrentAccount().getAccountID(), mAccountService.getCurrentAccount().hasPassword()); } - public void onLicenceClicked(int aboutType) { - getView().showLicence(aboutType); - } - public void onEditProfileClicked() { getView().showProfileEditing(); } @@ -131,4 +127,5 @@ public class MainPresenter extends RootPresenter<MainView> { public void onSettingsClicked() { getView().showSettings(); } + } \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/main/MainView.java b/ring-android/app/src/main/java/cx/ring/tv/main/MainView.java index 39f1cf2a2..be65e03e8 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/main/MainView.java +++ b/ring-android/app/src/main/java/cx/ring/tv/main/MainView.java @@ -40,7 +40,8 @@ public interface MainView { void displayErrorToast(Error error); - void displayAccountInfos(HomeNavigationViewModel viewModel); + void displayAccountInfo(HomeNavigationViewModel viewModel); + void updateModel(Account account); void showExportDialog(String pAccountID, boolean hasPassword); @@ -49,7 +50,6 @@ public interface MainView { void showAccountShare(); - void showLicence(int aboutType); - void showSettings(); + } diff --git a/ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchFragment.java b/ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchFragment.java deleted file mode 100644 index 8e92f0a04..000000000 --- a/ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchFragment.java +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Michel Schmit <michel.schmit@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.tv.search; - -import android.content.Intent; -import android.os.Bundle; - -import androidx.leanback.app.SearchSupportFragment; -import androidx.leanback.widget.ArrayObjectAdapter; -import androidx.leanback.widget.HeaderItem; -import androidx.leanback.widget.ListRow; -import androidx.leanback.widget.ListRowPresenter; -import androidx.leanback.widget.ObjectAdapter; -import androidx.leanback.widget.SearchBar; -import androidx.leanback.widget.SearchEditText; -import androidx.core.content.ContextCompat; -import android.view.View; - -import cx.ring.R; -import cx.ring.application.JamiApplication; -import cx.ring.client.CallActivity; -import net.jami.model.Contact; -import net.jami.smartlist.SmartListViewModel; -import cx.ring.tv.call.TVCallActivity; -import cx.ring.tv.cards.Card; -import cx.ring.tv.cards.CardPresenterSelector; -import cx.ring.tv.cards.contacts.ContactCard; -import cx.ring.tv.contact.TVContactActivity; -import cx.ring.utils.ConversationPath; - -public class ContactSearchFragment extends BaseSearchFragment<ContactSearchPresenter> - implements SearchSupportFragment.SearchResultProvider, ContactSearchView { - private SearchEditText mTextEditor; - private SearchBar mSearchBar; - - private final ArrayObjectAdapter mRowsAdapter = new ArrayObjectAdapter(new ListRowPresenter()); - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setSearchResultProvider(this); - - // dependency injection - ((JamiApplication) getActivity().getApplication()).getInjectionComponent().inject(this); - setOnItemViewClickedListener((itemViewHolder, item, rowViewHolder, row) -> presenter.contactClicked(((ContactCard) item).getModel())); - setBadgeDrawable(ContextCompat.getDrawable(getActivity(), R.mipmap.ic_launcher)); - setSearchQuery("", false); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - mTextEditor = view.findViewById(R.id.lb_search_text_editor); - mSearchBar = view.findViewById(R.id.lb_search_bar); - // view injection - mSearchBar.setSearchBarListener(new SearchBar.SearchBarListener() { - @Override - public void onSearchQueryChange(String query) { - onQueryTextChange(query); - } - - @Override - public void onSearchQuerySubmit(String query) { - onQueryTextSubmit(query); - } - - @Override - public void onKeyboardDismiss(String query) { - mSearchBar.postDelayed(()-> { - getRowsSupportFragment().getVerticalGridView().requestFocus(); - }, 200); - } - }); - } - - @Override - public void onResume() { - super.onResume(); - if (mTextEditor != null) { - mTextEditor.requestFocus(); - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - } - - @Override - public ObjectAdapter getResultsAdapter() { - return mRowsAdapter; - } - - @Override - public boolean onQueryTextChange(String newQuery) { - presenter.queryTextChanged(newQuery); - return true; - } - - @Override - public boolean onQueryTextSubmit(String query) { - presenter.queryTextChanged(query); - return true; - } - - @Override - public void displayContact(String accountId, final Contact contact) { - mRowsAdapter.clear(); - ArrayObjectAdapter listRowAdapter = new ArrayObjectAdapter(new CardPresenterSelector(getActivity())); - listRowAdapter.add(new ContactCard(accountId, contact, Card.Type.SEARCH_RESULT)); - HeaderItem header = new HeaderItem(getActivity().getResources().getString(R.string.search_results)); - mRowsAdapter.add(new ListRow(header, listRowAdapter)); - } - - @Override - public void clearSearch() { - mRowsAdapter.clear(); - } - - @Override - public void startCall(String accountID, String number) { - Intent intent = new Intent(CallActivity.ACTION_CALL, ConversationPath.toUri(accountID, number), getActivity(), TVCallActivity.class); - intent.putExtra(Intent.EXTRA_PHONE_NUMBER, number); - startActivity(intent); - getActivity().finish(); - } - - @Override - public void displayContactDetails(SmartListViewModel model) { - Intent intent = new Intent(getActivity(), TVContactActivity.class); - //intent.putExtra(TVContactActivity.CONTACT_REQUEST_URI, model.getContact().getPrimaryUri()); - intent.setDataAndType(ConversationPath.toUri(model.getAccountId(), model.getUri()), TVContactActivity.TYPE_CONTACT_REQUEST_OUTGOING); - startActivity(intent); - getActivity().finish(); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchFragment.kt b/ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchFragment.kt new file mode 100644 index 000000000..fe5843efc --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchFragment.kt @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Michel Schmit <michel.schmit@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.tv.search + +import cx.ring.utils.ConversationPath.Companion.toUri +import dagger.hilt.android.AndroidEntryPoint +import cx.ring.tv.search.BaseSearchFragment +import cx.ring.tv.search.ContactSearchPresenter +import androidx.leanback.app.SearchSupportFragment +import cx.ring.tv.search.ContactSearchView +import android.os.Bundle +import cx.ring.tv.cards.contacts.ContactCard +import androidx.core.content.ContextCompat +import cx.ring.R +import androidx.leanback.widget.SearchBar.SearchBarListener +import net.jami.model.Contact +import cx.ring.tv.cards.CardPresenterSelector +import android.content.Intent +import android.view.View +import androidx.leanback.widget.* +import cx.ring.client.CallActivity +import cx.ring.utils.ConversationPath +import cx.ring.tv.call.TVCallActivity +import cx.ring.tv.cards.Card +import net.jami.smartlist.SmartListViewModel +import cx.ring.tv.contact.TVContactActivity + +@AndroidEntryPoint +class ContactSearchFragment : BaseSearchFragment<ContactSearchPresenter>(), + SearchSupportFragment.SearchResultProvider, ContactSearchView { + + private var mTextEditor: SearchEditText? = null + private val mRowsAdapter = ArrayObjectAdapter(ListRowPresenter()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setSearchResultProvider(this) + setOnItemViewClickedListener { _, item: Any, _, _ -> + presenter.contactClicked((item as ContactCard).model) + } + badgeDrawable = ContextCompat.getDrawable(requireContext(), R.mipmap.ic_launcher) + setSearchQuery("", false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + mTextEditor = view.findViewById(R.id.lb_search_text_editor) + val mSearchBar: SearchBar = view.findViewById(R.id.lb_search_bar) + mSearchBar.setSearchBarListener(object : SearchBarListener { + override fun onSearchQueryChange(query: String) { + onQueryTextChange(query) + } + + override fun onSearchQuerySubmit(query: String) { + onQueryTextSubmit(query) + } + + override fun onKeyboardDismiss(query: String) { + mSearchBar.postDelayed({ rowsSupportFragment.verticalGridView.requestFocus() }, 200) + } + }) + } + + override fun onResume() { + super.onResume() + mTextEditor?.requestFocus() + } + + override fun getResultsAdapter(): ObjectAdapter { + return mRowsAdapter + } + + override fun onQueryTextChange(newQuery: String): Boolean { + presenter.queryTextChanged(newQuery) + return true + } + + override fun onQueryTextSubmit(query: String): Boolean { + presenter.queryTextChanged(query) + return true + } + + override fun displayContact(accountId: String, contact: Contact) { + mRowsAdapter.clear() + val listRowAdapter = ArrayObjectAdapter(CardPresenterSelector(activity)) + listRowAdapter.add(ContactCard(accountId, contact, Card.Type.SEARCH_RESULT)) + val header = HeaderItem(getString(R.string.search_results)) + mRowsAdapter.add(ListRow(header, listRowAdapter)) + } + + override fun clearSearch() { + mRowsAdapter.clear() + } + + override fun startCall(accountID: String, number: String) { + val intent = Intent(CallActivity.ACTION_CALL, toUri(accountID, number), activity, TVCallActivity::class.java) + intent.putExtra(Intent.EXTRA_PHONE_NUMBER, number) + startActivity(intent) + activity?.finish() + } + + override fun displayContactDetails(model: SmartListViewModel) { + val intent = Intent(activity, TVContactActivity::class.java) + //intent.putExtra(TVContactActivity.CONTACT_REQUEST_URI, model.getContact().getPrimaryUri()); + intent.setDataAndType(toUri(model.accountId, model.uri), TVContactActivity.TYPE_CONTACT_REQUEST_OUTGOING) + startActivity(intent) + activity?.finish() + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchPresenter.java b/ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchPresenter.java index ba0994e23..3e875738d 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchPresenter.java +++ b/ring-android/app/src/main/java/cx/ring/tv/search/ContactSearchPresenter.java @@ -40,8 +40,6 @@ import io.reactivex.rxjava3.subjects.PublishSubject; public class ContactSearchPresenter extends RootPresenter<ContactSearchView> { private final AccountService mAccountService; - private final HardwareService mHardwareService; - private final VCardService mVCardService; private Contact mContact; @Inject @@ -51,12 +49,8 @@ public class ContactSearchPresenter extends RootPresenter<ContactSearchView> { private final PublishSubject<String> contactQuery = PublishSubject.create(); @Inject - public ContactSearchPresenter(AccountService accountService, - HardwareService hardwareService, - VCardService vCardService) { + public ContactSearchPresenter(AccountService accountService) { mAccountService = accountService; - mHardwareService = hardwareService; - mVCardService = vCardService; } @Override @@ -66,7 +60,7 @@ public class ContactSearchPresenter extends RootPresenter<ContactSearchView> { .debounce(350, TimeUnit.MILLISECONDS) .switchMapSingle(q -> mAccountService.findRegistrationByName(mAccountService.getCurrentAccount().getAccountID(), "", q)) .observeOn(mUiScheduler) - .subscribe(q -> parseEventState(mAccountService.getAccount(q.accountId), q.name, q.address, q.state))); + .subscribe(q -> parseEventState(mAccountService.getAccount(q.getAccountId()), q.getName(), q.getAddress(), q.getState()))); } @Override diff --git a/ring-android/app/src/main/java/cx/ring/tv/search/SearchActivity.java b/ring-android/app/src/main/java/cx/ring/tv/search/SearchActivity.java index df4282fb1..8624d5dea 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/search/SearchActivity.java +++ b/ring-android/app/src/main/java/cx/ring/tv/search/SearchActivity.java @@ -24,7 +24,9 @@ import android.os.Bundle; import androidx.fragment.app.FragmentActivity; import cx.ring.R; +import dagger.hilt.android.AndroidEntryPoint; +@AndroidEntryPoint public class SearchActivity extends FragmentActivity { @Override protected void onCreate(Bundle savedInstanceState) { diff --git a/ring-android/app/src/main/java/cx/ring/tv/settings/TVAboutFragment.java b/ring-android/app/src/main/java/cx/ring/tv/settings/TVAboutFragment.java new file mode 100644 index 000000000..1e9958f1e --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/settings/TVAboutFragment.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2004-2020 Savoir-faire Linux Inc. + * + * Author: AmirHossein Naghshzan <amirhossein.naghshzan@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.settings; + +import android.os.Bundle; +import android.text.Html; +import android.text.SpannableString; +import android.text.style.UnderlineSpan; + +import androidx.leanback.preference.LeanbackPreferenceFragmentCompat; +import androidx.preference.Preference; + +import net.jami.model.ConfigKey; + +import cx.ring.BuildConfig; +import cx.ring.R; + +public class TVAboutFragment extends LeanbackPreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.tv_about_pref, rootKey); + Preference version = findPreference("About.version"); + Preference license = findPreference("About.license"); + Preference rights = findPreference("About.rights"); + Preference credits = findPreference("About.credits"); + version.setTitle(getVersion()); + license.setTitle(getLicense()); + rights.setTitle(getRights()); + credits.setTitle(getCredits()); + } + + public static TVAboutFragment newInstance() { + return new TVAboutFragment(); + } + + @Override + public boolean onPreferenceTreeClick(Preference preference) { + if (preference.getKey().equals(ConfigKey.ACCOUNT_AUTOANSWER.key())) { + + } else if (preference.getKey().equals(ConfigKey.ACCOUNT_ISRENDEZVOUS.key())) { + + } + return super.onPreferenceTreeClick(preference); + } + + private CharSequence getVersion() { + SpannableString version = new SpannableString(requireContext().getResources().getString(R.string.version_section)); + version.setSpan(new UnderlineSpan(), 0, version.length(), 0); + return requireContext().getResources().getString(R.string.app_release, BuildConfig.VERSION_NAME); + } + + private CharSequence getLicense() { + SpannableString licence = new SpannableString(requireContext().getResources().getString(R.string.section_license)); + licence.setSpan(new UnderlineSpan(), 0, licence.length(), 0); + return requireContext().getResources().getString(R.string.license); + } + + private CharSequence getRights() { + SpannableString licence = new SpannableString(requireContext().getResources().getString(R.string.copyright_section)); + licence.setSpan(new UnderlineSpan(), 0, licence.length(), 0); + return requireContext().getResources().getString(R.string.copyright); + } + + private CharSequence getCredits() { + SpannableString developedby = new SpannableString(requireContext().getResources().getString(R.string.developed_by)); + developedby.setSpan(new UnderlineSpan(), 0, developedby.length(), 0); + CharSequence developed = requireContext().getResources().getString(R.string.credits_developer).replaceAll("\n", "<br/>"); + return Html.fromHtml("<b><u>" + developedby + "</u></b><br/>" + developed); + } + +} diff --git a/ring-android/app/src/main/java/cx/ring/tv/settings/TVSettingsActivity.kt b/ring-android/app/src/main/java/cx/ring/tv/settings/TVSettingsActivity.kt new file mode 100644 index 000000000..9830d0e31 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/settings/TVSettingsActivity.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Pierre Duchemin <pierre.duchemin@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.tv.settings + +import android.os.Bundle +import androidx.fragment.app.FragmentActivity +import cx.ring.R +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TVSettingsActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.tv_activity_settings) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/tv/account/TVSettingsFragment.java b/ring-android/app/src/main/java/cx/ring/tv/settings/TVSettingsFragment.java similarity index 87% rename from ring-android/app/src/main/java/cx/ring/tv/account/TVSettingsFragment.java rename to ring-android/app/src/main/java/cx/ring/tv/settings/TVSettingsFragment.java index 022fc9ca6..365daf3f5 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/account/TVSettingsFragment.java +++ b/ring-android/app/src/main/java/cx/ring/tv/settings/TVSettingsFragment.java @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ -package cx.ring.tv.account; +package cx.ring.tv.settings; import android.content.Context; @@ -29,6 +29,7 @@ import androidx.fragment.app.Fragment; import androidx.leanback.preference.LeanbackSettingsFragmentCompat; import androidx.preference.ListPreference; import androidx.preference.Preference; +import androidx.preference.PreferenceDialogFragmentCompat; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import androidx.preference.PreferenceScreen; @@ -43,9 +44,13 @@ import cx.ring.fragments.GeneralAccountPresenter; import cx.ring.fragments.GeneralAccountView; import net.jami.model.Account; import net.jami.model.ConfigKey; -import cx.ring.services.SharedPreferencesServiceImpl; import net.jami.utils.Tuple; +import dagger.hilt.android.AndroidEntryPoint; + +import cx.ring.services.SharedPreferencesServiceImpl; +import cx.ring.tv.account.JamiPreferenceFragment; +@AndroidEntryPoint public class TVSettingsFragment extends LeanbackSettingsFragmentCompat { @Override @@ -55,7 +60,18 @@ public class TVSettingsFragment extends LeanbackSettingsFragmentCompat { @Override public boolean onPreferenceStartFragment(PreferenceFragmentCompat preferenceFragment, Preference preference) { - return false; + final Bundle args = preference.getExtras(); + final Fragment f = getChildFragmentManager().getFragmentFactory().instantiate( + requireActivity().getClassLoader(), preference.getFragment()); + f.setArguments(args); + f.setTargetFragment(preferenceFragment, 0); + if (f instanceof PreferenceFragmentCompat + || f instanceof PreferenceDialogFragmentCompat) { + startPreferenceFragment(f); + } else { + startImmersiveFragment(f); + } + return true; } @Override @@ -68,6 +84,7 @@ public class TVSettingsFragment extends LeanbackSettingsFragmentCompat { return true; } + @AndroidEntryPoint public static class PrefsFragment extends JamiPreferenceFragment<GeneralAccountPresenter> implements GeneralAccountView { private boolean autoAnswer; private boolean rendezvousMode; @@ -78,7 +95,6 @@ public class TVSettingsFragment extends LeanbackSettingsFragmentCompat { @Override public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - ((JamiApplication) requireActivity().getApplication()).getInjectionComponent().inject(this); super.onViewCreated(view, savedInstanceState); presenter.init(); } @@ -150,7 +166,8 @@ public class TVSettingsFragment extends LeanbackSettingsFragmentCompat { @Override public boolean onPreferenceTreeClick(Preference preference) { - if (preference.getKey().equals(ConfigKey.ACCOUNT_AUTOANSWER.key())) { + if (preference.getKey().equals("Account.about")) { + } else if (preference.getKey().equals(ConfigKey.ACCOUNT_AUTOANSWER.key())) { presenter.twoStatePreferenceChanged(ConfigKey.ACCOUNT_AUTOANSWER, !autoAnswer); autoAnswer = !autoAnswer; } else if (preference.getKey().equals(ConfigKey.ACCOUNT_ISRENDEZVOUS.key())) { @@ -159,5 +176,6 @@ public class TVSettingsFragment extends LeanbackSettingsFragmentCompat { } return super.onPreferenceTreeClick(preference); } + } } diff --git a/ring-android/app/src/main/java/cx/ring/tv/views/CustomTitleView.java b/ring-android/app/src/main/java/cx/ring/tv/views/CustomTitleView.java index 61bdffd3c..a4ddddffd 100644 --- a/ring-android/app/src/main/java/cx/ring/tv/views/CustomTitleView.java +++ b/ring-android/app/src/main/java/cx/ring/tv/views/CustomTitleView.java @@ -18,8 +18,10 @@ import android.graphics.drawable.Drawable; import androidx.leanback.widget.TitleViewAdapter; import android.util.AttributeSet; import android.util.Log; +import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.TextView; @@ -37,6 +39,7 @@ public class CustomTitleView extends RelativeLayout implements TitleViewAdapter. private final TextView mTitleView; private final ImageView mLogoView; private final View mSearchOrbView; + private final ImageButton mSettingsButton; private final TitleViewAdapter mTitleViewAdapter = new TitleViewAdapter() { @Override @@ -82,8 +85,20 @@ public class CustomTitleView extends RelativeLayout implements TitleViewAdapter. mAliasView = root.findViewById(R.id.account_alias); mTitleView = root.findViewById(R.id.title_text); mLogoView = root.findViewById(R.id.title_photo_contact); + mSettingsButton = root.findViewById(R.id.title_settings); mSearchOrbView = root.findViewById(R.id.title_orb); + mSearchOrbView.setOnKeyListener(new OnKeyListener() { + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { + mSettingsButton.requestFocus(); + return true; + } + return false; + } + }); + setClipChildren(false); setClipToPadding(false); } @@ -105,12 +120,17 @@ public class CustomTitleView extends RelativeLayout implements TitleViewAdapter. mTitleView.setText(title); mTitleView.setVisibility(View.VISIBLE); mLogoView.setVisibility(View.VISIBLE); + mSettingsButton.setVisibility(View.VISIBLE); } public ImageView getLogoView() { return mLogoView; } + public ImageButton getSettingsButton() { + return mSettingsButton; + } + @Override public TitleViewAdapter getTitleViewAdapter() { return mTitleViewAdapter; diff --git a/ring-android/app/src/main/java/cx/ring/tv/views/NonOverlappingFrameLayout.java b/ring-android/app/src/main/java/cx/ring/tv/views/NonOverlappingFrameLayout.java new file mode 100644 index 000000000..3c5cd3bf5 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/tv/views/NonOverlappingFrameLayout.java @@ -0,0 +1,23 @@ +package cx.ring.tv.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.FrameLayout; +public class NonOverlappingFrameLayout extends FrameLayout { + public NonOverlappingFrameLayout(Context context) { + this(context, null); + } + public NonOverlappingFrameLayout(Context context, AttributeSet attrs) { + super(context, attrs, 0); + } + public NonOverlappingFrameLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + /** + * Avoid creating hardware layer when Transition is animating alpha. + */ + @Override + public boolean hasOverlappingRendering() { + return false; + } +} diff --git a/ring-android/app/src/main/java/cx/ring/utils/AndroidFileUtils.java b/ring-android/app/src/main/java/cx/ring/utils/AndroidFileUtils.java deleted file mode 100644 index 8e0299a44..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/AndroidFileUtils.java +++ /dev/null @@ -1,604 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.utils; - -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.Context; -import android.content.res.AssetManager; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Matrix; -import android.media.ExifInterface; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.os.StatFs; -import android.provider.DocumentsContract; -import android.provider.MediaStore; -import android.provider.OpenableColumns; -import android.text.TextUtils; -import android.util.Log; -import android.webkit.MimeTypeMap; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; - -import androidx.annotation.NonNull; - -import net.jami.model.Conversation; -import net.jami.utils.FileUtils; - -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class AndroidFileUtils { - - private static final String TAG = AndroidFileUtils.class.getSimpleName(); - private final static int ORIENTATION_LEFT = 270; - private final static int ORIENTATION_RIGHT = 90; - private final static int MAX_IMAGE_DIMENSION = 1024; - - /** - * Copy assets from a folder recursively ( files and subfolder) - * @param assetManager Asset Manager ( you can get it from Context.getAssets() ) - * @param fromAssetPath path to the assets folder we want to copy - * @param toPath a directory in internal storage - * @return true if success - */ - public static boolean copyAssetFolder( @NonNull AssetManager assetManager, String fromAssetPath, File toPath) { - try { - boolean res = true; - - // mkdirs checks if the folder exists and if not creates it - toPath.mkdirs(); - - // List the files of this asset directory - String[] files = assetManager.list(fromAssetPath); - - if (files != null) { - for (String file : files) { - String subAsset = fromAssetPath + File.separator + file; - if (isAssetDirectory(assetManager, subAsset)) { - String destination = toPath.getAbsolutePath() + File.separator + file; - File newDir = new File(destination); - copyAssetFolder(assetManager, subAsset, newDir); - Log.d(TAG, "Copied folder: " + subAsset + " to " + newDir); - } else { - File newFile = new File(toPath, file); - res &= copyAsset(assetManager, fromAssetPath + File.separator + file, newFile); - Log.d(TAG, "Copied file: " + subAsset + " to " + newFile); - } - } - } - - return res; - } catch (IOException e) { - Log.e(TAG, "Error while copying asset folder", e); - return false; - } - } - - /** - * Checks whether an asset is a file or a directory - * @param assetManager Asset Manager ( you can get it from Context.getAssets() ) - * @param fromAssetPath asset path, if just a file in assets root folder then it should be - * the file name, otherwise, folder/filename - * @return boolean directory or not - */ - public static boolean isAssetDirectory( @NonNull AssetManager assetManager, String fromAssetPath) { - try { - String[] files = assetManager.list(fromAssetPath); - if (files != null && files.length > 0) { - return true; - } - } catch (IOException e) { - Log.e(TAG, "Error while reading an asset ", e); - } - return false; - } - - /** - * Prints assets tree - * @param assetManager Asset Manager ( you can get it from Context.getAssets() ) - * @param rootPath default empty, sub folder otherwise - * @param fileName the name of the file or folder - * @param level default 0, should be 0 - */ - public static void assetTree( @NonNull AssetManager assetManager, String rootPath, String fileName, int level) { - try { - String fromAssetPath; - if(TextUtils.isEmpty(rootPath)) { - fromAssetPath = fileName; - } else { - fromAssetPath = rootPath + File.separator+ fileName; - } - - String repeated = new String(new char[level]).replace("\0", "\t|"); - - String[] files = assetManager.list(fromAssetPath); - - if (files != null) { - Log.d(TAG, "|"+ repeated + "-- " + fileName); - for(String file : files) { - assetTree(assetManager,fromAssetPath,file,level+1); - } - } - } catch (IOException e) { - Log.e(TAG, "Error while reading asset ", e); - } - } - - public static boolean copyAsset(AssetManager assetManager, String fromAssetPath, File toPath) { - try (InputStream in = assetManager.open(fromAssetPath); - OutputStream out = new FileOutputStream(toPath)) { - net.jami.utils.FileUtils.copyFile(in, out); - out.flush(); - return true; - } catch (IOException e) { - Log.e(TAG, "Error while copying asset", e); - return false; - } - } - - public static String getRealPathFromURI(Context context, Uri uri) { - String path = null; - if (DocumentsContract.isDocumentUri(context, uri)) { - if (isExternalStorageDocument(uri)) { - final String docId = DocumentsContract.getDocumentId(uri); - final String[] split = docId.split(":"); - final String type = split[0]; - if ("primary".equalsIgnoreCase(type)) { - path = Environment.getExternalStorageDirectory() + "/" + split[1]; - } - } else if (isDownloadsDocument(uri)) { - final String id = DocumentsContract.getDocumentId(uri); - final Uri contentUri = ContentUris.withAppendedId( - Uri.parse("content://downloads/public_downloads"), Long.parseLong(id)); - path = getDataColumn(context, contentUri, null, null); - } else if (isMediaDocument(uri)) { - final String docId = DocumentsContract.getDocumentId(uri); - final String[] split = docId.split(":"); - final String type = split[0]; - Uri contentUri = null; - if ("image".equals(type)) { - contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; - } else if ("video".equals(type)) { - contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; - } else if ("audio".equals(type)) { - contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; - } - final String selection = "_id=?"; - final String[] selectionArgs = new String[]{split[1]}; - path = getDataColumn(context, contentUri, selection, selectionArgs); - } - } else if ("content".equalsIgnoreCase(uri.getScheme())) { - path = getDataColumn(context, uri, null, null); - } else if ("file".equalsIgnoreCase(uri.getScheme())) { - path = uri.getPath(); - } - return path; - } - - private static String getFilename(ContentResolver cr, Uri uri) { - String result = null; - if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) { - try (Cursor cursor = cr.query(uri, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); - } - } - } - if (result == null) { - result = uri.getPath(); - int cut = result.lastIndexOf('/'); - if (cut != -1) { - result = result.substring(cut + 1); - } - } - if (result.lastIndexOf('.') == -1) { - String mimeType = getMimeType(cr, uri); - String extensionFromMimeType = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); - if (extensionFromMimeType != null) { - result += '.' + extensionFromMimeType; - } - } - return result; - } - - private static String getMimeType(ContentResolver cr, Uri uri) { - String mimeType; - if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) { - mimeType = cr.getType(uri); - } else { - mimeType = getMimeType(uri.toString()); - } - return mimeType; - } - - public static String getMimeType(String filename) { - int pos = filename.lastIndexOf("."); - String fileExtension = null; - if (pos >= 0) { - fileExtension = MimeTypeMap.getFileExtensionFromUrl(filename.substring(pos)); - } - return getMimeTypeFromExtension(fileExtension); - } - - public static String getMimeTypeFromExtension(String ext) { - if (!TextUtils.isEmpty(ext)) { - String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.toLowerCase()); - if (!TextUtils.isEmpty(mimeType)) - return mimeType; - if (ext.contentEquals("gz")) { - return "application/gzip"; - } - } - return "application/octet-stream"; - } - - public static File getTempShareDir(@NonNull Context context) { - File tmp = new File(context.getCacheDir(), "tmp"); - tmp.mkdir(); - return tmp; - } - - public static File createImageFile(@NonNull Context context) throws IOException { - // Create an image file name - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); - String imageFileName = "img_" + timeStamp + "_"; - - // Save a file: path for use with ACTION_VIEW intents - return File.createTempFile(imageFileName, ".jpg", getTempShareDir(context)); - } - - public static File createAudioFile(@NonNull Context context) throws IOException { - // Create an image file name - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); - String imageFileName = "audio_" + timeStamp + "_"; - - // Save a file: path for use with ACTION_VIEW intents - return File.createTempFile(imageFileName, ".mp3", getTempShareDir(context)); - } - public static File createVideoFile(@NonNull Context context) throws IOException { - // Create an image file name - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); - String imageFileName = "video_" + timeStamp + "_"; - - // Save a file: path for use with ACTION_VIEW intents - return File.createTempFile(imageFileName, ".webm", getTempShareDir(context)); - } - public static File createLogFile(@NonNull Context context) throws IOException { - // Create an image file name - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); - String imageFileName = "log_" + timeStamp + "_"; - - // Save a file: path for use with ACTION_VIEW intents - return File.createTempFile(imageFileName, ".log", getTempShareDir(context)); - } - - /** - * Copies a file from a uri whether locally on a remote location to the local cache - * @param context Context to get access to cache directory - * @param uri uri of the - * @return Single<File> which points to the newly created copy in the cache - */ - public static @NonNull - Single<File> getCacheFile(@NonNull Context context, @NonNull Uri uri) { - ContentResolver contentResolver = context.getContentResolver(); - File cacheDir = context.getCacheDir(); - return Single.fromCallable(() -> { - File file = new File(cacheDir, getFilename(contentResolver, uri)); - try (InputStream inputStream = contentResolver.openInputStream(uri); - FileOutputStream output = new FileOutputStream(file)) { - if (inputStream == null) - throw new FileNotFoundException(); - net.jami.utils.FileUtils.copyFile(inputStream, output); - output.flush(); - } - return file; - }).subscribeOn(Schedulers.io()); - } - - public static @NonNull Single<File> getFileToSend(@NonNull Context context, @NonNull Conversation conversation, @NonNull Uri uri) { - ContentResolver contentResolver = context.getContentResolver(); - File cacheDir = context.getCacheDir(); - return Single.fromCallable(() -> { - File file = new File(cacheDir, getFilename(contentResolver, uri)); - try (InputStream inputStream = contentResolver.openInputStream(uri); - FileOutputStream output = new FileOutputStream(file)) { - if (inputStream == null) - throw new FileNotFoundException(); - net.jami.utils.FileUtils.copyFile(inputStream, output); - output.flush(); - } - return file; - }).subscribeOn(Schedulers.io()); - } - - public static Completable moveToUri(@NonNull ContentResolver cr, @NonNull File input, @NonNull Uri outUri) { - return Completable.fromAction(() -> { - try (InputStream inputStream = new FileInputStream(input); - OutputStream output = cr.openOutputStream(outUri)) { - if (output == null) - throw new FileNotFoundException(); - FileUtils.copyFile(inputStream, output); - } - input.delete(); - }).subscribeOn(Schedulers.io()); - } - - /** - * Copies a file to a predefined Uri destination - * Uses the underlying copyFile(InputStream,OutputStream) - * @param cr content resolver - * @param input the file we want to copy - * @param outUri the uri destination - * @return success value - */ - public static Completable copyFileToUri(ContentResolver cr, File input, Uri outUri){ - return Completable.fromAction(() -> { - try (InputStream inputStream = new FileInputStream(input); OutputStream outputStream = cr.openOutputStream(outUri)) { - net.jami.utils.FileUtils.copyFile(inputStream, outputStream); - } - }).subscribeOn(Schedulers.io()); - } - - public static File getConversationFile(Context context, Uri uri, String conversationId, String name) throws IOException { - File file = getConversationPath(context, conversationId, name); - FileOutputStream output = new FileOutputStream(file); - InputStream inputStream = context.getContentResolver().openInputStream(uri); - net.jami.utils.FileUtils.copyFile(inputStream, output); - return file; - } - - public static File getCachePath(Context context, String filename) { - return new File(context.getCacheDir(), filename); - } - - public static File getFilePath(Context context, String filename) { - return context.getFileStreamPath(filename); - } - - public static File getConversationDir(Context context, String conversationId) { - File conversationsDir = getFilePath(context, "conversation_data"); - if (!conversationsDir.exists()) - conversationsDir.mkdir(); - - File conversationDir = new File(conversationsDir, conversationId); - if (!conversationDir.exists()) - conversationDir.mkdir(); - - return conversationDir; - } - - public static File getConversationDir(Context context, String accountId, String conversationId) { - File conversationsDir = getFilePath(context, "conversation_data"); - if (!conversationsDir.exists()) - conversationsDir.mkdir(); - - File accountDir = new File(conversationsDir, accountId); - if (!accountDir.exists()) - accountDir.mkdir(); - - File conversationDir = new File(accountDir, conversationId); - if (!conversationDir.exists()) - conversationDir.mkdir(); - - return conversationDir; - } - - public static File getConversationPath(Context context, String conversationId, String name) { - return new File(getConversationDir(context, conversationId), name); - } - public static File getConversationPath(Context context, String accountId, String conversationId, String name) { - return new File(getConversationDir(context, accountId, conversationId), name); - } - - public static File getTempPath(Context context, String conversationId, String name) { - File conversationsDir = getCachePath(context, "conversation_data"); - - if (!conversationsDir.exists()) - conversationsDir.mkdir(); - - File conversationDir = new File(conversationsDir, conversationId); - if (!conversationDir.exists()) - conversationDir.mkdir(); - - return new File(conversationDir, name); - } - - public static String writeCacheFileToExtStorage(Context context, Uri cacheFile, String targetFilename) throws IOException { - File downloadsDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - - int fileCount = 0; - File finalFile = new File(downloadsDirectory, targetFilename); - int lastDotIndex = targetFilename.lastIndexOf('.'); - String filename = targetFilename.substring(0, lastDotIndex); - String extension = targetFilename.substring(lastDotIndex + 1); - while (finalFile.exists()) { - finalFile = new File(downloadsDirectory, filename + "_" + fileCount + '.' + extension); - fileCount++; - } - - Log.d(TAG, "writeCacheFileToExtStorage: finalFile=" + finalFile + ",exists=" + finalFile.exists()); - try (InputStream inputStream = context.getContentResolver().openInputStream(cacheFile); - FileOutputStream output = new FileOutputStream(finalFile)) { - net.jami.utils.FileUtils.copyFile(inputStream, output); - } - return finalFile.toString(); - } - - public static boolean isExternalStorageWritable() { - String state = Environment.getExternalStorageState(); - return Environment.MEDIA_MOUNTED.equals(state); - } - - private static boolean isExternalStorageDocument(Uri uri) { - return "com.android.externalstorage.documents".equals(uri.getAuthority()); - } - - private static boolean isDownloadsDocument(Uri uri) { - return "com.android.providers.downloads.documents".equals(uri.getAuthority()); - } - - private static boolean isMediaDocument(Uri uri) { - return "com.android.providers.media.documents".equals(uri.getAuthority()); - } - - private static String getDataColumn(Context context, Uri uri, String selection, String[] selectionArgs) { - String path = null; - final String column = "_data"; - final String[] projection = {column}; - try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) { - if (cursor != null && cursor.moveToFirst()) { - final int column_index = cursor.getColumnIndexOrThrow(column); - path = cursor.getString(column_index); - } - } catch (Exception e) { - Log.e(TAG, "Error while saving the ringtone", e); - } - return path; - } - - public static File ringtonesPath(Context context) { - return new File(context.getFilesDir(), "ringtones"); - } - - /** - * Get space left in a specific path - * - * @return -1L if an error occurred, size otherwise - */ - public static long getSpaceLeft(String path) { - try { - StatFs statfs = new StatFs(path); - return statfs.getAvailableBytes(); - } catch (IllegalArgumentException e) { - Log.e(TAG, "getSpaceLeft: not able to access path on " + path); - return -1L; - } - } - - public static Single<Bitmap> loadBitmap(Context context, Uri uriImage) { - return Single.fromCallable(() -> { - BitmapFactory.Options dbo = new BitmapFactory.Options(); - dbo.inJustDecodeBounds = true; - - try (InputStream is = context.getContentResolver().openInputStream(uriImage)) { - BitmapFactory.decodeStream(is, null, dbo); - } - - int rotatedWidth, rotatedHeight; - int orientation = getOrientation(context, uriImage); - - if (orientation == ORIENTATION_LEFT || orientation == ORIENTATION_RIGHT) { - rotatedWidth = dbo.outHeight; - rotatedHeight = dbo.outWidth; - } else { - rotatedWidth = dbo.outWidth; - rotatedHeight = dbo.outHeight; - } - - Bitmap srcBitmap; - try (InputStream is = context.getContentResolver().openInputStream(uriImage)) { - if (rotatedWidth > MAX_IMAGE_DIMENSION || rotatedHeight > MAX_IMAGE_DIMENSION) { - float widthRatio = ((float) rotatedWidth) / ((float) MAX_IMAGE_DIMENSION); - float heightRatio = ((float) rotatedHeight) / ((float) MAX_IMAGE_DIMENSION); - float maxRatio = Math.max(widthRatio, heightRatio); - - // Create the bitmap from file - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inSampleSize = (int) maxRatio; - srcBitmap = BitmapFactory.decodeStream(is, null, options); - } else { - srcBitmap = BitmapFactory.decodeStream(is); - } - } - - if (orientation > 0) { - Matrix matrix = new Matrix(); - matrix.postRotate(orientation); - - srcBitmap = Bitmap.createBitmap(srcBitmap, 0, 0, srcBitmap.getWidth(), - srcBitmap.getHeight(), matrix, true); - } - return srcBitmap; - }).subscribeOn(Schedulers.io()); - } - - private static int getOrientation(@NonNull Context context, @NonNull Uri photoUri) { - ContentResolver resolver = context.getContentResolver(); - if (resolver == null) - return 0; - try (Cursor cursor = resolver.query(photoUri, new String[]{MediaStore.Images.ImageColumns.ORIENTATION}, null, null, null)) { - cursor.moveToFirst(); - return cursor.getInt(0); - } catch (Exception e) { - switch (getExifOrientation(resolver, photoUri)) { - case ExifInterface.ORIENTATION_ROTATE_90: - return 90; - case ExifInterface.ORIENTATION_ROTATE_180: - return 180; - case ExifInterface.ORIENTATION_ROTATE_270: - return 270; - default: - return 0; - } - } - } - - private static int getExifOrientation(@NonNull ContentResolver resolver, @NonNull Uri photoUri) { - if (Build.VERSION.SDK_INT > 23) { - try (InputStream input = resolver.openInputStream(photoUri)) { - return new ExifInterface(input) - .getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); - } catch (Exception e) { - return 0; - } - } else { - try { - return new ExifInterface(photoUri.getPath()) - .getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); - } catch (Exception e) { - return 0; - } - } - } - - public static boolean isImage(String s) { - return getMimeType(s).startsWith("image"); - } - - public static String getFileName(String s) { - String[] parts = s.split("\\/"); - return parts[parts.length - 1]; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/AndroidFileUtils.kt b/ring-android/app/src/main/java/cx/ring/utils/AndroidFileUtils.kt new file mode 100644 index 000000000..94adcdb0e --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/AndroidFileUtils.kt @@ -0,0 +1,569 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.utils + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.content.res.AssetManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.media.ExifInterface +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.StatFs +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.text.TextUtils +import android.util.Log +import android.webkit.MimeTypeMap +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import net.jami.model.Conversation +import net.jami.utils.FileUtils +import java.io.* +import java.text.SimpleDateFormat +import java.util.* + +object AndroidFileUtils { + private val TAG = AndroidFileUtils::class.simpleName + private const val ORIENTATION_LEFT = 270 + private const val ORIENTATION_RIGHT = 90 + private const val MAX_IMAGE_DIMENSION = 1024 + + /** + * Copy assets from a folder recursively ( files and subfolder) + * @param assetManager Asset Manager ( you can get it from Context.getAssets() ) + * @param fromAssetPath path to the assets folder we want to copy + * @param toPath a directory in internal storage + * @return true if success + */ + fun copyAssetFolder(assetManager: AssetManager, fromAssetPath: String, toPath: File): Boolean { + return try { + var res = true + + // mkdirs checks if the folder exists and if not creates it + toPath.mkdirs() + + // List the files of this asset directory + val files = assetManager.list(fromAssetPath) + if (files != null) { + for (file in files) { + val subAsset = fromAssetPath + File.separator + file + if (isAssetDirectory(assetManager, subAsset)) { + val destination = toPath.absolutePath + File.separator + file + val newDir = File(destination) + copyAssetFolder(assetManager, subAsset, newDir) + Log.d(TAG, "Copied folder: $subAsset to $newDir") + } else { + val newFile = File(toPath, file) + res = res and copyAsset(assetManager, fromAssetPath + File.separator + file, newFile) + Log.d(TAG, "Copied file: $subAsset to $newFile") + } + } + } + res + } catch (e: IOException) { + Log.e(TAG, "Error while copying asset folder", e) + false + } + } + + /** + * Checks whether an asset is a file or a directory + * @param assetManager Asset Manager ( you can get it from Context.getAssets() ) + * @param fromAssetPath asset path, if just a file in assets root folder then it should be + * the file name, otherwise, folder/filename + * @return boolean directory or not + */ + private fun isAssetDirectory(assetManager: AssetManager, fromAssetPath: String): Boolean { + try { + if (assetManager.list(fromAssetPath)?.isNotEmpty() == true) { + return true + } + } catch (e: IOException) { + Log.e(TAG, "Error while reading an asset ", e) + } + return false + } + + /** + * Prints assets tree + * @param assetManager Asset Manager ( you can get it from Context.getAssets() ) + * @param rootPath default empty, sub folder otherwise + * @param fileName the name of the file or folder + * @param level default 0, should be 0 + */ + fun assetTree(assetManager: AssetManager, rootPath: String, fileName: String, level: Int) { + try { + val fromAssetPath: String = if (TextUtils.isEmpty(rootPath)) { + fileName + } else { + rootPath + File.separator + fileName + } + val repeated = String(CharArray(level)).replace("\u0000", "\t|") + val files = assetManager.list(fromAssetPath) + if (files != null) { + Log.d(TAG, "|$repeated-- $fileName") + for (file in files) { + assetTree(assetManager, fromAssetPath, file, level + 1) + } + } + } catch (e: IOException) { + Log.e(TAG, "Error while reading asset ", e) + } + } + + fun copyAsset(assetManager: AssetManager, fromAssetPath: String, toPath: File): Boolean { + try { + assetManager.open(fromAssetPath).use { input -> + FileOutputStream(toPath).use { out -> + FileUtils.copyFile(input, out) + out.flush() + return true + } + } + } catch (e: IOException) { + Log.e(TAG, "Error while copying asset", e) + return false + } + } + + @JvmStatic + fun getRealPathFromURI(context: Context, uri: Uri): String? { + var path: String? = null + if (DocumentsContract.isDocumentUri(context, uri)) { + if (isExternalStorageDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).toTypedArray() + val type = split[0] + if ("primary".equals(type, ignoreCase = true)) { + path = Environment.getExternalStorageDirectory().toString() + "/" + split[1] + } + } else if (isDownloadsDocument(uri)) { + val id = DocumentsContract.getDocumentId(uri) + val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), id.toLong()) + path = 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 + when (type) { + "image" -> contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + "video" -> contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + "audio" -> contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + } + val selection = "_id=?" + val selectionArgs = arrayOf(split[1]) + path = getDataColumn(context, contentUri, selection, selectionArgs) + } + } else if ("content".equals(uri.scheme, ignoreCase = true)) { + path = getDataColumn(context, uri, null, null) + } else if ("file".equals(uri.scheme, ignoreCase = true)) { + path = uri.path + } + return path + } + + private fun getFilename(cr: ContentResolver, uri: Uri): String { + var result: String? = null + if (ContentResolver.SCHEME_CONTENT == uri.scheme) { + cr.query(uri, null, null, null, null).use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + } + } + } + if (result == null) { + result = uri.path + val cut = result!!.lastIndexOf('/') + if (cut != -1) { + result = result!!.substring(cut + 1) + } + } + if (result!!.lastIndexOf('.') == -1) { + val mimeType = getMimeType(cr, uri) + val extensionFromMimeType = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) + if (extensionFromMimeType != null) { + result += ".$extensionFromMimeType" + } + } + return result ?: uri.lastPathSegment!! + } + + private fun getMimeType(cr: ContentResolver, uri: Uri): String? { + return if (ContentResolver.SCHEME_CONTENT == uri.scheme) + cr.getType(uri) + else + getMimeType(uri.toString()) + } + + fun getMimeType(filename: String): String? { + val pos = filename.lastIndexOf(".") + var fileExtension: String? = null + if (pos >= 0) { + fileExtension = MimeTypeMap.getFileExtensionFromUrl(filename.substring(pos)) + } + return getMimeTypeFromExtension(fileExtension) + } + + fun getMimeTypeFromExtension(ext: String?): String { + if (ext != null && ext.isNotEmpty()) { + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.lowercase(Locale.getDefault())) + if (mimeType != null && mimeType.isNotEmpty()) return mimeType + if (ext == "gz") { + return "application/gzip" + } + } + return "application/octet-stream" + } + + fun getTempShareDir(context: Context): File { + val tmp = File(context.cacheDir, "tmp") + tmp.mkdir() + return tmp + } + + @Throws(IOException::class) + fun createImageFile(context: Context): File { + // Create an image file name + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val imageFileName = "img_" + timeStamp + "_" + + // Save a file: path for use with ACTION_VIEW intents + return File.createTempFile(imageFileName, ".jpg", getTempShareDir(context)) + } + + @Throws(IOException::class) + fun createAudioFile(context: Context): File { + // Create an image file name + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val imageFileName = "audio_" + timeStamp + "_" + + // Save a file: path for use with ACTION_VIEW intents + return File.createTempFile(imageFileName, ".mp3", getTempShareDir(context)) + } + + @Throws(IOException::class) + fun createVideoFile(context: Context): File { + // Create an image file name + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val imageFileName = "video_" + timeStamp + "_" + + // Save a file: path for use with ACTION_VIEW intents + return File.createTempFile(imageFileName, ".webm", getTempShareDir(context)) + } + + @Throws(IOException::class) + fun createLogFile(context: Context): File { + // Create an image file name + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val imageFileName = "log_" + timeStamp + "_" + + // Save a file: path for use with ACTION_VIEW intents + return File.createTempFile(imageFileName, ".log", getTempShareDir(context)) + } + + /** + * Copies a file from a uri whether locally on a remote location to the local cache + * @param context Context to get access to cache directory + * @param uri uri of the + * @return Single<File> which points to the newly created copy in the cache + </File> */ + @JvmStatic + fun getCacheFile(context: Context, uri: Uri): Single<File> { + val contentResolver = context.contentResolver + val cacheDir = context.cacheDir + return Single.fromCallable { + val file = File(cacheDir, getFilename(contentResolver, uri)) + contentResolver.openInputStream(uri).use { inputStream -> + FileOutputStream(file).use { output -> + if (inputStream == null) throw FileNotFoundException() + FileUtils.copyFile(inputStream, output) + output.flush() + } + } + file + }.subscribeOn(Schedulers.io()) + } + + fun getFileToSend(context: Context, conversation: Conversation, uri: Uri): Single<File> { + val contentResolver = context.contentResolver + val cacheDir = context.cacheDir + return Single.fromCallable { + val file = File(cacheDir, getFilename(contentResolver, uri)) + contentResolver.openInputStream(uri).use { inputStream -> + FileOutputStream(file).use { output -> + if (inputStream == null) throw FileNotFoundException() + FileUtils.copyFile(inputStream, output) + output.flush() + } + } + file + }.subscribeOn(Schedulers.io()) + } + + fun moveToUri(cr: ContentResolver, input: File, outUri: Uri): Completable { + return Completable.fromAction { + FileInputStream(input).use { inputStream -> + cr.openOutputStream(outUri).use { output -> + if (output == null) throw FileNotFoundException() + FileUtils.copyFile(inputStream, output) + } + } + input.delete() + }.subscribeOn(Schedulers.io()) + } + + /** + * Copies a file to a predefined Uri destination + * Uses the underlying copyFile(InputStream,OutputStream) + * @param cr content resolver + * @param input the file we want to copy + * @param outUri the uri destination + * @return success value + */ + fun copyFileToUri(cr: ContentResolver, input: File?, outUri: Uri): Completable { + return Completable.fromAction { + FileInputStream(input).use { inputStream -> + cr.openOutputStream(outUri)?.use { outputStream -> + FileUtils.copyFile(inputStream, outputStream) + } } + }.subscribeOn(Schedulers.io()) + } + + @Throws(IOException::class) + fun getConversationFile(context: Context, uri: Uri, conversationId: String, name: String): File { + val file = getConversationPath(context, conversationId, name) + context.contentResolver.openInputStream(uri)?.use { inputStream -> + FileOutputStream(file).use { output -> + FileUtils.copyFile(inputStream, output) + } } + return file + } + + fun getCachePath(context: Context, filename: String): File { + return File(context.cacheDir, filename) + } + + fun getFilePath(context: Context, filename: String?): File { + return context.getFileStreamPath(filename) + } + + fun getConversationDir(context: Context, conversationId: String): File { + val conversationsDir = getFilePath(context, "conversation_data") + if (!conversationsDir.exists()) conversationsDir.mkdir() + val conversationDir = File(conversationsDir, conversationId) + if (!conversationDir.exists()) conversationDir.mkdir() + return conversationDir + } + + fun getConversationDir(context: Context, accountId: String, conversationId: String): File { + val conversationsDir = getFilePath(context, "conversation_data") + if (!conversationsDir.exists()) conversationsDir.mkdir() + val accountDir = File(conversationsDir, accountId) + if (!accountDir.exists()) accountDir.mkdir() + val conversationDir = File(accountDir, conversationId) + if (!conversationDir.exists()) conversationDir.mkdir() + return conversationDir + } + + fun getConversationPath(context: Context, conversationId: String, name: String): File { + return File(getConversationDir(context, conversationId), name) + } + + fun getConversationPath(context: Context, accountId: String, conversationId: String, name: String): File { + return File(getConversationDir(context, accountId, conversationId), name) + } + + fun getTempPath(context: Context, conversationId: String, name: String): File { + val conversationsDir = getCachePath(context, "conversation_data") + if (!conversationsDir.exists()) conversationsDir.mkdir() + val conversationDir = File(conversationsDir, conversationId) + if (!conversationDir.exists()) conversationDir.mkdir() + return File(conversationDir, name) + } + + @Throws(IOException::class) + fun writeCacheFileToExtStorage(context: Context, cacheFile: Uri, targetFilename: String): String { + val downloadsDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + var fileCount = 0 + var finalFile = File(downloadsDirectory, targetFilename) + val lastDotIndex = targetFilename.lastIndexOf('.') + val filename = targetFilename.substring(0, lastDotIndex) + val extension = targetFilename.substring(lastDotIndex + 1) + while (finalFile.exists()) { + finalFile = File(downloadsDirectory, filename + "_" + fileCount + '.' + extension) + fileCount++ + } + Log.d(TAG, "writeCacheFileToExtStorage: finalFile=" + finalFile + ",exists=" + finalFile.exists()) + context.contentResolver.openInputStream(cacheFile)?.use { inputStream -> + FileOutputStream(finalFile).use { output -> + FileUtils.copyFile(inputStream, output) + } } + return finalFile.toString() + } + + val isExternalStorageWritable: Boolean + get() { + val state = Environment.getExternalStorageState() + return Environment.MEDIA_MOUNTED == state + } + + private fun isExternalStorageDocument(uri: Uri): Boolean { + return "com.android.externalstorage.documents" == uri.authority + } + + private fun isDownloadsDocument(uri: Uri): Boolean { + return "com.android.providers.downloads.documents" == uri.authority + } + + private fun isMediaDocument(uri: Uri): Boolean { + return "com.android.providers.media.documents" == uri.authority + } + + private fun getDataColumn(context: Context, uri: Uri?, selection: String?, selectionArgs: Array<String>?): String? { + var path: String? = null + val column = "_data" + val projection = arrayOf(column) + try { + context.contentResolver.query(uri!!, projection, selection, selectionArgs, null).use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + path = cursor.getString(cursor.getColumnIndexOrThrow(column)) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error while saving the ringtone", e) + } + return path + } + + fun ringtonesPath(context: Context): File { + return File(context.filesDir, "ringtones") + } + + /** + * Get space left in a specific path + * + * @return -1L if an error occurred, size otherwise + */ + @JvmStatic + fun getSpaceLeft(path: String): Long { + return try { + StatFs(path).availableBytes + } catch (e: IllegalArgumentException) { + Log.e(TAG, "getSpaceLeft: not able to access path on $path") + -1L + } + } + + fun loadBitmap(context: Context, uriImage: Uri): Single<Bitmap> { + return Single.fromCallable<Bitmap> { + val dbo = BitmapFactory.Options() + dbo.inJustDecodeBounds = true + context.contentResolver.openInputStream(uriImage).use { `is` -> BitmapFactory.decodeStream(`is`, null, dbo) } + val rotatedWidth: Int + val rotatedHeight: Int + val orientation = getOrientation(context, uriImage) + if (orientation == ORIENTATION_LEFT || orientation == ORIENTATION_RIGHT) { + rotatedWidth = dbo.outHeight + rotatedHeight = dbo.outWidth + } else { + rotatedWidth = dbo.outWidth + rotatedHeight = dbo.outHeight + } + var srcBitmap: Bitmap? + context.contentResolver.openInputStream(uriImage).use { `is` -> + if (rotatedWidth > MAX_IMAGE_DIMENSION || rotatedHeight > MAX_IMAGE_DIMENSION) { + val widthRatio = rotatedWidth.toFloat() / MAX_IMAGE_DIMENSION.toFloat() + val heightRatio = rotatedHeight.toFloat() / MAX_IMAGE_DIMENSION.toFloat() + val maxRatio = Math.max(widthRatio, heightRatio) + + // Create the bitmap from file + val options = BitmapFactory.Options() + options.inSampleSize = maxRatio.toInt() + srcBitmap = BitmapFactory.decodeStream(`is`, null, options) + } else { + srcBitmap = BitmapFactory.decodeStream(`is`) + } + } + if (orientation > 0) { + val matrix = Matrix() + matrix.postRotate(orientation.toFloat()) + srcBitmap = Bitmap.createBitmap(srcBitmap!!, 0, 0, srcBitmap!!.width, + srcBitmap!!.height, matrix, true) + } + srcBitmap + }.subscribeOn(Schedulers.io()) + } + + private fun getOrientation(context: Context, photoUri: Uri): Int { + val resolver = context.contentResolver ?: return 0 + try { + resolver.query(photoUri, arrayOf(MediaStore.Images.ImageColumns.ORIENTATION), null, null, null).use { cursor -> + cursor!!.moveToFirst() + return cursor.getInt(0) + } + } catch (e: Exception) { + return when (getExifOrientation(resolver, photoUri)) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90 + ExifInterface.ORIENTATION_ROTATE_180 -> 180 + ExifInterface.ORIENTATION_ROTATE_270 -> 270 + else -> 0 + } + } + } + + private fun getExifOrientation(resolver: ContentResolver, photoUri: Uri): Int { + if (Build.VERSION.SDK_INT > 23) { + try { + resolver.openInputStream(photoUri).use { input -> + return ExifInterface(input!!) + .getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + } + } catch (e: Exception) { + return 0 + } + } else { + return try { + ExifInterface(photoUri.path!!) + .getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + } catch (e: Exception) { + 0 + } + } + } + + @JvmStatic + fun isImage(s: String): Boolean { + return getMimeType(s)?.startsWith("image") ?: false + } + + @JvmStatic + fun getFileName(s: String): String { + val parts = s.split(Regex("/")).toTypedArray() + return parts[parts.size - 1] + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/BitmapUtils.java b/ring-android/app/src/main/java/cx/ring/utils/BitmapUtils.java deleted file mode 100644 index 23c7e8618..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/BitmapUtils.java +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.utils; - -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.util.Base64; -import android.util.Log; - -import androidx.annotation.NonNull; - -import ezvcard.parameter.ImageType; -import ezvcard.property.Photo; - -import java.io.ByteArrayOutputStream; -import java.nio.ByteBuffer; - -/** - * Helper calls to manipulates Bitmaps - */ -public final class BitmapUtils -{ - private static final String TAG = BitmapUtils.class.getSimpleName(); - private BitmapUtils() {} - - public static Photo bitmapToPhoto(@NonNull Bitmap image) { - return new Photo(bitmapToPng(image), ImageType.PNG); - } - - public static byte[] bitmapToPng(@NonNull Bitmap image) { - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - image.compress(Bitmap.CompressFormat.PNG, 100, stream); - return stream.toByteArray(); - } - - public static byte[] bitmapToBytes(Bitmap bmp) { - int bytes = bmp.getByteCount(); - ByteBuffer buffer = ByteBuffer.allocate(bytes); //Create a new buffer - bmp.copyPixelsToBuffer(buffer); //Move the byte data to the buffer - return buffer.array(); - } - - public static Bitmap base64ToBitmap(String base64) { - if (base64 == null) - return null; - try { - return bytesToBitmap(Base64.decode(base64, Base64.DEFAULT)); - } catch (IllegalArgumentException e) { - return null; - } - } - - public static Bitmap bytesToBitmap(byte[] imageData) { - if (imageData != null && imageData.length > 0) { - return BitmapFactory.decodeByteArray(imageData, 0, imageData.length); - } - return null; - } - - public static Bitmap bytesToBitmap(byte[] data, int maxSize) { - // First decode with inJustDecodeBounds=true to check dimensions - final BitmapFactory.Options options = new BitmapFactory.Options(); - options.inJustDecodeBounds = true; - BitmapFactory.decodeByteArray(data, 0, data.length, options); - int width = options.outWidth; - int height = options.outHeight; - int scale = 1; - while (3 * width * height > maxSize) { - scale *= 2; - width /= 2; - height /= 2; - } - options.inSampleSize = scale; - options.inJustDecodeBounds = false; - return BitmapFactory.decodeByteArray(data, 0, data.length, options); - } - - public static Bitmap reduceBitmap(Bitmap bmp, int size) { - if (bmp.getByteCount() <= size) - return bmp; - Log.d(TAG, "reduceBitmap: bitmap size before reduce " + bmp.getByteCount()); - int height = bmp.getHeight(); - int width = bmp.getWidth(); - int minRatio = bmp.getByteCount()/size; - - int ratio = 2; - while (ratio*ratio < minRatio) - ratio *= 2; - - height /= ratio; - width /= ratio; - bmp = Bitmap.createScaledBitmap(bmp, width, height, true); - - net.jami.utils.Log.d(TAG, "reduceBitmap: bitmap size after x" + ratio + " reduce " + bmp.getByteCount()); - return bmp; - } - - public static Bitmap createScaledBitmap(Bitmap bitmap, int maxSize) { - if (bitmap == null || maxSize < 0) { - throw new IllegalArgumentException(); - } - int width = bitmap.getHeight(); - int height = bitmap.getWidth(); - if (width != height) { - if (width < height) { - // portrait - height = maxSize; - width = (maxSize * bitmap.getWidth()) / bitmap.getHeight(); - } else { - // landscape - height = (maxSize * bitmap.getHeight()) / bitmap.getWidth(); - width = maxSize; - } - } else { - width = maxSize; - height = maxSize; - } - return Bitmap.createScaledBitmap(bitmap, width, height, true); - } - - public static Bitmap drawableToBitmap(Drawable drawable) { - return drawableToBitmap(drawable, -1); - } - public static Bitmap drawableToBitmap(Drawable drawable, int size) { - return drawableToBitmap(drawable, size, 0); - } - public static Bitmap drawableToBitmap(Drawable drawable, int size, int padding) { - if (drawable instanceof BitmapDrawable) { - return ((BitmapDrawable)drawable).getBitmap(); - } - - int width = drawable.getIntrinsicWidth(); - width = width > 0 ? width : size; - int height = drawable.getIntrinsicHeight(); - height = height > 0 ? height : size; - - Bitmap bitmap = Bitmap.createBitmap(width + 2*padding, height + 2*padding, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - drawable.setBounds(padding, padding, canvas.getWidth()-padding, canvas.getHeight()-padding); - drawable.draw(canvas); - return bitmap; - } - - public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { - // Raw height and width of image - final int height = options.outHeight; - final int width = options.outWidth; - int inSampleSize = 1; - - if (height > reqHeight || width > reqWidth) { - - final int halfHeight = height / 2; - final int halfWidth = width / 2; - - // Calculate the largest inSampleSize value that is a power of 2 and keeps both - // height and width larger than the requested height and width. - while ((halfHeight / inSampleSize) >= reqHeight - && (halfWidth / inSampleSize) >= reqWidth) { - inSampleSize *= 2; - } - } - - return inSampleSize; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/BitmapUtils.kt b/ring-android/app/src/main/java/cx/ring/utils/BitmapUtils.kt new file mode 100644 index 000000000..06e8141de --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/BitmapUtils.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.utils + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.Base64 +import android.util.Log +import ezvcard.parameter.ImageType +import ezvcard.property.Photo +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +/** + * Helper calls to manipulates Bitmaps + */ +object BitmapUtils { + private val TAG = BitmapUtils::class.simpleName!! + @JvmStatic + fun bitmapToPhoto(image: Bitmap): Photo { + return Photo(bitmapToPng(image), ImageType.PNG) + } + + fun bitmapToPng(image: Bitmap): ByteArray { + val stream = ByteArrayOutputStream() + image.compress(Bitmap.CompressFormat.PNG, 100, stream) + return stream.toByteArray() + } + + fun bitmapToBytes(bmp: Bitmap): ByteArray { + val bytes = bmp.byteCount + val buffer = ByteBuffer.allocate(bytes) //Create a new buffer + bmp.copyPixelsToBuffer(buffer) //Move the byte data to the buffer + return buffer.array() + } + + fun base64ToBitmap(base64: String?): Bitmap? { + return if (base64 == null) null else try { + bytesToBitmap(Base64.decode(base64, Base64.DEFAULT)) + } catch (e: IllegalArgumentException) { + null + } + } + + fun bytesToBitmap(imageData: ByteArray?): Bitmap? { + return if (imageData != null && imageData.isNotEmpty()) { + BitmapFactory.decodeByteArray(imageData, 0, imageData.size) + } else null + } + + fun bytesToBitmap(data: ByteArray, maxSize: Int): Bitmap { + // First decode with inJustDecodeBounds=true to check dimensions + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeByteArray(data, 0, data.size, options) + var width = options.outWidth + var height = options.outHeight + var scale = 1 + while (3 * width * height > maxSize) { + scale *= 2 + width /= 2 + height /= 2 + } + options.inSampleSize = scale + options.inJustDecodeBounds = false + return BitmapFactory.decodeByteArray(data, 0, data.size, options) + } + + fun reduceBitmap(bmp: Bitmap, size: Int): Bitmap { + if (bmp.byteCount <= size) return bmp + Log.d(TAG, "reduceBitmap: bitmap size before reduce " + bmp.byteCount) + var height = bmp.height + var width = bmp.width + val minRatio = bmp.byteCount / size + var ratio = 2 + while (ratio * ratio < minRatio) ratio *= 2 + height /= ratio + width /= ratio + val ret = Bitmap.createScaledBitmap(bmp, width, height, true) + Log.d(TAG, "reduceBitmap: bitmap size after x" + ratio + " reduce " + ret.byteCount) + return ret + } + + fun createScaledBitmap(bitmap: Bitmap?, maxSize: Int): Bitmap { + require(!(bitmap == null || maxSize < 0)) + var width = bitmap.height + var height = bitmap.width + if (width != height) { + if (width < height) { + // portrait + height = maxSize + width = maxSize * bitmap.width / bitmap.height + } else { + // landscape + height = maxSize * bitmap.height / bitmap.width + width = maxSize + } + } else { + width = maxSize + height = maxSize + } + return Bitmap.createScaledBitmap(bitmap, width, height, true) + } + + @JvmStatic + @JvmOverloads + fun drawableToBitmap(drawable: Drawable, size: Int = -1, padding: Int = 0): Bitmap { + if (drawable is BitmapDrawable) { + return drawable.bitmap + } + var width = drawable.intrinsicWidth + width = if (width > 0) width else size + var height = drawable.intrinsicHeight + height = if (height > 0) height else size + val bitmap = + Bitmap.createBitmap(width + 2 * padding, height + 2 * padding, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(padding, padding, canvas.width - padding, canvas.height - padding) + drawable.draw(canvas) + return bitmap + } + + fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + // Raw height and width of image + val height = options.outHeight + val width = options.outWidth + var inSampleSize = 1 + if (height > reqHeight || width > reqWidth) { + val halfHeight = height / 2 + val halfWidth = width / 2 + + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while (halfHeight / inSampleSize >= reqHeight + && halfWidth / inSampleSize >= reqWidth + ) { + inSampleSize *= 2 + } + } + return inSampleSize + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/ClipboardHelper.java b/ring-android/app/src/main/java/cx/ring/utils/ClipboardHelper.java deleted file mode 100644 index 73e8b90a4..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/ClipboardHelper.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.utils; - -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; - -import cx.ring.BuildConfig; -import cx.ring.R; - -public class ClipboardHelper { - public static final String TAG = ClipboardHelper.class.getSimpleName(); - - public static void copyToClipboard(final @NonNull Context context, - final String text) { - if (TextUtils.isEmpty(text)) { - Log.d(TAG, "copyNumberToClipboard: number is null"); - return; - } - - ClipboardManager clipboard = (ClipboardManager) context - .getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = android.content.ClipData.newPlainText(context.getText(R.string.clip_contact_uri), text); - clipboard.setPrimaryClip(clip); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/ClipboardHelper.kt b/ring-android/app/src/main/java/cx/ring/utils/ClipboardHelper.kt new file mode 100644 index 000000000..fe99f5375 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/ClipboardHelper.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.utils + +import android.text.TextUtils +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import cx.ring.R + +object ClipboardHelper { + val TAG = ClipboardHelper::class.simpleName!! + fun copyToClipboard( + context: Context, + text: String? + ) { + if (TextUtils.isEmpty(text)) { + return + } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(context.getText(R.string.clip_contact_uri), text) + clipboard.setPrimaryClip(clip) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/ContentUriHandler.java b/ring-android/app/src/main/java/cx/ring/utils/ContentUriHandler.java deleted file mode 100644 index 27f733902..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/ContentUriHandler.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.utils; - -import android.content.ContentResolver; -import android.content.Context; -import android.content.res.Resources; -import android.net.Uri; -import android.os.Build; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.FileProvider; - -import net.jami.utils.FileUtils; - -import java.io.File; - -import cx.ring.BuildConfig; - -/** - * This class distributes content uri used to pass along data in the app - */ -public class ContentUriHandler { - private final static String TAG = ContentUriHandler.class.getSimpleName(); - - public static final String AUTHORITY = BuildConfig.APPLICATION_ID; - public static final String AUTHORITY_FILES = AUTHORITY + ".file_provider"; - public static final String SCHEME_TV = "jamitv"; - public static final String PATH_TV_HOME = "home"; - public static final String PATH_TV_CONVERSATION = "conversation"; - - private static final Uri AUTHORITY_URI = Uri.parse("content://" + AUTHORITY); - - public static final Uri CONVERSATION_CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "conversation"); - public static final Uri ACCOUNTS_CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "accounts"); - public static final Uri CONTACT_CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "contact"); - - private ContentUriHandler() { - // hidden constructor - } - - /** - * The following is a workaround used to mitigate getUriForFile exceptions - * on Huawei devices taken from stackoverflow - * https://stackoverflow.com/a/41309223 - */ - private static final String HUAWEI_MANUFACTURER = "Huawei"; - - public static Uri getUriForResource(@NonNull Context context, int resourceId) { - Resources resources = context.getResources(); - return new Uri.Builder() - .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority(resources.getResourcePackageName(resourceId)) - .appendPath(resources.getResourceTypeName(resourceId)) - .appendPath(resources.getResourceEntryName(resourceId)) - .build(); - } - - public static Uri getUriForFile(@NonNull Context context, @NonNull String authority, @NonNull File file) { - return getUriForFile(context, authority, file, null); - } - public static Uri getUriForFile(@NonNull Context context, @NonNull String authority, @NonNull File file, @Nullable String displayName) { - try { - return displayName == null ? FileProvider.getUriForFile(context, authority, file) - : FileProvider.getUriForFile(context, authority, file, displayName); - } catch (IllegalArgumentException e) { - if (HUAWEI_MANUFACTURER.equalsIgnoreCase(Build.MANUFACTURER)) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - Log.w(TAG, "Returning Uri.fromFile to avoid Huawei 'external-files-path' bug for pre-N devices", e); - return Uri.fromFile(file); - } else { - Log.w(TAG, "ANR Risk -- Copying the file the location cache to avoid Huawei 'external-files-path' bug for N+ devices", e); - // Note: Periodically clear this cache - final File cacheFolder = new File(context.getCacheDir(), HUAWEI_MANUFACTURER); - final File cacheLocation = new File(cacheFolder, file.getName()); - if (FileUtils.copyFile(file, cacheLocation)) { - Log.i(TAG, "Completed Android N+ Huawei file copy. Attempting to return the cached file"); - return displayName == null ? FileProvider.getUriForFile(context, authority, cacheLocation) - : FileProvider.getUriForFile(context, authority, cacheLocation, displayName); - } - Log.e(TAG, "Failed to copy the Huawei file. Re-throwing exception"); - throw new IllegalArgumentException("Huawei devices are unsupported for Android N"); - } - } else { - throw e; - } - } - } -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/ContentUriHandler.kt b/ring-android/app/src/main/java/cx/ring/utils/ContentUriHandler.kt new file mode 100644 index 000000000..3e6d99dbd --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/ContentUriHandler.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.utils + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import android.os.Build +import android.util.Log +import cx.ring.BuildConfig +import net.jami.utils.FileUtils +import java.io.File +import java.lang.IllegalArgumentException + +/** + * This class distributes content uri used to pass along data in the app + */ +object ContentUriHandler { + private val TAG = ContentUriHandler::class.java.simpleName + const val AUTHORITY = BuildConfig.APPLICATION_ID + const val AUTHORITY_FILES = "$AUTHORITY.file_provider" + const val SCHEME_TV = "jamitv" + const val PATH_TV_HOME = "home" + const val PATH_TV_CONVERSATION = "conversation" + private val AUTHORITY_URI = Uri.parse("content://$AUTHORITY") + + val CONVERSATION_CONTENT_URI: Uri = Uri.withAppendedPath(AUTHORITY_URI, "conversation") + val ACCOUNTS_CONTENT_URI: Uri = Uri.withAppendedPath(AUTHORITY_URI, "accounts") + val CONTACT_CONTENT_URI: Uri = Uri.withAppendedPath(AUTHORITY_URI, "contact") + + /** + * The following is a workaround used to mitigate getUriForFile exceptions + * on Huawei devices taken from stackoverflow + * https://stackoverflow.com/a/41309223 + */ + private const val HUAWEI_MANUFACTURER = "Huawei" + fun getUriForResource(context: Context, resourceId: Int): Uri { + val resources = context.resources + return Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(resources.getResourcePackageName(resourceId)) + .appendPath(resources.getResourceTypeName(resourceId)) + .appendPath(resources.getResourceEntryName(resourceId)) + .build() + } + + @JvmStatic + fun getUriForFile(context: Context, authority: String, file: File): Uri { + return getUriForFile(context, authority, file, null) + } + + @JvmStatic + fun getUriForFile(context: Context, authority: String, file: File, displayName: String?): Uri { + return try { + if (displayName == null) + FileProvider.getUriForFile(context, authority, file) + else + FileProvider.getUriForFile(context, authority, file, displayName) + } catch (e: IllegalArgumentException) { + if (HUAWEI_MANUFACTURER.equals(Build.MANUFACTURER, ignoreCase = true)) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + Log.w(TAG, "Returning Uri.fromFile to avoid Huawei 'external-files-path' bug for pre-N devices", e) + Uri.fromFile(file) + } else { + Log.w(TAG, "ANR Risk -- Copying the file the location cache to avoid Huawei 'external-files-path' bug for N+ devices", e) + // Note: Periodically clear this cache + val cacheFolder = File(context.cacheDir, HUAWEI_MANUFACTURER) + val cacheLocation = File(cacheFolder, file.name) + if (FileUtils.copyFile(file, cacheLocation)) { + Log.i(TAG, "Completed Android N+ Huawei file copy. Attempting to return the cached file") + return if (displayName == null) FileProvider.getUriForFile( + context, + authority, + cacheLocation + ) else FileProvider.getUriForFile( + context, + authority, + cacheLocation, + displayName + ) + } + Log.e(TAG, "Failed to copy the Huawei file. Re-throwing exception") + throw IllegalArgumentException("Huawei devices are unsupported for Android N") + } + } else { + throw e + } + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/ConversationPath.java b/ring-android/app/src/main/java/cx/ring/utils/ConversationPath.java deleted file mode 100644 index fda742130..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/ConversationPath.java +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.utils; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.pm.ShortcutManagerCompat; - -import net.jami.model.Conversation; -import net.jami.model.Interaction; -import net.jami.utils.StringUtils; -import net.jami.utils.Tuple; - -import java.util.Arrays; -import java.util.List; -import java.util.Objects; - -import cx.ring.BuildConfig; - -public class ConversationPath { - public static final String KEY_CONVERSATION_URI = BuildConfig.APPLICATION_ID + ".conversationUri"; - public static final String KEY_ACCOUNT_ID = BuildConfig.APPLICATION_ID + ".accountId"; - - private final String accountId; - private final String conversationId; - public ConversationPath(String account, String contact) { - accountId = account; - conversationId = contact; - } - public ConversationPath(String account, net.jami.model.Uri conversationUri) { - accountId = account; - conversationId = conversationUri.getUri(); - } - - public ConversationPath(@NonNull Tuple<String, String> path) { - accountId = path.first; - conversationId = path.second; - } - - public ConversationPath(Conversation conversation) { - accountId = conversation.getAccountId(); - conversationId = conversation.getUri().getUri(); - } - - public String getAccountId() { - return accountId; - } - public String getConversationId() { - return conversationId; - } - public net.jami.model.Uri getConversationUri() { - return net.jami.model.Uri.fromString(conversationId); - } - - @Deprecated - public String getContactId() { - return conversationId; - } - - public Uri toUri() { - return toUri(accountId, conversationId); - } - public static Uri toUri(String accountId, String contactId) { - return ContentUriHandler.CONVERSATION_CONTENT_URI.buildUpon() - .appendEncodedPath(accountId) - .appendEncodedPath(contactId) - .build(); - } - public static Uri toUri(String accountId, @NonNull net.jami.model.Uri conversationUri) { - return ContentUriHandler.CONVERSATION_CONTENT_URI.buildUpon() - .appendEncodedPath(accountId) - .appendEncodedPath(conversationUri.getUri()) - .build(); - } - public static Uri toUri(@NonNull Conversation conversation) { - return toUri(conversation.getAccountId(), conversation.getUri()); - } - public static Uri toUri(@NonNull Interaction interaction) { - if (interaction.getConversation() instanceof Conversation) - return toUri(interaction.getAccount(), ((Conversation) interaction.getConversation()).getUri()); - else - return toUri(interaction.getAccount(), net.jami.model.Uri.fromString(interaction.getConversation().getParticipant())); - } - - public Bundle toBundle() { - return toBundle(accountId, conversationId); - } - public void toBundle(Bundle bundle) { - bundle.putString(KEY_CONVERSATION_URI, conversationId); - bundle.putString(KEY_ACCOUNT_ID, accountId); - } - - public static Bundle toBundle(String accountId, String uri) { - Bundle bundle = new Bundle(); - bundle.putString(KEY_CONVERSATION_URI, uri); - bundle.putString(KEY_ACCOUNT_ID, accountId); - return bundle; - } - public static Bundle toBundle(String accountId, net.jami.model.Uri uri) { - return toBundle(accountId, uri.getUri()); - } - public static Bundle toBundle(@NonNull Conversation conversation) { - return toBundle(conversation.getAccountId(), conversation.getUri()); - } - - public static String toKey(String accountId, String uri) { - return TextUtils.join(",", Arrays.asList(accountId, uri)); - } - public String toKey() { - return toKey(accountId, conversationId); - } - public static ConversationPath fromKey(String key) { - if (key != null) { - String[] keys = TextUtils.split(key, ","); - if (keys.length > 1) { - String accountId = keys[0]; - String contactId = keys[1]; - if (!StringUtils.isEmpty(accountId) && !StringUtils.isEmpty(contactId)) { - return new ConversationPath(accountId, contactId); - } - } - } - return null; - } - - public static ConversationPath fromUri(Uri uri) { - if (uri == null) - return null; - if (ContentUriHandler.SCHEME_TV.equals(uri.getScheme()) || uri.toString().startsWith(ContentUriHandler.CONVERSATION_CONTENT_URI.toString())) { - List<String> pathSegments = uri.getPathSegments(); - if (pathSegments.size() > 2) { - return new ConversationPath(pathSegments.get(pathSegments.size() - 2), pathSegments.get(pathSegments.size() - 1)); - } - } - return null; - } - - public static ConversationPath fromBundle(@Nullable Bundle bundle) { - if (bundle != null) { - String accountId = bundle.getString(KEY_ACCOUNT_ID); - String contactId = bundle.getString(KEY_CONVERSATION_URI); - if (accountId != null && contactId != null) { - return new ConversationPath(accountId, contactId); - } else { - String shortcutId = bundle.getString(ShortcutManagerCompat.EXTRA_SHORTCUT_ID); - if (shortcutId != null) - return fromKey(shortcutId); - } - } - return null; - } - - public static ConversationPath fromIntent(@Nullable Intent intent) { - if (intent != null) { - Uri uri = intent.getData(); - ConversationPath conversationPath = fromUri(uri); - if (conversationPath != null) - return conversationPath; - return fromBundle(intent.getExtras()); - } - return null; - } - - @Override - public @NonNull String toString() { - return "ConversationPath{" + - "accountId='" + accountId + '\'' + - ", conversationId='" + conversationId + '\'' + - '}'; - } - - @Override - public boolean equals(@Nullable Object obj) { - if (obj == this) - return true; - if (!(obj instanceof ConversationPath)) - return false; - ConversationPath o = (ConversationPath) obj; - return Objects.equals(o.accountId, accountId) - && Objects.equals(o.conversationId, conversationId); - } - - @Override - public int hashCode() { - return Objects.hash(accountId, conversationId); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/ConversationPath.kt b/ring-android/app/src/main/java/cx/ring/utils/ConversationPath.kt new file mode 100644 index 000000000..fbd26a1dc --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/ConversationPath.kt @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.utils + +import android.os.Bundle +import net.jami.model.Interaction +import android.text.TextUtils +import androidx.core.content.pm.ShortcutManagerCompat +import android.content.Intent +import cx.ring.BuildConfig +import net.jami.model.Conversation +import net.jami.model.Uri +import net.jami.utils.StringUtils +import net.jami.utils.Tuple +import java.util.* + +class ConversationPath { + val accountId: String + val conversationId: String + val conversationUri: Uri + get() = Uri.fromString(conversationId) + + constructor(account: String, contact: String) { + accountId = account + conversationId = contact + } + + constructor(account: String, conversationUri: Uri) { + accountId = account + conversationId = conversationUri.uri + } + + constructor(path: Tuple<String, String>) { + accountId = path.first + conversationId = path.second + } + + constructor(conversation: Conversation) { + accountId = conversation.accountId + conversationId = conversation.uri.uri + } + + fun toBundle(bundle: Bundle) { + bundle.putString(KEY_CONVERSATION_URI, conversationId) + bundle.putString(KEY_ACCOUNT_ID, accountId) + } + fun toBundle(): Bundle { + val bundle = Bundle() + bundle.putString(KEY_CONVERSATION_URI, conversationId) + bundle.putString(KEY_ACCOUNT_ID, accountId) + return bundle + } + fun toUri(): android.net.Uri { + return ContentUriHandler.CONVERSATION_CONTENT_URI.buildUpon() + .appendEncodedPath(accountId) + .appendEncodedPath(conversationId) + .build() + } + fun toKey(): String { + return TextUtils.join(",", listOf(accountId, conversationId)) + } + + override fun toString(): String { + return "ConversationPath{accountId='$accountId' conversationId='$conversationId'}" + } + + override fun equals(other: Any?): Boolean { + if (other === this) return true + if (other !is ConversationPath) return false + return (other.accountId == accountId + && other.conversationId == conversationId) + } + + override fun hashCode(): Int { + return Objects.hash(accountId, conversationId) + } + + companion object { + const val KEY_CONVERSATION_URI = BuildConfig.APPLICATION_ID + ".conversationUri" + const val KEY_ACCOUNT_ID = BuildConfig.APPLICATION_ID + ".accountId" + + fun toUri(accountId: String, contactId: String): android.net.Uri { + return ContentUriHandler.CONVERSATION_CONTENT_URI.buildUpon() + .appendEncodedPath(accountId) + .appendEncodedPath(contactId) + .build() + } + + fun toUri(accountId: String?, conversationUri: Uri): android.net.Uri { + return ContentUriHandler.CONVERSATION_CONTENT_URI.buildUpon() + .appendEncodedPath(accountId) + .appendEncodedPath(conversationUri.uri) + .build() + } + + fun toUri(conversation: Conversation): android.net.Uri { + return toUri(conversation.accountId, conversation.uri) + } + + fun toUri(interaction: Interaction): android.net.Uri { + return if (interaction.conversation is Conversation) + toUri(interaction.account, (interaction.conversation as Conversation).uri) + else + toUri(interaction.account, Uri.fromString(interaction.conversation!!.participant)) + } + + fun toBundle(accountId: String, uri: String): Bundle { + val bundle = Bundle() + bundle.putString(KEY_CONVERSATION_URI, uri) + bundle.putString(KEY_ACCOUNT_ID, accountId) + return bundle + } + + fun toBundle(accountId: String, uri: Uri): Bundle { + return toBundle(accountId, uri.uri) + } + + fun toBundle(conversation: Conversation): Bundle { + return toBundle(conversation.accountId, conversation.uri) + } + + fun toKey(accountId: String, uri: String): String { + return TextUtils.join(",", listOf(accountId, uri)) + } + + fun fromKey(key: String?): ConversationPath? { + if (key != null) { + val keys = TextUtils.split(key, ",") + if (keys.size > 1) { + val accountId = keys[0] + val contactId = keys[1] + if (!StringUtils.isEmpty(accountId) && !StringUtils.isEmpty(contactId)) { + return ConversationPath(accountId, contactId) + } + } + } + return null + } + + fun fromUri(uri: android.net.Uri?): ConversationPath? { + if (uri == null) return null + if (ContentUriHandler.SCHEME_TV == uri.scheme || uri.toString().startsWith(ContentUriHandler.CONVERSATION_CONTENT_URI.toString())) { + val pathSegments = uri.pathSegments + if (pathSegments.size > 2) { + return ConversationPath(pathSegments[pathSegments.size - 2], pathSegments[pathSegments.size - 1]) + } + } + return null + } + + fun fromBundle(bundle: Bundle?): ConversationPath? { + if (bundle != null) { + val accountId = bundle.getString(KEY_ACCOUNT_ID) + val contactId = bundle.getString(KEY_CONVERSATION_URI) + if (accountId != null && contactId != null) { + return ConversationPath(accountId, contactId) + } else { + val shortcutId = bundle.getString(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) + if (shortcutId != null) return fromKey(shortcutId) + } + } + return null + } + + fun fromIntent(intent: Intent?): ConversationPath? { + if (intent != null) { + val uri = intent.data + val conversationPath = fromUri(uri) + return conversationPath ?: fromBundle(intent.extras) + } + return null + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/DeviceUtils.kt b/ring-android/app/src/main/java/cx/ring/utils/DeviceUtils.kt new file mode 100644 index 000000000..15f196329 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/DeviceUtils.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Pierre Duchemin <pierre.duchemin@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.utils + +import android.app.UiModeManager +import android.content.Context +import android.content.res.Configuration +import cx.ring.R + +object DeviceUtils { + @JvmStatic + fun isTv(context: Context): Boolean { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + } + + @JvmStatic + fun isTablet(context: Context): Boolean { + return context.resources.getBoolean(R.bool.isTablet) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/JamiGlideModule.java b/ring-android/app/src/main/java/cx/ring/utils/JamiGlideModule.java deleted file mode 100644 index 68315533b..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/JamiGlideModule.java +++ /dev/null @@ -1,22 +0,0 @@ -package cx.ring.utils; - -import android.content.Context; -import androidx.annotation.NonNull; - -import com.bumptech.glide.GlideBuilder; -import com.bumptech.glide.annotation.GlideModule; -import com.bumptech.glide.load.DecodeFormat; -import com.bumptech.glide.module.AppGlideModule; -import com.bumptech.glide.request.RequestOptions; - -@GlideModule -public final class JamiGlideModule extends AppGlideModule { - - @Override - public void applyOptions(@NonNull Context context, GlideBuilder builder) { - builder.setDefaultRequestOptions( - new RequestOptions() - .format(DecodeFormat.PREFER_ARGB_8888)); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/JamiGlideModule.kt b/ring-android/app/src/main/java/cx/ring/utils/JamiGlideModule.kt new file mode 100644 index 000000000..a49de0ca1 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/JamiGlideModule.kt @@ -0,0 +1,15 @@ +package cx.ring.utils + +import android.content.Context +import com.bumptech.glide.module.AppGlideModule +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.load.DecodeFormat + +@GlideModule +class JamiGlideModule : AppGlideModule() { + override fun applyOptions(context: Context, builder: GlideBuilder) { + builder.setDefaultRequestOptions(RequestOptions().format(DecodeFormat.PREFER_ARGB_8888)) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameFilter.java b/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameFilter.java deleted file mode 100644 index 1fe541305..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameFilter.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.utils; - -import android.text.InputFilter; -import android.text.SpannableString; -import android.text.Spanned; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class RegisteredNameFilter implements InputFilter { - private static final Pattern REGISTERED_NAME_CHAR_PATTERN = Pattern.compile("[a-z0-9_\\-]", Pattern.CASE_INSENSITIVE); - private final Matcher nameCharMatcher = REGISTERED_NAME_CHAR_PATTERN.matcher(""); - - @Override - public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { - boolean keepOriginal = true; - StringBuilder sb = new StringBuilder(end - start); - for (int i = start; i < end; i++) { - char c = source.charAt(i); - if (isCharAllowed(c)) { - sb.append(c); - } else { - keepOriginal = false; - } - } - if (keepOriginal) { - return null; - } else { - if (source instanceof Spanned) { - return new SpannableString(sb); - } else { - return sb; - } - } - } - - private boolean isCharAllowed(char c) { - return nameCharMatcher.reset(String.valueOf(c)).matches(); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameFilter.kt b/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameFilter.kt new file mode 100644 index 000000000..49e39688d --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameFilter.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.utils + +import android.text.InputFilter +import android.text.Spanned +import android.text.SpannableString +import java.lang.StringBuilder +import java.util.regex.Pattern + +class RegisteredNameFilter : InputFilter { + private val nameCharMatcher = REGISTERED_NAME_CHAR_PATTERN.matcher("") + override fun filter( + source: CharSequence, + start: Int, + end: Int, + dest: Spanned, + dstart: Int, + dend: Int + ): CharSequence? { + var keepOriginal = true + val sb = StringBuilder(end - start) + for (i in start until end) { + val c = source[i] + if (isCharAllowed(c)) { + sb.append(c) + } else { + keepOriginal = false + } + } + return if (keepOriginal) { + null + } else { + if (source is Spanned) { + SpannableString(sb) + } else { + sb + } + } + } + + private fun isCharAllowed(c: Char): Boolean { + return nameCharMatcher.reset(c.toString()).matches() + } + + companion object { + private val REGISTERED_NAME_CHAR_PATTERN = + Pattern.compile("[a-z0-9_\\-]", Pattern.CASE_INSENSITIVE) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameTextWatcher.java b/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameTextWatcher.java deleted file mode 100644 index 58f195e4f..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameTextWatcher.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package cx.ring.utils; - -import android.content.Context; -import com.google.android.material.textfield.TextInputLayout; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.widget.EditText; - -import java.lang.ref.WeakReference; - -import cx.ring.R; -import net.jami.services.AccountService; -import net.jami.utils.NameLookupInputHandler; - -public class RegisteredNameTextWatcher implements TextWatcher { - - private WeakReference<TextInputLayout> mInputLayout; - private WeakReference<EditText> mInputText; - private NameLookupInputHandler mNameLookupInputHandler; - private String mLookingForAvailability; - - public RegisteredNameTextWatcher(Context context, AccountService accountService, String accountId, TextInputLayout inputLayout, EditText inputText) { - mInputLayout = new WeakReference<>(inputLayout); - mInputText = new WeakReference<>(inputText); - mLookingForAvailability = context.getString(R.string.looking_for_username_availability); - mNameLookupInputHandler = new net.jami.utils.NameLookupInputHandler(accountService, accountId); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - if (mInputText.get() != null) { - mInputText.get().setError(null); - } - } - - @Override - public void afterTextChanged(final Editable txt) { - final String name = txt.toString(); - - if (mInputLayout.get() == null || mInputText.get() == null) { - return; - } - - if (TextUtils.isEmpty(name)) { - mInputLayout.get().setErrorEnabled(false); - mInputLayout.get().setError(null); - } else { - mInputLayout.get().setErrorEnabled(true); - mInputLayout.get().setError(mLookingForAvailability); - } - - if (!TextUtils.isEmpty(name)) { - mNameLookupInputHandler.enqueueNextLookup(name); - } - } -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameTextWatcher.kt b/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameTextWatcher.kt new file mode 100644 index 000000000..0a952af76 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/RegisteredNameTextWatcher.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.utils + +import android.content.Context +import net.jami.services.AccountService +import com.google.android.material.textfield.TextInputLayout +import android.widget.EditText +import android.text.TextWatcher +import net.jami.utils.NameLookupInputHandler +import android.text.Editable +import android.text.TextUtils +import cx.ring.R +import java.lang.ref.WeakReference + +class RegisteredNameTextWatcher( + context: Context, + accountService: AccountService, + accountId: String, + inputLayout: TextInputLayout, + inputText: EditText +) : TextWatcher { + private val mInputLayout: WeakReference<TextInputLayout> = WeakReference(inputLayout) + private val mInputText: WeakReference<EditText> = WeakReference(inputText) + private val mNameLookupInputHandler = NameLookupInputHandler(accountService, accountId) + private val mLookingForAvailability = context.getString(R.string.looking_for_username_availability) + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + mInputText.get()?.apply { error = null } + } + + override fun afterTextChanged(txt: Editable) { + val name = txt.toString() + mInputLayout.get()?.let { inputLayout -> + if (TextUtils.isEmpty(name)) { + inputLayout.isErrorEnabled = false + inputLayout.error = null + } else { + inputLayout.isErrorEnabled = true + inputLayout.error = mLookingForAvailability + } + } + if (!TextUtils.isEmpty(name)) { + mNameLookupInputHandler.enqueueNextLookup(name) + } + } + +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/Ringer.java b/ring-android/app/src/main/java/cx/ring/utils/Ringer.java deleted file mode 100644 index 6c10af37b..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/Ringer.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.utils; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.media.AudioAttributes; -import android.media.AudioManager; -import android.os.Build; -import android.os.Vibrator; -import android.util.Log; - -/** - * Ringer manager - */ -public class Ringer { - private static final String TAG = Ringer.class.getSimpleName(); - private static final long[] VIBRATE_PATTERN = {0, 1000, 1000}; - - @SuppressLint("NewApi") - private static final AudioAttributes VIBRATE_ATTRIBUTES = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? - new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE).build() : null; - - private final Context context; - private final Vibrator vibrator; - - public Ringer(Context aContext) { - context = aContext; - vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); - } - - /** - * Starts the ringtone and/or vibrator. - */ - public void ring() { - Log.d(TAG, "ring: called..."); - - AudioManager audioManager = - (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - - vibrator.cancel(); - - int ringerMode = audioManager.getRingerMode(); - if (ringerMode == AudioManager.RINGER_MODE_SILENT) { - //No ring no vibrate - Log.d(TAG, "ring: skipping ring and vibrate because profile is Silent"); - } else if (ringerMode == AudioManager.RINGER_MODE_VIBRATE || ringerMode == AudioManager.RINGER_MODE_NORMAL) { - // Vibrate - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - vibrator.vibrate(VIBRATE_PATTERN, 0, VIBRATE_ATTRIBUTES); - } else { - vibrator.vibrate(VIBRATE_PATTERN, 0); - } - } - } - - /** - * Stops the ringtone and/or vibrator if any of these are actually - * ringing/vibrating. - */ - public void stopRing() { - Log.d(TAG, "stopRing: called..."); - vibrator.cancel(); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/Ringer.kt b/ring-android/app/src/main/java/cx/ring/utils/Ringer.kt new file mode 100644 index 000000000..54a565686 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/Ringer.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.utils + +import android.content.Context +import android.os.Vibrator +import android.media.AudioManager +import android.media.AudioAttributes +import android.util.Log + +/** + * Ringer manager + */ +class Ringer(private val context: Context) { + private val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + + /** + * Starts the ringtone and/or vibrator. + */ + fun ring() { + Log.d(TAG, "ring: called...") + vibrator.cancel() + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val ringerMode = audioManager.ringerMode + if (ringerMode == AudioManager.RINGER_MODE_SILENT) { + //No ring no vibrate + Log.d(TAG, "ring: skipping ring and vibrate because profile is Silent") + } else if (ringerMode == AudioManager.RINGER_MODE_VIBRATE || ringerMode == AudioManager.RINGER_MODE_NORMAL) { + // Vibrate + vibrator.vibrate(VIBRATE_PATTERN, 0, VIBRATE_ATTRIBUTES) + } + } + + /** + * Stops the ringtone and/or vibrator if any of these are actually + * ringing/vibrating. + */ + fun stopRing() { + Log.d(TAG, "stopRing: called...") + vibrator.cancel() + } + + companion object { + private val TAG = Ringer::class.simpleName!! + private val VIBRATE_PATTERN = longArrayOf(0, 1000, 1000) + private val VIBRATE_ATTRIBUTES = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE).build() + } + +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/utils/ShadowScrollBehavior.java b/ring-android/app/src/main/java/cx/ring/utils/ShadowScrollBehavior.java deleted file mode 100644 index e4b785474..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/ShadowScrollBehavior.java +++ /dev/null @@ -1,90 +0,0 @@ -package cx.ring.utils; - -import android.content.Context; -import android.content.res.Resources; -import android.util.AttributeSet; -import android.util.TypedValue; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.view.ViewCompat; -import androidx.transition.TransitionManager; - -import com.google.android.material.appbar.AppBarLayout; - -public class ShadowScrollBehavior extends AppBarLayout.ScrollingViewBehavior - implements View.OnLayoutChangeListener { - - int totalDy = 0; - boolean isElevated; - View child; - - public ShadowScrollBehavior(Context context, AttributeSet attrs) { - super(context, attrs); - } - - @Override - public boolean layoutDependsOn(CoordinatorLayout parent, View child, - View dependency) { - parent.addOnLayoutChangeListener(this); - this.child = child; - return super.layoutDependsOn(parent, child, dependency); - } - - @Override - public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, - @NonNull View child, @NonNull View directTargetChild, - @NonNull View target, int axes, int type) { - // Ensure we react to vertical scrolling - return axes == ViewCompat.SCROLL_AXIS_VERTICAL || - super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, - target, axes, type); - } - - @Override - public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, - @NonNull View child, @NonNull View target, - int dx, int dy, @NonNull int[] consumed, int type) { - totalDy += dy; - if (totalDy <= 0) { - if (isElevated) { - ViewGroup parent = (ViewGroup) child.getParent(); - if (parent != null) { - TransitionManager.beginDelayedTransition(parent); - ViewCompat.setElevation(child, 0); - } - } - totalDy = 0; - isElevated = false; - } else { - if (!isElevated) { - ViewGroup parent = (ViewGroup) child.getParent(); - if (parent != null) { - TransitionManager.beginDelayedTransition(parent); - ViewCompat.setElevation(child, dp2px(child.getContext(), 4)); - } - } - if (totalDy > target.getBottom()) - totalDy = target.getBottom(); - isElevated = true; - } - super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type); - } - - - private float dp2px(Context context, int dp) { - Resources r = context.getResources(); - float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, r.getDisplayMetrics()); - return px; - } - - - @Override - public void onLayoutChange(View view, int i, int i1, int i2, int i3, int i4, int i5, int i6, int i7) { - totalDy = 0; - isElevated = false; - ViewCompat.setElevation(child, 0); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.java b/ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.java deleted file mode 100644 index a5c079b80..000000000 --- a/ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.java +++ /dev/null @@ -1,54 +0,0 @@ -package cx.ring.utils; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.view.GestureDetector; -import android.view.MotionEvent; -import android.view.View; - -public class TouchClickListener implements GestureDetector.OnGestureListener, View.OnTouchListener { - private final View.OnClickListener onClickListener; - private final GestureDetector mGestureDetector; - private View view; - - public TouchClickListener(Context c, View.OnClickListener l) { - onClickListener = l; - mGestureDetector = new GestureDetector(c, this); - } - - @Override - public boolean onDown(MotionEvent e) { - return false; - } - - @Override - public void onShowPress(MotionEvent e) {} - - @Override - public boolean onSingleTapUp(MotionEvent e) { - onClickListener.onClick(view); - return true; - } - - @Override - public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { - return false; - } - - @Override - public void onLongPress(MotionEvent e) {} - - @Override - public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { - return false; - } - - @SuppressLint("ClickableViewAccessibility") - @Override - public boolean onTouch(View v, MotionEvent event) { - view = v; - mGestureDetector.onTouchEvent(event); - view = null; - return true; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.kt b/ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.kt new file mode 100644 index 000000000..206a80e4c --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.utils + +import android.view.GestureDetector +import android.view.View.OnTouchListener +import android.view.MotionEvent +import android.annotation.SuppressLint +import android.content.Context +import android.view.View + +class TouchClickListener(c: Context?, private val onClickListener: View.OnClickListener) : + GestureDetector.OnGestureListener, OnTouchListener { + private val mGestureDetector: GestureDetector = GestureDetector(c, this) + private var view: View? = null + override fun onDown(e: MotionEvent): Boolean { + return false + } + + override fun onShowPress(e: MotionEvent) {} + override fun onSingleTapUp(e: MotionEvent): Boolean { + onClickListener.onClick(view) + return true + } + + override fun onScroll( + e1: MotionEvent, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + return false + } + + override fun onLongPress(e: MotionEvent) {} + override fun onFling( + e1: MotionEvent, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + return false + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + view = v + mGestureDetector.onTouchEvent(event) + view = null + return true + } + +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/viewholders/SmartListViewHolder.java b/ring-android/app/src/main/java/cx/ring/viewholders/SmartListViewHolder.java deleted file mode 100644 index 6dea324b2..000000000 --- a/ring-android/app/src/main/java/cx/ring/viewholders/SmartListViewHolder.java +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.viewholders; - -import android.content.Context; -import android.graphics.Typeface; -import android.text.format.DateUtils; -import android.util.Log; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import net.jami.model.Call; -import net.jami.model.ContactEvent; -import net.jami.model.Interaction; -import net.jami.smartlist.SmartListViewModel; - -import cx.ring.R; -import cx.ring.databinding.ItemSmartlistBinding; -import cx.ring.databinding.ItemSmartlistHeaderBinding; -import cx.ring.utils.ResourceMapper; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class SmartListViewHolder extends RecyclerView.ViewHolder { - public final ItemSmartlistBinding binding; - public final ItemSmartlistHeaderBinding headerBinding; - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - - public SmartListViewHolder(@NonNull ItemSmartlistBinding b, @NonNull CompositeDisposable parentDisposable) { - super(b.getRoot()); - binding = b; - headerBinding = null; - parentDisposable.add(compositeDisposable); - } - - public SmartListViewHolder(@NonNull ItemSmartlistHeaderBinding b, @NonNull CompositeDisposable parentDisposable) { - super(b.getRoot()); - binding = null; - headerBinding = b; - parentDisposable.add(compositeDisposable); - } - - public void bind(final SmartListListeners clickListener, final SmartListViewModel smartListViewModel) { - //Log.w("SmartListViewHolder", "bind " + smartListViewModel.getContact() + " " +smartListViewModel.showPresence()); - compositeDisposable.clear(); - - if (binding != null) { - itemView.setOnClickListener(v -> clickListener.onItemClick(smartListViewModel)); - Observable<Boolean> selected = smartListViewModel.getSelected(); - if (selected != null) { - compositeDisposable.add(smartListViewModel.getSelected().subscribe(binding.itemLayout::setActivated)); - } - itemView.setOnLongClickListener(v -> { - clickListener.onItemLongClick(smartListViewModel); - return true; - }); - - binding.convParticipant.setText(smartListViewModel.getContactName()); - - long lastInteraction = smartListViewModel.getLastInteractionTime(); - String lastInteractionStr = lastInteraction == 0 ? - "" : DateUtils.getRelativeTimeSpanString(lastInteraction, System.currentTimeMillis(), 0L, DateUtils.FORMAT_ABBREV_ALL).toString(); - - binding.convLastTime.setText(lastInteractionStr); - if (smartListViewModel.hasOngoingCall()) { - binding.convLastItem.setVisibility(View.VISIBLE); - binding.convLastItem.setText(itemView.getContext().getString(R.string.ongoing_call)); - } else if (smartListViewModel.getLastEvent() != null) { - binding.convLastItem.setVisibility(View.VISIBLE); - binding.convLastItem.setText(getLastEventSummary(smartListViewModel.getLastEvent(), itemView.getContext())); - } else { - binding.convLastItem.setVisibility(View.GONE); - } - - if (smartListViewModel.hasUnreadTextMessage()) { - binding.convParticipant.setTypeface(null, Typeface.BOLD); - binding.convLastTime.setTypeface(null, Typeface.BOLD); - binding.convLastItem.setTypeface(null, Typeface.BOLD); - } else { - binding.convParticipant.setTypeface(null, Typeface.NORMAL); - binding.convLastTime.setTypeface(null, Typeface.NORMAL); - binding.convLastItem.setTypeface(null, Typeface.NORMAL); - } - - binding.photo.setImageDrawable(new AvatarDrawable.Builder() - .withViewModel(smartListViewModel) - .withCircleCrop(true) - .build(binding.photo.getContext())); - - } else if (headerBinding != null) { - headerBinding.headerTitle.setText(smartListViewModel.getHeaderTitle() == SmartListViewModel.Title.Conversations - ? R.string.navigation_item_conversation : R.string.search_results_public_directory); - } - } - - public void unbind() { - compositeDisposable.clear(); - } - - private String getLastEventSummary(Interaction e, Context context) { - if (e.getType() == (Interaction.InteractionType.TEXT)) { - if (e.isIncoming()) { - return e.getBody(); - } else { - return context.getText(R.string.you_txt_prefix) + " " + e.getBody(); - } - } else if (e.getType() == (Interaction.InteractionType.CALL)) { - Call call = (Call) e; - if (call.isMissed()) - return call.isIncoming() ? - context.getString(R.string.notif_missed_incoming_call) : - context.getString(R.string.notif_missed_outgoing_call); - else - return call.isIncoming() ? - String.format(context.getString(R.string.hist_in_call), call.getDurationString()) : - String.format(context.getString(R.string.hist_out_call), call.getDurationString()); - } else if (e.getType() == (Interaction.InteractionType.CONTACT)) { - ContactEvent contactEvent = (ContactEvent) e; - if (contactEvent.event == ContactEvent.Event.ADDED) { - return context.getString(R.string.hist_contact_added); - } else if (contactEvent.event == ContactEvent.Event.INCOMING_REQUEST) { - return context.getString(R.string.hist_invitation_received); - } - } else if (e.getType() == (Interaction.InteractionType.DATA_TRANSFER)) { - if (e.getStatus() == Interaction.InteractionStatus.TRANSFER_FINISHED) { - if (!e.isIncoming()) { - return context.getString(R.string.hist_file_sent); - } else { - return context.getString(R.string.hist_file_received); - } - } - return ResourceMapper.getReadableFileTransferStatus(context, e.getStatus()); - } - return null; - } - - public interface SmartListListeners { - void onItemClick(SmartListViewModel smartListViewModel); - - void onItemLongClick(SmartListViewModel smartListViewModel); - } - -} diff --git a/ring-android/app/src/main/java/cx/ring/viewholders/SmartListViewHolder.kt b/ring-android/app/src/main/java/cx/ring/viewholders/SmartListViewHolder.kt new file mode 100644 index 000000000..6ecfaab13 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/viewholders/SmartListViewHolder.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.viewholders + +import android.content.Context +import android.graphics.Typeface +import android.text.format.DateUtils +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import cx.ring.R +import cx.ring.databinding.ItemSmartlistBinding +import cx.ring.databinding.ItemSmartlistHeaderBinding +import cx.ring.utils.ResourceMapper +import cx.ring.views.AvatarDrawable +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import net.jami.model.Call +import net.jami.model.ContactEvent +import net.jami.model.Interaction +import net.jami.smartlist.SmartListViewModel + +class SmartListViewHolder : RecyclerView.ViewHolder { + val binding: ItemSmartlistBinding? + val headerBinding: ItemSmartlistHeaderBinding? + private val compositeDisposable = CompositeDisposable() + + constructor(b: ItemSmartlistBinding, parentDisposable: CompositeDisposable) : super(b.root) { + binding = b + headerBinding = null + parentDisposable.add(compositeDisposable) + } + + constructor(b: ItemSmartlistHeaderBinding, parentDisposable: CompositeDisposable) : super(b.root) { + binding = null + headerBinding = b + parentDisposable.add(compositeDisposable) + } + + fun bind(clickListener: SmartListListeners, smartListViewModel: SmartListViewModel) { + //Log.w("SmartListViewHolder", "bind " + smartListViewModel.getContact() + " " +smartListViewModel.showPresence()); + compositeDisposable.clear() + if (binding != null) { + itemView.setOnClickListener { clickListener.onItemClick(smartListViewModel) } + smartListViewModel.selected?.let { selected -> + compositeDisposable.add(selected.observeOn(AndroidSchedulers.mainThread()).subscribe { activated -> + binding.itemLayout.isActivated = activated + }) + } + itemView.setOnLongClickListener { + clickListener.onItemLongClick(smartListViewModel) + true + } + binding.convParticipant.text = smartListViewModel.contactName + val lastInteraction = smartListViewModel.lastInteractionTime + val lastInteractionStr = + if (lastInteraction == 0L) "" else DateUtils.getRelativeTimeSpanString( + lastInteraction, + System.currentTimeMillis(), + 0L, + DateUtils.FORMAT_ABBREV_ALL + ).toString() + binding.convLastTime.text = lastInteractionStr + if (smartListViewModel.hasOngoingCall()) { + binding.convLastItem.visibility = View.VISIBLE + binding.convLastItem.text = itemView.context.getString(R.string.ongoing_call) + } else if (smartListViewModel.lastEvent != null) { + binding.convLastItem.visibility = View.VISIBLE + binding.convLastItem.text = + getLastEventSummary(smartListViewModel.lastEvent, itemView.context) + } else { + binding.convLastItem.visibility = View.GONE + } + if (smartListViewModel.hasUnreadTextMessage()) { + binding.convParticipant.setTypeface(null, Typeface.BOLD) + binding.convLastTime.setTypeface(null, Typeface.BOLD) + binding.convLastItem.setTypeface(null, Typeface.BOLD) + } else { + binding.convParticipant.setTypeface(null, Typeface.NORMAL) + binding.convLastTime.setTypeface(null, Typeface.NORMAL) + binding.convLastItem.setTypeface(null, Typeface.NORMAL) + } + binding.photo.setImageDrawable( + AvatarDrawable.Builder() + .withViewModel(smartListViewModel) + .withCircleCrop(true) + .build(binding.photo.context) + ) + } else headerBinding?.headerTitle?.setText( + if (smartListViewModel.headerTitle == SmartListViewModel.Title.Conversations) R.string.navigation_item_conversation else R.string.search_results_public_directory + ) + } + + fun unbind() { + compositeDisposable.clear() + } + + private fun getLastEventSummary(e: Interaction, context: Context): String? { + if (e.type == Interaction.InteractionType.TEXT) { + return if (e.isIncoming) { + e.body + } else { + context.getText(R.string.you_txt_prefix).toString() + " " + e.body + } + } else if (e.type == Interaction.InteractionType.CALL) { + val call = e as Call + return if (call.isMissed) if (call.isIncoming) context.getString(R.string.notif_missed_incoming_call) else context.getString( + R.string.notif_missed_outgoing_call + ) else if (call.isIncoming) String.format( + context.getString(R.string.hist_in_call), + call.durationString + ) else String.format(context.getString(R.string.hist_out_call), call.durationString) + } else if (e.type == Interaction.InteractionType.CONTACT) { + val contactEvent = e as ContactEvent + if (contactEvent.event == ContactEvent.Event.ADDED) { + return context.getString(R.string.hist_contact_added) + } else if (contactEvent.event == ContactEvent.Event.INCOMING_REQUEST) { + return context.getString(R.string.hist_invitation_received) + } + } else if (e.type == Interaction.InteractionType.DATA_TRANSFER) { + return if (e.status == Interaction.InteractionStatus.TRANSFER_FINISHED) { + if (!e.isIncoming) { + context.getString(R.string.hist_file_sent) + } else { + context.getString(R.string.hist_file_received) + } + } else ResourceMapper.getReadableFileTransferStatus(context, e.status) + } + return null + } + + interface SmartListListeners { + fun onItemClick(smartListViewModel: SmartListViewModel) + fun onItemLongClick(smartListViewModel: SmartListViewModel) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/views/AvatarDrawable.java b/ring-android/app/src/main/java/cx/ring/views/AvatarDrawable.java deleted file mode 100644 index b8ec20818..000000000 --- a/ring-android/app/src/main/java/cx/ring/views/AvatarDrawable.java +++ /dev/null @@ -1,689 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package cx.ring.views; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.BitmapShader; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.ColorFilter; -import android.graphics.Paint; -import android.graphics.PixelFormat; -import android.graphics.PorterDuff; -import android.graphics.Rect; -import android.graphics.RectF; -import android.graphics.Shader; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.VectorDrawable; -import android.text.TextUtils; -import android.util.TypedValue; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; - -import net.jami.model.Account; -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.smartlist.SmartListViewModel; -import net.jami.utils.HashUtils; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import cx.ring.R; -import cx.ring.services.VCardServiceImpl; -import cx.ring.utils.DeviceUtils; -import io.reactivex.rxjava3.core.Single; - -public class AvatarDrawable extends Drawable { - private static final String TAG = AvatarDrawable.class.getSimpleName(); - - private static final int SIZE_AB = 36; - private static final float SIZE_BORDER = 2f; - - private static final float DEFAULT_TEXT_SIZE_PERCENTAGE = 0.5f; - private static final int PLACEHOLDER_ICON = R.drawable.baseline_account_crop_24; - private static final int PLACEHOLDER_ICON_GROUP = R.drawable.baseline_group_24; - private static final int CHECKED_ICON = R.drawable.baseline_check_circle_24; - private static final int PRESENCE_COLOR = R.color.green_A700; - - private static final int[] contactColors = { - R.color.red_500, R.color.pink_500, - R.color.purple_500, R.color.deep_purple_500, - R.color.indigo_500, R.color.blue_500, - R.color.cyan_500, R.color.teal_500, - R.color.green_500, R.color.light_green_500, - R.color.grey_500, R.color.lime_500, - R.color.amber_500, R.color.deep_orange_500, - R.color.brown_500, R.color.blue_grey_500 - }; - - private static class PresenceIndicatorInfo { - int cx, cy, radius; - } - - ; - private final PresenceIndicatorInfo presence = new PresenceIndicatorInfo(); - - private boolean update = true; - private final boolean isGroup; - private int inSize = -1; - - private final int minSize; - private final List<Bitmap> workspace; - private final List<Bitmap> bitmaps; - private VectorDrawable placeholder; - private VectorDrawable checkedIcon; - private final List<RectF> backgroundBounds; - private final List<Rect> inBounds; - private String avatarText; - private float textStartXPoint; - private float textStartYPoint; - private int color; - - private final List<Paint> clipPaint; - private final Paint textPaint = new Paint(); - private final Paint presenceFillPaint; - private final Paint presenceStrokePaint; - private final Paint checkedPaint; - private static final Paint drawPaint = new Paint(); - - static { - drawPaint.setAntiAlias(true); - drawPaint.setFilterBitmap(true); - } - - private final boolean cropCircle; - private boolean isOnline; - private boolean isChecked; - private boolean showPresence; - - public static class Builder { - - private List<Bitmap> photos = null; - private String name = null; - private String id = null; - private boolean circleCrop = false; - private boolean isOnline = false; - private boolean showPresence = true; - private boolean isChecked = false; - private boolean isGroup = false; - - public Builder() { - } - - public Builder withId(String id) { - this.id = id; - return this; - } - - public Builder withPhoto(Bitmap photo) { - this.photos = photo == null ? null : Arrays.asList(photo); // list elements must be mutable - return this; - } - - public Builder withPhotos(List<Bitmap> photos) { - this.photos = photos.isEmpty() ? null : photos; - return this; - } - - public Builder withName(String name) { - this.name = name; - return this; - } - - public Builder withCircleCrop(boolean crop) { - this.circleCrop = crop; - return this; - } - - public Builder withOnlineState(boolean isOnline) { - this.isOnline = isOnline; - return this; - } - - public Builder withPresence(boolean showPresence) { - this.showPresence = showPresence; - return this; - } - - public Builder withCheck(boolean checked) { - this.isChecked = checked; - return this; - } - - public Builder withNameData(String profileName, String username) { - return withName(TextUtils.isEmpty(profileName) ? username : profileName); - } - - public Builder withContact(Contact contact) { - if (contact == null) - return this; - return withPhoto((Bitmap) contact.getPhoto()) - .withId(contact.getPrimaryNumber()) - .withOnlineState(contact.isOnline()) - .withNameData(contact.getProfileName(), contact.getUsername()); - } - - public Builder withContacts(List<Contact> contacts) { - List<Bitmap> bitmaps = new ArrayList<>(contacts.size()); - int notTheUser = 0; - for (Contact contact : contacts) { - if (contact.isUser()) - continue; - notTheUser++; - Bitmap bitmap = (Bitmap) contact.getPhoto(); - if (bitmap != null) { - bitmaps.add(bitmap); - } - if (bitmaps.size() == 4) - break; - } - if (notTheUser == 1) { - for (Contact contact : contacts) { - if (!contact.isUser()) - return withContact(contact); - } - } - if (bitmaps.isEmpty()) { - // Fallback to the user avatar - for (Contact contact : contacts) - return withContact(contact); - } else { - return withPhotos(bitmaps); - } - return this; - } - - public Builder withConversation(Conversation conversation) { - return conversation.isSwarm() - ? withContacts(conversation.getContacts()).setGroup() - : withContact(conversation.getContact()); - } - - private Builder setGroup() { - isGroup = true; - return this; - } - - public Builder withViewModel(SmartListViewModel vm) { - boolean isSwarm = vm.getUri().isSwarm(); - return (isSwarm - ? withContacts(vm.getContacts()).setGroup() - : withContact(vm.getContacts().isEmpty() ? null : vm.getContacts().get(vm.getContacts().size() - 1))) - .withPresence(vm.showPresence()) - .withOnlineState(vm.isOnline()) - .withCheck(vm.isChecked()); - } - - public AvatarDrawable build(Context context) { - AvatarDrawable avatarDrawable = new AvatarDrawable( - context, photos, name, id, circleCrop, isGroup); - avatarDrawable.setOnline(isOnline); - avatarDrawable.setChecked(isChecked); - avatarDrawable.showPresence = this.showPresence; - return avatarDrawable; - } - - public Single<AvatarDrawable> buildAsync(Context context) { - return Single.fromCallable(() -> build(context)); - } - } - - public static Single<AvatarDrawable> load(Context context, Account account, boolean crop) { - return VCardServiceImpl.loadProfile(context, account) - .map(data -> new Builder() - .withPhoto((Bitmap) data.second) - .withNameData(data.first, account.getRegisteredName()) - .withId(account.getUri()) - .withCircleCrop(crop) - .build(context)); - } - - public static Single<AvatarDrawable> load(Context context, Account account) { - return load(context, account, true); - } - - public void update(Contact contact) { - String profileName = contact.getProfileName(); - String username = contact.getUsername(); - avatarText = convertNameToAvatarText( - TextUtils.isEmpty(profileName) ? username : profileName); - if (bitmaps != null) { - bitmaps.set(0, (Bitmap) contact.getPhoto()); - } - isOnline = contact.isOnline(); - update = true; - } - - public void setName(String name) { - avatarText = convertNameToAvatarText(name); - update = true; - } - - public void setPhoto(Bitmap photo) { - bitmaps.set(0, photo); - update = true; - } - - public void setOnline(boolean online) { - isOnline = online; - } - - public void setChecked(boolean checked) { - isChecked = checked; - } - - private AvatarDrawable(Context context, List<Bitmap> photos, String name, String id, boolean crop, boolean group) { - //Log.w("AvatarDrawable", "AvatarDrawable " + (photos == null ? null : photos.size()) + " " + name); - cropCircle = crop; - isGroup = group; - minSize = (int) TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, SIZE_AB, context.getResources().getDisplayMetrics()); - float borderSize = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, SIZE_BORDER, context.getResources().getDisplayMetrics()); - if (cropCircle) { - inSize = minSize; - } - if (photos != null && photos.size() > 0) { - avatarText = null; - bitmaps = photos; - if (photos.size() == 1) { - backgroundBounds = Collections.singletonList(new RectF()); - inBounds = Collections.singletonList(null); - clipPaint = cropCircle ? Collections.singletonList(new Paint()) : null; - workspace = Arrays.asList((Bitmap) null); - } else { - backgroundBounds = new ArrayList<>(bitmaps.size()); - inBounds = new ArrayList<>(bitmaps.size()); - clipPaint = cropCircle ? new ArrayList<>(bitmaps.size()) : null; - workspace = cropCircle ? new ArrayList<>(bitmaps.size()) : Arrays.asList((Bitmap) null); - for (Bitmap ignored : bitmaps) { - backgroundBounds.add(new RectF()); - inBounds.add(cropCircle ? null : new Rect()); - if (cropCircle) { - Paint p = new Paint(); - p.setStrokeWidth(borderSize); - p.setColor(Color.WHITE); - p.setStyle(Paint.Style.FILL); - clipPaint.add(p); - workspace.add(null); - } - } - } - } else { - workspace = Arrays.asList((Bitmap) null); - bitmaps = null; - backgroundBounds = null; - inBounds = null; - avatarText = convertNameToAvatarText(name); - color = ContextCompat.getColor(context, getAvatarColor(id)); - clipPaint = cropCircle ? Collections.singletonList(new Paint()) : null; - if (avatarText == null) { - placeholder = (VectorDrawable) context.getDrawable(isGroup ? PLACEHOLDER_ICON_GROUP : PLACEHOLDER_ICON); - } else { - textPaint.setColor(Color.WHITE); - textPaint.setTypeface(Typeface.SANS_SERIF); - } - } - presenceFillPaint = new Paint(); - presenceFillPaint.setColor(ContextCompat.getColor(context, PRESENCE_COLOR)); - presenceFillPaint.setStyle(Paint.Style.FILL); - presenceFillPaint.setAntiAlias(true); - - presenceFillPaint.setColor(ContextCompat.getColor(context, PRESENCE_COLOR)); - presenceFillPaint.setStyle(Paint.Style.FILL); - presenceFillPaint.setAntiAlias(true); - - presenceStrokePaint = new Paint(); - presenceStrokePaint.setColor(ContextCompat.getColor(context, DeviceUtils.isTv(context) ? R.color.grey_900 : R.color.background)); - presenceStrokePaint.setStyle(Paint.Style.STROKE); - presenceStrokePaint.setAntiAlias(true); - - checkedIcon = (VectorDrawable) context.getDrawable(CHECKED_ICON); - checkedIcon.setTint(ContextCompat.getColor(context, R.color.colorPrimary)); - checkedPaint = new Paint(); - checkedPaint.setColor(ContextCompat.getColor(context, R.color.background)); - checkedPaint.setStyle(Paint.Style.FILL_AND_STROKE); - checkedPaint.setAntiAlias(true); - - if (clipPaint != null) - for (Paint p : clipPaint) - p.setAntiAlias(true); - textPaint.setAntiAlias(true); - textPaint.setColor(Color.WHITE); - textPaint.setTypeface(Typeface.SANS_SERIF); - } - - public AvatarDrawable(AvatarDrawable other) { - //Log.w("AvatarDrawable", "AvatarDrawable copy"); - cropCircle = other.cropCircle; - isGroup = other.isGroup; - minSize = other.minSize; - bitmaps = other.bitmaps; - backgroundBounds = other.backgroundBounds == null ? null : new ArrayList<>(other.backgroundBounds.size()); - if (backgroundBounds != null) { - for (int i = 0, n = other.backgroundBounds.size(); i < n; i++) { - backgroundBounds.add(new RectF()); - } - } - - inBounds = other.inBounds; - color = other.color; - placeholder = other.placeholder; - avatarText = other.avatarText; - workspace = new ArrayList<>(other.workspace.size()); - for (int i = 0, n = other.workspace.size(); i < n; i++) { - workspace.add(null); - } - clipPaint = other.clipPaint == null ? null : new ArrayList<>(other.clipPaint.size()); - if (clipPaint != null) { - for (int i = 0, n = other.clipPaint.size(); i < n; i++) { - clipPaint.add(new Paint(other.clipPaint.get(i))); - clipPaint.get(i).setShader(null); - } - } - - isOnline = other.isOnline; - isChecked = other.isChecked; - showPresence = other.showPresence; - presenceFillPaint = other.presenceFillPaint; - presenceStrokePaint = other.presenceStrokePaint; - checkedPaint = other.checkedPaint; - - textPaint.setAntiAlias(true); - textPaint.setColor(Color.WHITE); - textPaint.setTypeface(Typeface.SANS_SERIF); - } - - @Override - public void draw(@NonNull Canvas finalCanvas) { - if (workspace.get(0) == null) - return; - if (update) { - for (int i = 0, s = workspace.size(); i < s; i++) - drawActual(i, new Canvas(workspace.get(i))); - update = false; - } - if (cropCircle) { - finalCanvas.save(); - finalCanvas.translate((getBounds().width() - workspace.get(0).getWidth()) / 2.f, (getBounds().height() - workspace.get(0).getHeight()) / 2.f); - float r = Math.min(workspace.get(0).getWidth(), workspace.get(0).getHeight()) / 2; - int cx = workspace.get(0).getWidth()/2;//getBounds().centerX(); - float cy = workspace.get(0).getHeight()/2;//getBounds().height() / 2; - int i = 0; - final float ratio = 1.333333f; - for (Paint paint : clipPaint) { - finalCanvas.drawCircle(cx, workspace.get(0).getHeight() - cy, r, paint); - if (i != 0) { - Shader s = paint.getShader(); - paint.setShader(null); - paint.setStyle(Paint.Style.STROKE); - finalCanvas.drawCircle(cx, workspace.get(0).getHeight() - cy, r, paint); - paint.setShader(s); - paint.setStyle(Paint.Style.FILL); - } - i++; - r /= ratio; - cy /= ratio; - } - - finalCanvas.restore(); - } else { - finalCanvas.drawBitmap(workspace.get(0), null, getBounds(), drawPaint); - } - if (showPresence && isOnline) { - drawPresence(finalCanvas); - } - if (isChecked) { - drawChecked(finalCanvas); - } - } - - private void drawActual(int i, @NonNull Canvas canvas) { - if (bitmaps != null) { - if (cropCircle) { - canvas.drawBitmap(bitmaps.get(i), inBounds.get(i), backgroundBounds.get(i), drawPaint); - } else { - if (backgroundBounds.size() == bitmaps.size()) - for (int n = 0, s = bitmaps.size(); n < s; n++) { - canvas.drawBitmap(bitmaps.get(n), inBounds.get(n), backgroundBounds.get(n), drawPaint); - } - } - } else { - canvas.drawColor(color); - if (avatarText != null) { - canvas.drawText(avatarText, textStartXPoint, textStartYPoint, textPaint); - } else { - placeholder.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN); - placeholder.draw(canvas); - } - } - } - - private void setupPresenceIndicator(Rect bounds) { - presence.radius = (int) (0.29289321881 * (double) (bounds.width()) * 0.5); - presence.cx = bounds.right - presence.radius; - presence.cy = bounds.bottom - presence.radius; - int presenceStrokeWidth = presence.radius / 3; - presenceStrokePaint.setStrokeWidth(presenceStrokeWidth); - checkedPaint.setStrokeWidth(presenceStrokeWidth); - presence.radius -= presenceStrokeWidth * 0.5; - - if (checkedIcon != null) - checkedIcon.setBounds(presence.cx - presence.radius, presence.cy - presence.radius, presence.cx + presence.radius, presence.cy + presence.radius); - } - - private void drawPresence(@NonNull Canvas canvas) { - canvas.drawCircle(presence.cx, presence.cy, presence.radius - 1, presenceFillPaint); - canvas.drawCircle(presence.cx, presence.cy, presence.radius, presenceStrokePaint); - } - - private void drawChecked(@NonNull Canvas canvas) { - if (checkedIcon != null) { - canvas.drawCircle(presence.cx, presence.cy, presence.radius, checkedPaint); - checkedIcon.draw(canvas); - } - } - - private static Rect getSubBounds(@NonNull Rect bounds, int total, int i) { - if (total == 1) - return bounds; - - if (total == 2 || (total == 3 && i == 0)) { - //Rect zone = getSubZone(bounds, 2, 1); - int w = bounds.width() / 2; - return (i == 0) - ? new Rect(bounds.left, bounds.top, bounds.left + w, bounds.bottom) - : new Rect(bounds.left + w, bounds.top, bounds.right, bounds.bottom); - } - if (total == 3 || (total == 4 && (i == 1 || i == 2))) { - int w = bounds.width() / 2; - int h = bounds.height() / 2; - return (i == 1) - ? new Rect(bounds.left + w, bounds.top, bounds.right, bounds.top + h) - : new Rect(bounds.left + w, bounds.top + h, bounds.right, bounds.bottom); - } - if (total == 4) { - int w = bounds.width() / 2; - int h = bounds.height() / 2; - return (i == 0) - ? new Rect(bounds.left, bounds.top, bounds.left + w, bounds.top + h) - : new Rect(bounds.left, bounds.top + h, bounds.left + w, bounds.bottom); - } - return null; - } - - private static <T> void fit(int iw, int ih, int bw, int bh, boolean outfit, T ret) { - int a = bw * ih; - int b = bh * iw; - int w; - int h; - if (outfit == (a < b)) { - w = iw; - h = (iw * bh) / bw; - } else { - w = (ih * bw) / bh; - h = ih; - } - int x = (iw - w) / 2; - int y = (ih - h) / 2; - if (ret instanceof Rect) - ((Rect) ret).set(x, y, x + w, y + h); - else if (ret instanceof RectF) - ((RectF) ret).set(x, y, x + w, y + h); - } - - @Override - protected void onBoundsChange(Rect bounds) { - //if (showPresence) - setupPresenceIndicator(bounds); - int d = Math.min(bounds.width(), bounds.height()); - if (placeholder != null) { - int cx = (bounds.width() - d) / 2; - int cy = (bounds.height() - d) / 2; - placeholder.setBounds(cx, cy, cx + d, cy + d); - } - int iw = cropCircle ? d : bounds.width(); - int ih = cropCircle ? d : bounds.height(); - for (int i = 0, n = workspace.size(); i < n; i++) { - if (workspace.get(i) != null) { - workspace.get(i).recycle(); - workspace.set(i, null); - clipPaint.get(i).setShader(null); - } - } - if (iw <= 0 || ih <= 0) { - return; - } - if (cropCircle) { - for (int i = 0, s = workspace.size(); i < s; i++) { - Bitmap workspacei = Bitmap.createBitmap(iw, ih, Bitmap.Config.ARGB_8888); - workspace.set(i, workspacei); - clipPaint.get(i).setShader(new BitmapShader(workspacei, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP)); - } - } else { - workspace.set(0, Bitmap.createBitmap(bounds.width(), bounds.height(), Bitmap.Config.ARGB_8888)); - } - - if (bitmaps != null) { - if (bitmaps.size() == 1 || cropCircle) { - for (int i = 0; i < bitmaps.size(); i++) { - Bitmap bitmap = bitmaps.get(i); - fit(iw, ih, bitmap.getWidth(), bitmap.getHeight(), true, backgroundBounds.get(i)); - } - } else { - Rect realBounds = cropCircle ? new Rect(0, 0, iw, ih) : bounds; - for (int i = 0; i < bitmaps.size(); i++) { - Bitmap bitmap = bitmaps.get(i); - Rect subBounds = getSubBounds(realBounds, bitmaps.size(), i); - if (subBounds != null) { - fit(bitmap.getWidth(), bitmap.getHeight(), subBounds.width(), subBounds.height(), false, inBounds.get(i)); - backgroundBounds.get(i).set(subBounds); - } - } - } - } else { - setAvatarTextValues(bounds); - } - update = true; - } - - @Override - public void setAlpha(int alpha) { - if (placeholder != null) { - placeholder.setAlpha(alpha); - } else { - textPaint.setAlpha(alpha); - } - } - - @Override - public void setColorFilter(ColorFilter colorFilter) { - if (placeholder != null) { - placeholder.setColorFilter(colorFilter); - } else { - textPaint.setColorFilter(colorFilter); - } - } - - @Override - public int getMinimumWidth() { - return minSize; - } - - @Override - public int getMinimumHeight() { - return minSize; - } - - public void setInSize(int s) { - inSize = s; - } - - @Override - public int getIntrinsicWidth() { - return inSize; - } - - @Override - public int getIntrinsicHeight() { - return inSize; - } - - @Override - public int getOpacity() { - return PixelFormat.TRANSLUCENT; - } - - private void setAvatarTextValues(Rect bounds) { - if (avatarText != null) { - textPaint.setTextSize(bounds.height() * DEFAULT_TEXT_SIZE_PERCENTAGE); - float stringWidth = textPaint.measureText(avatarText); - textStartXPoint = (bounds.width() / 2f) - (stringWidth / 2f); - textStartYPoint = (bounds.height() / 2f) - ((textPaint.ascent() + textPaint.descent()) / 2f); - } - } - - private String convertNameToAvatarText(String name) { - if (TextUtils.isEmpty(name)) { - return null; - } else { - return new String(Character.toChars(name.codePointAt(0))).toUpperCase(); - } - } - - private static int getAvatarColor(String id) { - if (id == null) { - return R.color.grey_500; - } - - String md5 = HashUtils.md5(id); - if (md5 == null) { - return R.color.grey_500; - } - int colorIndex = Integer.parseInt(md5.charAt(0) + "", 16); - return contactColors[colorIndex % contactColors.length]; - } -} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/views/AvatarDrawable.kt b/ring-android/app/src/main/java/cx/ring/views/AvatarDrawable.kt new file mode 100644 index 000000000..2e143fc69 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/views/AvatarDrawable.kt @@ -0,0 +1,695 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.views + +import android.content.Context +import android.graphics.* +import android.graphics.drawable.Drawable +import android.graphics.drawable.VectorDrawable +import android.text.TextUtils +import android.util.TypedValue +import androidx.core.content.ContextCompat +import cx.ring.R +import cx.ring.services.VCardServiceImpl +import cx.ring.utils.DeviceUtils.isTv +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import net.jami.model.Account +import net.jami.model.Contact +import net.jami.model.Conversation +import net.jami.smartlist.SmartListViewModel +import net.jami.utils.HashUtils +import net.jami.utils.Tuple +import java.util.* + +class AvatarDrawable : Drawable { + private class PresenceIndicatorInfo { + var cx = 0 + var cy = 0 + var radius = 0 + } + + private val presence = PresenceIndicatorInfo() + private var update = true + private val isGroup: Boolean + private var inSize = -1 + private val minSize: Int + private val workspace: MutableList<Bitmap?> + private val bitmaps: MutableList<Bitmap>? + private var placeholder: VectorDrawable? = null + private var checkedIcon: VectorDrawable? = null + private val backgroundBounds: List<RectF>? + private val inBounds: List<Rect?>? + private var avatarText: String? = null + private var textStartXPoint = 0f + private var textStartYPoint = 0f + private var color = 0 + private val clipPaint: MutableList<Paint>? + private val textPaint = Paint() + private val presenceFillPaint: Paint + private val presenceStrokePaint: Paint + private val checkedPaint: Paint + + companion object { + private val TAG = AvatarDrawable::class.simpleName!! + private const val SIZE_AB = 36 + private const val SIZE_BORDER = 2 + private const val DEFAULT_TEXT_SIZE_PERCENTAGE = 0.5f + private const val PLACEHOLDER_ICON = R.drawable.baseline_account_crop_24 + private const val PLACEHOLDER_ICON_GROUP = R.drawable.baseline_group_24 + private const val CHECKED_ICON = R.drawable.baseline_check_circle_24 + private const val PRESENCE_COLOR = R.color.green_A700 + private val contactColors = intArrayOf( + R.color.red_500, R.color.pink_500, + R.color.purple_500, R.color.deep_purple_500, + R.color.indigo_500, R.color.blue_500, + R.color.cyan_500, R.color.teal_500, + R.color.green_500, R.color.light_green_500, + R.color.grey_500, R.color.lime_500, + R.color.amber_500, R.color.deep_orange_500, + R.color.brown_500, R.color.blue_grey_500 + ) + private val drawPaint = Paint() + + fun load(context: Context, account: Account, crop: Boolean = true): Observable<AvatarDrawable> { + return VCardServiceImpl.loadProfile(context, account) + .map { profile -> build(context, account, profile, crop) } + } + fun build(context: Context, account: Account, profile: Tuple<String?, Any?>, crop: Boolean): AvatarDrawable { + return Builder() + .withPhoto(profile.second as Bitmap?) + .withNameData(profile.first, account.registeredName) + .withId(account.uri) + .withCircleCrop(crop) + .build(context) + } + + private fun getSubBounds(bounds: Rect, total: Int, i: Int): Rect? { + if (total == 1) return bounds + if (total == 2 || total == 3 && i == 0) { + //Rect zone = getSubZone(bounds, 2, 1); + val w = bounds.width() / 2 + return if (i == 0) + Rect(bounds.left, bounds.top, bounds.left + w, bounds.bottom) + else + Rect(bounds.left + w, bounds.top, bounds.right, bounds.bottom) + } + if (total == 3 || total == 4 && (i == 1 || i == 2)) { + val w = bounds.width() / 2 + val h = bounds.height() / 2 + return if (i == 1) + Rect(bounds.left + w, bounds.top, bounds.right, bounds.top + h) + else + Rect(bounds.left + w, bounds.top + h, bounds.right, bounds.bottom) + } + if (total == 4) { + val w = bounds.width() / 2 + val h = bounds.height() / 2 + return if (i == 0) + Rect(bounds.left, bounds.top, bounds.left + w, bounds.top + h) + else + Rect(bounds.left, bounds.top + h, bounds.left + w, bounds.bottom) + } + return null + } + + private fun <T> fit(iw: Int, ih: Int, bw: Int, bh: Int, outfit: Boolean, ret: T) { + val a = bw * ih + val b = bh * iw + val w: Int + val h: Int + if (outfit == a < b) { + w = iw + h = iw * bh / bw + } else { + w = ih * bw / bh + h = ih + } + val x = (iw - w) / 2 + val y = (ih - h) / 2 + if (ret is Rect) ret[x, y, x + w] = + y + h else if (ret is RectF) ret[x.toFloat(), y.toFloat(), (x + w).toFloat()] = + (y + h).toFloat() + } + + private fun getAvatarColor(id: String?): Int { + if (id == null) { + return R.color.grey_500 + } + val md5 = HashUtils.md5(id) ?: return R.color.grey_500 + val colorIndex = (md5[0].toString() + "").toInt(16) + return contactColors[colorIndex % contactColors.size] + } + + init { + drawPaint.isAntiAlias = true + drawPaint.isFilterBitmap = true + } + } + + private val cropCircle: Boolean + private var isOnline = false + private var isChecked = false + private var showPresence = false + + class Builder { + private var photos: MutableList<Bitmap>? = null + private var name: String? = null + private var id: String? = null + private var circleCrop = false + private var isOnline = false + private var showPresence = true + private var isChecked = false + private var isGroup = false + fun withId(id: String?): Builder { + this.id = id + return this + } + + fun withPhoto(photo: Bitmap?): Builder { + photos = if (photo == null) null else mutableListOf(photo) // list elements must be mutable + return this + } + + fun withPhotos(photos: MutableList<Bitmap>): Builder { + this.photos = if (photos.isEmpty()) null else photos + return this + } + + fun withName(name: String?): Builder { + this.name = name + return this + } + + fun withCircleCrop(crop: Boolean): Builder { + circleCrop = crop + return this + } + + fun withOnlineState(isOnline: Boolean): Builder { + this.isOnline = isOnline + return this + } + + fun withPresence(showPresence: Boolean): Builder { + this.showPresence = showPresence + return this + } + + fun withCheck(checked: Boolean): Builder { + isChecked = checked + return this + } + + fun withNameData(profileName: String?, username: String?): Builder { + return withName(if (TextUtils.isEmpty(profileName)) username else profileName) + } + + fun withContact(contact: Contact?): Builder { + return if (contact == null) this else withPhoto(contact.photo as Bitmap?) + .withId(contact.primaryNumber) + .withOnlineState(contact.isOnline) + .withNameData(contact.profileName, contact.username) + } + + fun withContacts(contacts: List<Contact>): Builder { + val bitmaps: MutableList<Bitmap> = ArrayList(contacts.size) + var notTheUser = 0 + for (contact in contacts) { + if (contact.isUser) continue + notTheUser++ + val bitmap = contact.photo as Bitmap? + if (bitmap != null) { + bitmaps.add(bitmap) + } + if (bitmaps.size == 4) break + } + if (notTheUser == 1) { + for (contact in contacts) { + if (!contact.isUser) return withContact(contact) + } + } + if (bitmaps.isEmpty()) { + // Fallback to the user avatar + for (contact in contacts) return withContact(contact) + } else { + return withPhotos(bitmaps) + } + return this + } + + fun withConversation(conversation: Conversation): Builder { + return if (conversation.isSwarm) withContacts(conversation.contacts).setGroup() + else withContact(conversation.contact) + } + + private fun setGroup(): Builder { + isGroup = true + return this + } + + fun withViewModel(vm: SmartListViewModel): Builder { + val isSwarm = vm.uri.isSwarm + return (if (isSwarm) withContacts(vm.contacts).setGroup() else withContact(if (vm.contacts.isEmpty()) null else vm.contacts[vm.contacts.size - 1])) + .withPresence(vm.showPresence()) + .withOnlineState(vm.isOnline) + .withCheck(vm.isChecked) + } + + fun build(context: Context): AvatarDrawable { + val avatarDrawable = AvatarDrawable( + context, photos, name, id, circleCrop, isGroup + ) + avatarDrawable.setOnline(isOnline) + avatarDrawable.setChecked(isChecked) + avatarDrawable.showPresence = showPresence + return avatarDrawable + } + + fun buildAsync(context: Context): Single<AvatarDrawable> { + return Single.fromCallable { build(context) } + } + } + + fun update(contact: Contact) { + val profileName = contact.profileName + val username = contact.username + avatarText = convertNameToAvatarText( + if (TextUtils.isEmpty(profileName)) username else profileName + ) + bitmaps?.set(0, contact.photo as Bitmap) + isOnline = contact.isOnline + update = true + } + + fun setName(name: String?) { + avatarText = convertNameToAvatarText(name) + update = true + } + + fun setPhoto(photo: Bitmap) { + bitmaps!![0] = photo + update = true + } + + fun setOnline(online: Boolean) { + isOnline = online + } + + fun setChecked(checked: Boolean) { + isChecked = checked + } + + private constructor( + context: Context, + photos: MutableList<Bitmap>?, + name: String?, + id: String?, + crop: Boolean, + group: Boolean + ) { + //Log.w("AvatarDrawable", "AvatarDrawable " + (photos == null ? null : photos.size()) + " " + name); + cropCircle = crop + isGroup = group + minSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, SIZE_AB.toFloat(), context.resources.displayMetrics).toInt() + val borderSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, SIZE_BORDER.toFloat(), context.resources.displayMetrics) + if (cropCircle) { + inSize = minSize + } + if (photos != null && photos.size > 0) { + avatarText = null + bitmaps = photos + if (photos.size == 1) { + backgroundBounds = listOf(RectF()) + inBounds = listOf<Rect?>(null) + clipPaint = if (cropCircle) mutableListOf(Paint()) else null + workspace = mutableListOf(null as Bitmap?) + } else { + backgroundBounds = ArrayList(bitmaps.size) + inBounds = ArrayList(bitmaps.size) + clipPaint = if (cropCircle) ArrayList(bitmaps.size) else null + workspace = + if (cropCircle) ArrayList(bitmaps.size) else Arrays.asList(null as Bitmap?) + for (ignored in bitmaps) { + backgroundBounds.add(RectF()) + inBounds.add(if (cropCircle) null else Rect()) + if (cropCircle) { + val p = Paint() + p.strokeWidth = borderSize + p.color = Color.WHITE + p.style = Paint.Style.FILL + clipPaint!!.add(p) + workspace.add(null) + } + } + } + } else { + workspace = mutableListOf(null as Bitmap?) + bitmaps = null + backgroundBounds = null + inBounds = null + avatarText = convertNameToAvatarText(name) + color = ContextCompat.getColor(context, getAvatarColor(id)) + clipPaint = if (cropCircle) mutableListOf(Paint()) else null + if (avatarText == null) { + placeholder = + context.getDrawable(if (isGroup) PLACEHOLDER_ICON_GROUP else PLACEHOLDER_ICON) as VectorDrawable? + } else { + textPaint.color = Color.WHITE + textPaint.typeface = Typeface.SANS_SERIF + } + } + presenceFillPaint = Paint() + presenceFillPaint.color = ContextCompat.getColor(context, PRESENCE_COLOR) + presenceFillPaint.style = Paint.Style.FILL + presenceFillPaint.isAntiAlias = true + presenceFillPaint.color = ContextCompat.getColor(context, PRESENCE_COLOR) + presenceFillPaint.style = Paint.Style.FILL + presenceFillPaint.isAntiAlias = true + presenceStrokePaint = Paint() + presenceStrokePaint.color = ContextCompat.getColor( + context, + if (isTv(context)) R.color.grey_900 else R.color.background + ) + presenceStrokePaint.style = Paint.Style.STROKE + presenceStrokePaint.isAntiAlias = true + checkedIcon = context.getDrawable(CHECKED_ICON) as VectorDrawable? + checkedIcon!!.setTint(ContextCompat.getColor(context, R.color.colorPrimary)) + checkedPaint = Paint() + checkedPaint.color = ContextCompat.getColor(context, R.color.background) + checkedPaint.style = Paint.Style.FILL_AND_STROKE + checkedPaint.isAntiAlias = true + if (clipPaint != null) for (p in clipPaint) p.isAntiAlias = true + textPaint.isAntiAlias = true + textPaint.color = Color.WHITE + textPaint.typeface = Typeface.SANS_SERIF + } + + constructor(other: AvatarDrawable) { + //Log.w("AvatarDrawable", "AvatarDrawable copy"); + cropCircle = other.cropCircle + isGroup = other.isGroup + minSize = other.minSize + bitmaps = other.bitmaps + if (other.backgroundBounds != null) { + backgroundBounds = ArrayList(other.backgroundBounds.size) + var i = 0 + val n = other.backgroundBounds.size + while (i < n) { + backgroundBounds.add(RectF()) + i++ + } + } else { + backgroundBounds = null + } + inBounds = other.inBounds + color = other.color + placeholder = other.placeholder + avatarText = other.avatarText + workspace = ArrayList(other.workspace.size) + var i = 0 + val n = other.workspace.size + while (i < n) { + workspace.add(null) + i++ + } + clipPaint = if (other.clipPaint == null) null else ArrayList(other.clipPaint.size) + if (clipPaint != null) { + i = 0 + val n = other.clipPaint!!.size + while (i < n) { + clipPaint.add(Paint(other.clipPaint[i])) + clipPaint[i].shader = null + i++ + } + } + isOnline = other.isOnline + isChecked = other.isChecked + showPresence = other.showPresence + presenceFillPaint = other.presenceFillPaint + presenceStrokePaint = other.presenceStrokePaint + checkedPaint = other.checkedPaint + textPaint.isAntiAlias = true + textPaint.color = Color.WHITE + textPaint.typeface = Typeface.SANS_SERIF + } + + override fun draw(finalCanvas: Canvas) { + if (workspace[0] == null) return + if (update) { + var i = 0 + val s = workspace.size + while (i < s) { + drawActual(i, Canvas(workspace[i]!!)) + i++ + } + update = false + } + if (cropCircle) { + finalCanvas.save() + finalCanvas.translate( + (bounds.width() - workspace[0]!! + .width) / 2f, (bounds.height() - workspace[0]!!.height) / 2f + ) + var r = (Math.min( + workspace[0]!!.width, workspace[0]!!.height + ) / 2).toFloat() + val cx = workspace[0]!!.width / 2 //getBounds().centerX(); + var cy = (workspace[0]!!.height / 2).toFloat() //getBounds().height() / 2; + var i = 0 + val ratio = 1.333333f + for (paint in clipPaint!!) { + finalCanvas.drawCircle(cx.toFloat(), workspace[0]!!.height - cy, r, paint) + if (i != 0) { + val s = paint.shader + paint.shader = null + paint.style = Paint.Style.STROKE + finalCanvas.drawCircle(cx.toFloat(), workspace[0]!!.height - cy, r, paint) + paint.shader = s + paint.style = Paint.Style.FILL + } + i++ + r /= ratio + cy /= ratio + } + finalCanvas.restore() + } else { + finalCanvas.drawBitmap(workspace[0]!!, null, bounds, drawPaint) + } + if (showPresence && isOnline) { + drawPresence(finalCanvas) + } + if (isChecked) { + drawChecked(finalCanvas) + } + } + + private fun drawActual(i: Int, canvas: Canvas) { + if (bitmaps != null) { + if (cropCircle) { + canvas.drawBitmap(bitmaps[i], inBounds!![i], backgroundBounds!![i], drawPaint) + } else { + if (backgroundBounds!!.size == bitmaps.size) { + var n = 0 + val s = bitmaps.size + while (n < s) { + canvas.drawBitmap(bitmaps[n], inBounds!![n], backgroundBounds[n], drawPaint) + n++ + } + } + } + } else { + canvas.drawColor(color) + if (avatarText != null) { + canvas.drawText(avatarText!!, textStartXPoint, textStartYPoint, textPaint) + } else { + placeholder!!.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN) + placeholder!!.draw(canvas) + } + } + } + + private fun setupPresenceIndicator(bounds: Rect) { + presence.radius = (0.29289321881 * bounds.width().toDouble() * 0.5).toInt() + presence.cx = bounds.right - presence.radius + presence.cy = bounds.bottom - presence.radius + val presenceStrokeWidth = presence.radius / 3 + presenceStrokePaint.strokeWidth = presenceStrokeWidth.toFloat() + checkedPaint.strokeWidth = presenceStrokeWidth.toFloat() + presence.radius -= (presenceStrokeWidth * 0.5).toInt() + if (checkedIcon != null) checkedIcon!!.setBounds( + presence.cx - presence.radius, + presence.cy - presence.radius, + presence.cx + presence.radius, + presence.cy + presence.radius + ) + } + + private fun drawPresence(canvas: Canvas) { + canvas.drawCircle( + presence.cx.toFloat(), + presence.cy.toFloat(), + (presence.radius - 1).toFloat(), + presenceFillPaint + ) + canvas.drawCircle( + presence.cx.toFloat(), + presence.cy.toFloat(), + presence.radius.toFloat(), + presenceStrokePaint + ) + } + + private fun drawChecked(canvas: Canvas) { + if (checkedIcon != null) { + canvas.drawCircle( + presence.cx.toFloat(), + presence.cy.toFloat(), + presence.radius.toFloat(), + checkedPaint + ) + checkedIcon!!.draw(canvas) + } + } + + override fun onBoundsChange(bounds: Rect) { + //if (showPresence) + setupPresenceIndicator(bounds) + val d = Math.min(bounds.width(), bounds.height()) + if (placeholder != null) { + val cx = (bounds.width() - d) / 2 + val cy = (bounds.height() - d) / 2 + placeholder!!.setBounds(cx, cy, cx + d, cy + d) + } + val iw = if (cropCircle) d else bounds.width() + val ih = if (cropCircle) d else bounds.height() + var i = 0 + val n = workspace.size + while (i < n) { + if (workspace[i] != null) { + workspace[i]!!.recycle() + workspace[i] = null + clipPaint!![i].shader = null + } + i++ + } + if (iw <= 0 || ih <= 0) { + return + } + if (cropCircle) { + i = 0 + val s = workspace.size + while (i < s) { + val workspacei = Bitmap.createBitmap(iw, ih, Bitmap.Config.ARGB_8888) + workspace[i] = workspacei + clipPaint!![i].shader = BitmapShader(workspacei, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + i++ + } + } else { + workspace[0] = + Bitmap.createBitmap(bounds.width(), bounds.height(), Bitmap.Config.ARGB_8888) + } + if (bitmaps != null) { + if (bitmaps.size == 1 || cropCircle) { + for (i in bitmaps.indices) { + val bitmap = bitmaps[i] + fit(iw, ih, bitmap.width, bitmap.height, true, backgroundBounds!![i]) + } + } else { + val realBounds = if (cropCircle) Rect(0, 0, iw, ih) else bounds + for (i in bitmaps.indices) { + val bitmap = bitmaps[i] + val subBounds = getSubBounds(realBounds, bitmaps.size, i) + if (subBounds != null) { + fit( + bitmap.width, + bitmap.height, + subBounds.width(), + subBounds.height(), + false, + inBounds!![i] + ) + backgroundBounds!![i].set(subBounds) + } + } + } + } else { + setAvatarTextValues(bounds) + } + update = true + } + + override fun setAlpha(alpha: Int) { + if (placeholder != null) { + placeholder!!.alpha = alpha + } else { + textPaint.alpha = alpha + } + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + if (placeholder != null) { + placeholder!!.colorFilter = colorFilter + } else { + textPaint.colorFilter = colorFilter + } + } + + override fun getMinimumWidth(): Int { + return minSize + } + + override fun getMinimumHeight(): Int { + return minSize + } + + fun setInSize(s: Int) { + inSize = s + } + + override fun getIntrinsicWidth(): Int { + return inSize + } + + override fun getIntrinsicHeight(): Int { + return inSize + } + + override fun getOpacity(): Int { + return PixelFormat.TRANSLUCENT + } + + private fun setAvatarTextValues(bounds: Rect) { + if (avatarText != null) { + textPaint.textSize = bounds.height() * DEFAULT_TEXT_SIZE_PERCENTAGE + val stringWidth = textPaint.measureText(avatarText) + textStartXPoint = bounds.width() / 2f - stringWidth / 2f + textStartYPoint = bounds.height() / 2f - (textPaint.ascent() + textPaint.descent()) / 2f + } + } + + private fun convertNameToAvatarText(name: String?): String? { + return if (name == null || name.isEmpty()) { + null + } else { + String(Character.toChars(name.codePointAt(0))).uppercase(Locale.getDefault()) + } + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/java/cx/ring/views/AvatarFactory.java b/ring-android/app/src/main/java/cx/ring/views/AvatarFactory.java deleted file mode 100644 index 59a0e59dc..000000000 --- a/ring-android/app/src/main/java/cx/ring/views/AvatarFactory.java +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Pierre Duchemin <pierre.duchemin@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ - -package cx.ring.views; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.drawable.Drawable; - -import android.text.TextUtils; -import android.widget.ImageView; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.RequestBuilder; -import com.bumptech.glide.RequestManager; - -import net.jami.model.Account; -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.smartlist.SmartListViewModel; -import cx.ring.utils.BitmapUtils; -import cx.ring.views.AvatarDrawable; -import io.reactivex.rxjava3.core.Single; - -public class AvatarFactory { - - public static final int SIZE_AB = 36; - public static final int SIZE_NOTIF = 48; - public static final int SIZE_PADDING = 8; - - private AvatarFactory() {} - - public static void loadGlideAvatar(ImageView view, Contact contact) { - getGlideAvatar(view.getContext(), contact).into(view); - } - - public static Single<Drawable> getAvatar(Context context, Contact contact, boolean presence) { - return Single.fromCallable(() -> - new AvatarDrawable.Builder() - .withContact(contact) - .withCircleCrop(true) - .withPresence(presence) - .build(context)); - } - public static Single<Drawable> getAvatar(Context context, Conversation conversation, boolean presence) { - return Single.fromCallable(() -> - new AvatarDrawable.Builder() - .withConversation(conversation) - .withCircleCrop(true) - .withPresence(presence) - .build(context)); - } - public static Single<Drawable> getAvatar(Context context, SmartListViewModel vm) { - return Single.fromCallable(() -> - new AvatarDrawable.Builder() - .withViewModel(vm) - .withCircleCrop(true) - .build(context)); - } - public static Single<Drawable> getAvatar(Context context, Contact contact) { - return getAvatar(context, contact, true); - } - public static Single<Bitmap> getBitmapAvatar(Context context, Conversation conversation, int size, boolean presence) { - return getAvatar(context, conversation, presence) - .map(d -> BitmapUtils.drawableToBitmap(d, size)); - } - public static Single<Bitmap> getBitmapAvatar(Context context, Contact contact, int size, boolean presence) { - return getAvatar(context, contact, presence) - .map(d -> BitmapUtils.drawableToBitmap(d, size)); - } - public static Single<Bitmap> getBitmapAvatar(Context context, Contact contact, int size) { - return getBitmapAvatar(context, contact, size, true); - } - - public static Single<Bitmap> getBitmapAvatar(Context context, Account account, int size) { - return AvatarDrawable.load(context, account) - .map(d -> BitmapUtils.drawableToBitmap(d, size)); - } - - private static Drawable getDrawable(Context context, Bitmap photo, String profileName, String username, String id) { - return new AvatarDrawable.Builder() - .withPhoto(photo) - .withName(TextUtils.isEmpty(profileName) ? username : profileName) - .withId(id) - .withCircleCrop(true) - .build(context); - } - - public static void clearCache() { - } - - private static <T> RequestBuilder<T> getGlideRequest(Context context, RequestBuilder<T> request, Bitmap photo, String profileName, String username, String id) { - return request.load(getDrawable(context, photo, profileName, username, id)); - } - - private static RequestBuilder<Drawable> getGlideAvatar(Context context, RequestManager manager, Contact contact) { - return getGlideRequest(context, manager.asDrawable(), (Bitmap)contact.getPhoto(), contact.getProfileName(), contact.getUsername(), contact.getPrimaryNumber()); - } - - private static RequestBuilder<Drawable> getGlideAvatar(Context context, Contact contact) { - return getGlideAvatar(context, Glide.with(context), contact); - } -} diff --git a/ring-android/app/src/main/java/cx/ring/views/AvatarFactory.kt b/ring-android/app/src/main/java/cx/ring/views/AvatarFactory.kt new file mode 100644 index 000000000..c5c2156bc --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/views/AvatarFactory.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Pierre Duchemin <pierre.duchemin@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package cx.ring.views + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.text.TextUtils +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.RequestManager +import cx.ring.utils.BitmapUtils +import io.reactivex.rxjava3.core.Single +import net.jami.model.Account +import net.jami.model.Contact +import net.jami.model.Conversation +import net.jami.smartlist.SmartListViewModel + +object AvatarFactory { + const val SIZE_AB = 36 + const val SIZE_NOTIF = 48 + const val SIZE_PADDING = 8 + fun loadGlideAvatar(view: ImageView, contact: Contact) { + getGlideAvatar(view.context, contact).into(view) + } + + fun getAvatar(context: Context, contact: Contact?, presence: Boolean): Single<Drawable> { + return Single.fromCallable { + AvatarDrawable.Builder() + .withContact(contact) + .withCircleCrop(true) + .withPresence(presence) + .build(context) + } + } + + fun getAvatar(context: Context, conversation: Conversation, presence: Boolean): Single<Drawable> { + return Single.fromCallable { + AvatarDrawable.Builder() + .withConversation(conversation) + .withCircleCrop(true) + .withPresence(presence) + .build(context) + } + } + + fun getAvatar(context: Context, vm: SmartListViewModel): Single<Drawable> { + return Single.fromCallable { + AvatarDrawable.Builder() + .withViewModel(vm) + .withCircleCrop(true) + .build(context) + } + } + + fun getAvatar(context: Context, contact: Contact?): Single<Drawable> { + return getAvatar(context, contact, true) + } + + fun getBitmapAvatar(context: Context, conversation: Conversation, size: Int, presence: Boolean): Single<Bitmap> { + return getAvatar(context, conversation, presence) + .map { d -> BitmapUtils.drawableToBitmap(d, size) } + } + + fun getBitmapAvatar(context: Context, contact: Contact?, size: Int, presence: Boolean): Single<Bitmap> { + return getAvatar(context, contact, presence) + .map { d -> BitmapUtils.drawableToBitmap(d, size) } + } + + fun getBitmapAvatar(context: Context, contact: Contact?, size: Int): Single<Bitmap> { + return getBitmapAvatar(context, contact, size, true) + } + + fun getBitmapAvatar(context: Context, account: Account, size: Int): Single<Bitmap> { + return AvatarDrawable.load(context, account) + .firstOrError() + .map { d -> BitmapUtils.drawableToBitmap(d, size) } + } + + private fun getDrawable(context: Context, photo: Bitmap, profileName: String?, username: String?, id: String): Drawable { + return AvatarDrawable.Builder() + .withPhoto(photo) + .withName(if (TextUtils.isEmpty(profileName)) username else profileName) + .withId(id) + .withCircleCrop(true) + .build(context) + } + + fun clearCache() {} + + private fun <T> getGlideRequest(context: Context, request: RequestBuilder<T>, photo: Bitmap, profileName: String?, username: String?, id: String): RequestBuilder<T> { + return request.load(getDrawable(context, photo, profileName, username, id)) + } + + private fun getGlideAvatar(context: Context, manager: RequestManager, contact: Contact): RequestBuilder<Drawable> { + return getGlideRequest(context, manager.asDrawable(), contact.photo as Bitmap, contact.profileName, contact.username, contact.primaryNumber) + } + + private fun getGlideAvatar(context: Context, contact: Contact): RequestBuilder<Drawable> { + return getGlideAvatar(context, Glide.with(context), contact) + } +} \ No newline at end of file diff --git a/ring-android/app/src/main/res/drawable/baseline_androidtv_account.xml b/ring-android/app/src/main/res/drawable/baseline_androidtv_account.xml new file mode 100644 index 000000000..2b1c62186 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_androidtv_account.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M22,16.7V7.3c0,0 0,0 0,0c0,-0.7 -0.5,-1.3 -1.1,-1.5c-0.2,0 -0.4,-0.1 -0.6,-0.1h-6.2v0.5c0,0.1 0,0.3 0,0.4c0,0.2 0,0.4 -0.1,0.6C14,7.6 13.9,7.8 13.7,8c-0.2,0.4 -0.6,0.6 -1,0.8c-0.6,0.2 -1.4,0.1 -1.9,-0.2c-0.2,-0.2 -0.4,-0.4 -0.5,-0.6C10.1,7.6 10,7.2 10,6.8c0,0 0,-0.1 0,-0.1c0,-0.2 0,-0.3 0,-0.5c0,-0.1 0,-0.2 0,-0.3c0,-0.1 0,-0.1 0,-0.2H5.4c-0.6,0 -1.3,0 -1.9,0C2.7,5.8 2,6.5 2,7.4c0,0.2 0,0.3 0,0.5V17c0,0.4 0,0.9 0,1.3c0,0.9 0.7,1.5 1.6,1.6c0.1,0 0.2,0 0.3,0h16.3c0.1,0 0.2,0 0.4,0c0.8,-0.1 1.4,-0.8 1.4,-1.6C22,17.8 22,17.2 22,16.7zM8.9,16.5c-1.2,0.2 -2.5,-0.3 -3.1,-1.4c-0.3,-0.5 -0.5,-1.1 -0.4,-1.7c0.1,-0.6 0.3,-1.2 0.7,-1.7c0.7,-0.9 2.1,-1.3 3.2,-0.9c1.1,0.4 1.9,1.5 1.9,2.7v0c0,0 0,0 0,0C11.2,15 10.3,16.3 8.9,16.5zM16.9,16.3h-3.7c-0.4,0 -0.7,-0.3 -0.7,-0.7c0,-0.4 0.3,-0.7 0.7,-0.7h3.7c0.4,0 0.7,0.3 0.7,0.7C17.7,16 17.3,16.3 16.9,16.3zM19.1,12.4h-5.9c-0.4,0 -0.7,-0.3 -0.7,-0.7s0.3,-0.7 0.7,-0.7h5.9c0.4,0 0.7,0.3 0.7,0.7C19.9,12.1 19.5,12.4 19.1,12.4z"/> + <path + android:fillColor="#FF000000" + android:pathData="M13.1,5.8c0,0.3 0,0.6 0,1c0,0 0,0.1 0,0.1c0,0 0,0.1 0,0.1c0,0 0,0 0,0s0,0.1 0,0.2c0,0 0,0.1 0,0.1v0l0,0c0,0.1 -0.1,0.1 -0.1,0.2c0,0 0,0 0,0.1c0,0 0,0 0,0c0,0 0,0 0,0c0,0 0,0 0,0c0,0 -0.1,0.1 -0.1,0.1c0,0 0,0 0,0c0,0 0,0 0,0c0,0 0,0 0,0c0,0 -0.1,0.1 -0.1,0.1c0,0 0,0 -0.1,0c0,0 0,0 0,0c-0.1,0 -0.1,0 -0.2,0c0,0 -0.1,0 -0.1,0c-0.1,0 -0.1,0 -0.2,0c0,0 -0.1,0 -0.1,0l0,0c-0.1,0 -0.1,0 -0.2,0c0,0 -0.1,0 -0.1,0h0c0,0 -0.1,0 -0.1,-0.1c0,0 -0.1,0 -0.1,-0.1c0,0 0,0 0,0l0,0c0,0 -0.1,-0.1 -0.1,-0.1c0,0 0,0 0,0c0,0 0,0 -0.1,-0.1l0,0l0,0c0,0 -0.1,-0.1 -0.1,-0.1c0,0 0,-0.1 0,-0.1c0,0 0,0 0,0c0,0 0,0 0,0c0,0 0,-0.1 0,-0.1c0,0 0,-0.1 0,-0.1c0,0 0,0 0,0c0,0 0,0 0,0c0,0 0,-0.1 0,-0.1c0,0 0,0 0,-0.1V5.4c0,-0.1 0,-0.2 0,-0.2c0,0 0,-0.1 0,-0.1c0,0 0,0 0,0.1c0,0 0,0 0,-0.1c0,-0.1 0,-0.1 0,-0.2c0,0 0,-0.1 0,-0.1c0,0 0,0 0,0c0,-0.1 0.1,-0.1 0.1,-0.2c0,0 0,0 0,0c0,0 0,0 0,0c0.1,-0.1 0.1,-0.1 0.2,-0.2c0,0 0,0 0,0c0,0 0.1,0 0.1,0c0,0 0.2,-0.1 0.2,-0.1c0,0 -0.1,0 -0.1,0c0,0 0,0 0,0c0,0 0.1,0 0.1,0c0,0 0.1,0 0.1,0c0,0 0,0 0.1,0c0,0 0,0 0,0c0,0 0,0 -0.1,0c0,0 0.2,0 0.2,0c0,0 0,0 0.1,0l0,0l0,0c0,0 0,0 0.1,0c0,0 0,0 0,0c0,0 0,0 0,0c0,0 0,0 0.1,0c0.1,0 0.2,0 0.2,0.1l0,0c0,0 0,0 0,0c0,0 0.1,0 0.1,0.1c0,0 0.1,0 0.1,0c0,0 0,0 0,0l0,0c0,0 0,0 0,0c0.1,0.1 0.1,0.1 0.2,0.2l0,0l0,0c0,0 0.1,0.1 0.1,0.1c0,0 0,0.1 0.1,0.1c0,0 0,0 0,0l0,0c0,0 0,0 0,0c0,0.1 0,0.2 0.1,0.2c0,0 0,0 0,0c0,0 0,0 0,0c0,0.1 0,0.1 0,0.2C13.1,5.4 13.1,5.6 13.1,5.8z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_androidtv_add_user.xml b/ring-android/app/src/main/res/drawable/baseline_androidtv_add_user.xml new file mode 100644 index 000000000..ca51ed764 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_androidtv_add_user.xml @@ -0,0 +1,15 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M17.1,12.1c-2.8,0 -4.9,2.2 -4.9,4.9s2.2,4.9 4.9,4.9S22,19.8 22,17S19.8,12.1 17.1,12.1zM18.3,17.6h-0.6v0.6c0,0.4 -0.3,0.6 -0.6,0.6c-0.3,0 -0.6,-0.3 -0.6,-0.6v-0.6h-0.6c-0.3,0 -0.6,-0.3 -0.6,-0.6c0,-0.3 0.3,-0.6 0.6,-0.6h0.6v-0.6c0,-0.3 0.3,-0.6 0.6,-0.6c0.3,0 0.6,0.3 0.6,0.6v0.6h0.6c0.3,0 0.6,0.3 0.6,0.6C19,17.4 18.6,17.6 18.3,17.6z"/> + <path + android:fillColor="#FF000000" + android:pathData="M6.6,6.2a3.6,4.2 0,1 0,7.2 0a3.6,4.2 0,1 0,-7.2 0z"/> + <path + android:fillColor="#FF000000" + android:pathData="M11.4,17c0,-2 1.1,-3.8 2.7,-4.8c-1.2,-0.7 -2.5,-1.1 -3.9,-1.1C5.7,11.1 2,15 2,19.7c0,2.4 3.7,2.2 8.2,2.2c1.4,0 2.8,0 4,-0.1C12.5,20.9 11.4,19.1 11.4,17z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_androidtv_chat.xml b/ring-android/app/src/main/res/drawable/baseline_androidtv_chat.xml new file mode 100644 index 000000000..9a2c3ecf8 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_androidtv_chat.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M8.3,19.5c-0.2,0 -0.3,0 -0.5,-0.1C7.3,19.2 7,18.7 7,18.2v-2L6.4,16.2c-1.5,0 -2.7,-1.2 -2.7,-2.8L3.7,7.2c0,-1.5 1.2,-2.8 2.7,-2.8h11.1c1.6,0 2.8,1.2 2.8,2.8v6.1c0,1.5 -1.2,2.8 -2.8,2.8L12.4,16.1l-3.2,3C9,19.3 8.7,19.5 8.3,19.5z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_androidtv_clearconversation.xml b/ring-android/app/src/main/res/drawable/baseline_androidtv_clearconversation.xml new file mode 100644 index 000000000..72e1cc804 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_androidtv_clearconversation.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M17.1,13.3c-3,0 -5.3,-2.4 -5.3,-5.3c0,-0.9 0.3,-1.8 0.7,-2.6H5.2c-1.5,0 -2.7,1.3 -2.7,2.8v6.2c0,1.6 1.2,2.8 2.7,2.8h0.6v2c0,0.5 0.3,1 0.8,1.2c0.2,0.1 0.3,0.1 0.5,0.1c0.4,0 0.7,-0.2 0.9,-0.4l3.2,-3h5.1c1.6,0 2.8,-1.3 2.8,-2.8v-1.4C18.5,13.1 17.8,13.3 17.1,13.3z"/> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M17.1,3.5c-2.5,0 -4.4,2 -4.4,4.4s2,4.4 4.4,4.4s4.4,-1.9 4.4,-4.4S19.5,3.5 17.1,3.5zM17.5,9l-0.4,-0.4L16.7,9c-0.3,0.3 -0.6,0.2 -0.8,0c-0.2,-0.2 -0.2,-0.6 0,-0.8l0.4,-0.4L16,7.5c-0.2,-0.2 -0.2,-0.6 0,-0.8c0.2,-0.2 0.6,-0.2 0.8,0l0.4,0.4l0.4,-0.4c0.2,-0.2 0.6,-0.2 0.8,0c0.2,0.2 0.2,0.6 0,0.8l-0.4,0.4l0.4,0.4c0.2,0.2 0.2,0.6 0,0.8C18.1,9.4 17.7,9.2 17.5,9z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_androidtv_deletecontact.xml b/ring-android/app/src/main/res/drawable/baseline_androidtv_deletecontact.xml new file mode 100644 index 000000000..78a89bfaa --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_androidtv_deletecontact.xml @@ -0,0 +1,19 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <group> + <clip-path + android:pathData="M-934.6,582L985.4,582"/> + </group> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M17.1,12.1c-2.8,0 -4.9,2.2 -4.9,4.9s2.2,4.9 4.9,4.9S22,19.8 22,17S19.8,12.1 17.1,12.1zM17.5,18.3l-0.4,-0.4l-0.4,0.4c-0.3,0.3 -0.6,0.2 -0.8,0c-0.2,-0.2 -0.2,-0.6 0,-0.8l0.4,-0.4l-0.4,-0.4c-0.2,-0.2 -0.2,-0.6 0,-0.8c0.2,-0.2 0.6,-0.2 0.8,0l0.4,0.4l0.4,-0.4c0.2,-0.2 0.6,-0.2 0.8,0c0.2,0.2 0.2,0.6 0,0.8L18,17l0.4,0.4c0.2,0.2 0.2,0.6 0,0.8C18.2,18.7 17.7,18.5 17.5,18.3z"/> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M6.6,6.2a3.6,4.2 0,1 0,7.2 0a3.6,4.2 0,1 0,-7.2 0z"/> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M11.4,17c0,-2 1.1,-3.8 2.7,-4.8c-1.2,-0.7 -2.5,-1.1 -3.9,-1.1C5.7,11.1 2,15 2,19.7c0,2.4 3.7,2.2 8.2,2.2c1.4,0 2.8,0 4,-0.1C12.5,20.9 11.4,19.1 11.4,17z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_androidtv_link_device.xml b/ring-android/app/src/main/res/drawable/baseline_androidtv_link_device.xml new file mode 100644 index 000000000..bb029b2dc --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_androidtv_link_device.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M2,5.0379h13.3v4h-7c-0.2,0 -0.3,0.1 -0.3,0.3v3h-6L2,5.0379zM8.7,9.6378h10v0.7h-4.3c-0.2,0 -0.3,0.2 -0.3,0.3v5h-1.3h-4L8.8,9.6378zM14.7,11.0379h5.3v2.7h-1c0,0 -0.1,0 -0.1,0c-0.1,0 -0.2,0.2 -0.2,0.3v4.3h-4L14.7,11.0379zM2,13.0379h6v0.7h-1.6h-0.2h-4.2L2,13.0379zM6.7,14.3378h1.3v1.3h-1.3L6.7,14.3378zM19.3,14.3378h2.7v3.3h-2.7L19.3,14.3378zM7.5,16.3378h5.2h1.3v0.7h-5.7C7.9,17.0379 7.6,16.7378 7.5,16.3378zM19.3,18.3378h2.7v0.7h-2.7v-0.3c0,0 0,0 0,-0.1L19.3,18.3378z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_androidtv_message_audio.xml b/ring-android/app/src/main/res/drawable/baseline_androidtv_message_audio.xml new file mode 100644 index 000000000..6ea3dd834 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_androidtv_message_audio.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M19.1,4.9C17.2,3 14.7,2 12,2S6.8,3 4.9,4.9S2,9.3 2,12s1,5.2 2.9,7.1S9.3,22 12,22h10V12C22,9.3 21,6.8 19.1,4.9zM10.5,9c0,-1 0.8,-1.8 1.8,-1.8S14.1,8 14.1,9v3.2c0,1 -0.8,1.7 -1.8,1.7s-1.8,-0.8 -1.8,-1.8V9zM15.8,12.1c0,1.8 -1.4,3.3 -3.1,3.6v0.5h0.9c0.3,0 0.5,0.2 0.5,0.5s-0.2,0.5 -0.5,0.5h-2.8c-0.3,0 -0.5,-0.2 -0.5,-0.5s0.2,-0.5 0.5,-0.5h0.9v-0.5c-1.8,-0.3 -3.1,-1.8 -3.1,-3.6v-1.4c0,-0.2 0.1,-0.3 0.2,-0.4c0.1,-0.1 0.2,-0.2 0.4,-0.2s0.3,0.1 0.4,0.2c0.1,0.1 0.2,0.3 0.2,0.4v1.4c0,1.4 1.1,2.5 2.5,2.5s2.5,-1.1 2.5,-2.5v-1.4c0,-0.2 0.1,-0.3 0.2,-0.4c0.2,-0.2 0.5,-0.1 0.6,0s0.2,0.3 0.2,0.4C15.8,10.7 15.8,12.1 15.8,12.1z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_androidtv_message_video.xml b/ring-android/app/src/main/res/drawable/baseline_androidtv_message_video.xml new file mode 100644 index 000000000..27d4753e5 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_androidtv_message_video.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M19.1,4.9C17.2,3 14.7,2 12,2S6.8,3 4.9,4.9C3,6.8 2,9.3 2,12s1,5.2 2.9,7.1C6.8,21 9.3,22 12,22h10V12C22,9.3 21,6.8 19.1,4.9zM17.2,14.7c0,0.4 -0.3,0.7 -0.4,0.8c-0.3,0.1 -0.6,0.1 -0.9,-0.1l-1,-0.5v0.5c0,0.4 -0.4,0.8 -1,0.8h-6c-0.7,0 -1.1,-0.4 -1.1,-0.9V9.6c0,-0.4 0.4,-0.8 1,-0.9h6c0.6,0 1,0.4 1,0.9v0.5l1,-0.5c0.4,-0.3 0.7,-0.3 1,-0.1c0.2,0.1 0.4,0.3 0.4,0.7V14.7z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_androidtv_settings.xml b/ring-android/app/src/main/res/drawable/baseline_androidtv_settings.xml new file mode 100644 index 000000000..77da6220a --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_androidtv_settings.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M15.1959,12.0165c0,1.7286 -1.4464,3.1397 -3.2103,3.1397c-1.7639,-0 -3.2103,-1.4111 -3.2103,-3.1397c0,-1.7286 1.4464,-3.1397 3.2103,-3.1397C13.7848,8.8768 15.1959,10.2526 15.1959,12.0165M19.2528,9.7587l-0.4939,-1.1994c0,-0 1.1642,-2.6458 1.0583,-2.7517l-1.5522,-1.5169c-0.1058,-0.1058 -2.7517,1.0936 -2.7517,1.0936l-1.2347,-0.4939c0,-0 -1.0936,-2.6811 -1.2347,-2.6811l-2.1872,-0c-0.1411,-0 -1.1642,2.6811 -1.1642,2.6811l-1.2347,0.4939c0,-0 -2.7164,-1.1642 -2.8222,-1.0583l-1.5522,1.5169c-0.1058,0.1058 1.1289,2.7164 1.1289,2.7164l-0.4939,1.1994c0,-0 -2.7517,1.0583 -2.7517,1.1994l0,2.1519c0,0.1411 2.7517,1.1289 2.7517,1.1289l0.4939,1.1994c0,-0 -1.1642,2.6458 -1.0583,2.7517l1.5522,1.5169c0.1058,0.1058 2.7517,-1.0936 2.7517,-1.0936l1.2347,0.4939c0,-0 1.0936,2.6811 1.2347,2.6811l2.1872,-0c0.1411,-0 1.1642,-2.6811 1.1642,-2.6811l1.2347,-0.4939c0,-0 2.7164,1.1642 2.8222,1.0583l1.5522,-1.5169c0.1058,-0.1058 -1.1289,-2.7164 -1.1289,-2.7164l0.4939,-1.1994c0,-0 2.7517,-1.0583 2.7517,-1.1994l0,-2.1519C22.0045,10.7465 19.2528,9.7587 19.2528,9.7587"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/ic_tv_online_indicator.xml b/ring-android/app/src/main/res/drawable/ic_tv_online_indicator.xml index 6c7ec0c55..0ac0b3acb 100644 --- a/ring-android/app/src/main/res/drawable/ic_tv_online_indicator.xml +++ b/ring-android/app/src/main/res/drawable/ic_tv_online_indicator.xml @@ -1,9 +1,8 @@ <?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval"> - <stroke android:width="1px" android:color="#fafafa"/> <solid android:color="#4CAF50" /> <size - android:width="10dp" - android:height="10dp" /> + android:width="8dp" + android:height="8dp" /> </shape> \ No newline at end of file diff --git a/ring-android/app/src/main/res/drawable/tv_header_bg.xml b/ring-android/app/src/main/res/drawable/tv_header_bg.xml index ed3098694..f062c80ac 100644 --- a/ring-android/app/src/main/res/drawable/tv_header_bg.xml +++ b/ring-android/app/src/main/res/drawable/tv_header_bg.xml @@ -5,8 +5,7 @@ <gradient android:angle="90" android:startColor="#00000000" - android:centerColor="@color/grey_900" - android:endColor="@color/grey_900" + android:endColor="@color/tv_contact_background" android:type="linear" /> </shape> </item> diff --git a/ring-android/app/src/main/res/font/mulish_regular.ttf b/ring-android/app/src/main/res/font/mulish_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..26b7cb2d7ea5479fca50600057ac9b0f1861eb6b GIT binary patch literal 89244 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%0AIQQs>whHVS>uhNC!bi2GB+@@YZh;MiKB) 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 AI{*Lx literal 0 HcmV?d00001 diff --git a/ring-android/app/src/main/res/font/mulish_semibold.ttf b/ring-android/app/src/main/res/font/mulish_semibold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8b46d612e05f040f41862c0d82b1b02b99cda864 GIT binary patch literal 89340 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 KQ2hl85e5Khl8Q3` literal 0 HcmV?d00001 diff --git a/ring-android/app/src/main/res/font/ubuntu_light.ttf b/ring-android/app/src/main/res/font/ubuntu_light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0e9f90d7cb13b3c816150ceae52bce1c91375e11 GIT binary patch literal 361676 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	^H=8hr5C`;VUqphn2cL6%?dsnr#_$vi z4k#po5s^kgg(PG&m5~=SiTXei@b=$~MHuN5mVB$M85kJ^7#Nte!Hf4_I>?KFQmP0j zrHX)3st72hihxq82&fqW3J?cQeg<Do(6JAoc`Q(pV+~~6@&Cd$&?08RK#(AK*(vBo zL{OR&0Ch(p_x;`gr9JTE9zO%<p3Vo`KuJ$P4m1P?Ud;t!fZ7ng+<^?@fs&xX5MJ=n z^amWIxfy&VrDdf;Wf&yb#MuPd#6ZC)z{bYF3O;HGl<YvM4jP7cwYC2mJ<*1gi=YNE z>bWAquuufw8V?%3S2i`ih5cX=$IerXVpxwBVLS&Ku?C&FJDUk~+^dO$3?G*ZPlW)B z3!7jq6F<8PBM%cNsI1~(2c3Rm4?5QNtv#duTO<897r>kG3=J4TZ5q(k5}?x)j0F{$ z#9SO5UH*YD6#*S9`(-DiFXU`MP?h$-kVyx;mD|igmW>ZQZqHCF$juSRRU^P2%FD~a z$H2|N0XvNbbhI91R`1JI(DqS71Mo>zD5pSJ+uK5SK$suvha3d~YIZX)Fbjf*_bnY1 zI9OcRD)@MqxLg>RJ@`50Ihi;(Ia$D0LNT#$fR4!pg}RWXeq8KZM*Y7=XF=NxL8ncC zPlPZQ1m7OP6b1Fv@9i)TG3tWSUl;=eQx$l`-+40=1Nh)72XLXo#ltVae}JC}TzB$; z54Ye6MLFIAG;j`E>v-+1_FH3+rHCUgM3os~hg_WMgm?`aVa!*VwlZikymOEe2klrD zR}f<G6$2fPC<YpZ0WahQ?<x}luh0ii`+;{dh(LBMfF}yT0(=kw2Y%4ZD|nTT45;J= z)n=exFlbU3)NBCtbRArU7<>f<1UML!TvSvvBtfOQBzVR^5<Fud30lk}>Bgb0=Au!d zr6}XYz|736$)w_;!KBWi%%Q{~DB=a0tOl>S1_ii1WZ5|=f}u;zLGf)QXensS04_1W z<twPtgYE!e1kJ%h#w9>2F~H*)pz#aHNVp*M01qa6Y?B72a%u69X@!4_T^OG-&O)9x zuwr7Aw+BrdKxY-IVAD+h-I(o|SQ#`KPC9VQgSPJpfm+Dm<!XEq48Fpk(B=ajH3(`w z!-|a;4leu*zA7L&70|rAq=bY32SXqe6PqHa%>cbP4K$|--e;l+nodv*7SPrP%^dJ+ zGlH60T9TkT4!k5mCR|igC7gqsn_WVbkDUQ@dI{`&l(*pdLePYgz+3R~-fuzsWx-P~ zpli9`!om`1DnT1|&I!)x7x1(K>=YCkS4H$`1#@+=0LZigWH#*%H#d%H1<<X&|2@EK z*aaCp87FVG0i_vG0SaoV`Z|FRwRHjw)Hs0#EOkNElLP~3v=6*=9mEIESb-Rzq8Gfj zjE|qe*A_GoVgU+F4N&PU&H&ov2yRY*uEKH<2AzQpt{XwaqTq8ZK~V;p%P@itgcyPJ zi81(s_+UFg3<n*4245pyE;n0H+}SE9n_CE3=qf8aF@OwX0G+YH;HF#ct?ym$%>>#I z32H2Y_CtaeTZ3uHp}j90WI)Hbx;a&Ov4ZmyE2u(Z_0rI=6&CXXw{OH5e1+VZLEB?M z^^UoR1!&q1oPfX#J_cU}u#?3hD>SzAF!=I%8QF4zb}VRX8)<9beG58=7c|tO{}yyb z^EYs;gVwu)hFSE5EFsGywBH(mcTYny6ew4LGZlyd$ykVu<l=VB%<Pbjlc1e4;(Cxh zG|;Ihb~$EacF?FHY_GK#Qwx`rfvU2RJfEPTwx5kkn2@-Ou33_|j=XiSiGZM}zK@xS zr;dn*TY{yKtA`yohm3!)ucU2pq?uh}iJ46Zql}WHv4Wh1pP@&Z8mpRJDB}SOE`C+7 zA|LxqKSOr?R7c}@Z(S|ta9zt}A7f5ceQ7Q;el;f(7Ol|Mgv8Ep4I%J8t^y`mCSC?3 z27AVG2W~S^0#gM|^MThG3X3rKvVoEb`1Dv|5DPR`2P)A(s|p?Xg&2I5!I==eo(vQ- zpeO~=TR_c5(8fOpLD1434)AF=9N_I?pj`>zgEI9%Nf#7NTS237W*{rUi%3BW9YI}c zumGqxCJst%kTui~z>Dg|je|j}xIu$=pw0&P*bq5ZZP2-&%GRL6Z>>Sg!L30{i>!l% z>Kz?HtqLFT-VD&x7pSKt3Tk^W1S{7$=;(!O*nt{4cHz9B-kBgL4}%Xcubj39Bdb}s zoD^si?*Ygq8q(sRAw6+9(2@h~yV{^S6|~|&A2jF#S#bcm#TuNJge*ac$k-UZE*rd$ z0+xEfi3eINgEwHIrxr%gqHA?{Vi9Kt7t-P)V&Znp;A^?6`IXGnIJpIst+dq4RRpCB zTy>;E?Ab$$6pg~{ly%)hHN-SHxFi(i?2Hw6aB<6<Yl>@VM=&y}xmv1oNCjx9d6+B7 z8ab+{d01<)tLm_taJX7BtGg!Jc&FP*vof2oi5LboX#2`AOKIDvTDmhZGT8lhWdhxs z=)l+wI{Nejcxj!60O+V^&~9!AAu$GD8Bob01BwB4H4D(7FnCi9crcg;JQ)093uyQR zG)fGf#;^mA2HyZRyFp{Vpavs&ow68sHRB8L);*BK7SMPeh~ePE!{E#8#vlmVg(nD} z#Q-h)5(J$;ENB5*9V@5-nsgD=0L8B$s7esj0IeMr6qGUNw_vnz%6DP}pJFS<;0r$0 zRuoD@u8;Vzm5aew-wo^`J<vk2Do1nBl!Up77j$sf0(?%R1;~#UU`s5-?Ksu7ywpI$ z%AjE|HBdj72V}jPA~Vi;4A9<HQ12aD(!4bS?dF3_O2i7Biv{f_1C=sh3_9Hj#4t7n zO<sUTd5?igC2%6bF+l-KIf&idph5|>Cr6x*S(}lMkue9$xUN-_y9UZ_j*f{=ii!@g zHn4jfwOE;1ung()ahOHd1|Z$<s1uOq?3(YV1-;&p6*M<){@)dJ@E3zQW5Z4c?f>AZ zC1p_8N)&tp2zclKv|i5vlousH4Fw6%0zU~25e8og(2_3DF<RS28GMzMjF6Mo3vgKj zK1~HQ-nQfajqRWrGE;EUdawmF5)W#BfvYJ{5(0NMKn&149*A}T4frt!L(`N2XvMXl z1}G-Mb7O*_c{f2pDMNlEc_StcBTG$C@u35XU(H~tItxR+a3k<SKO<1BX9RM$5y;&} z;-&&1qfFV9LEEmx7<`r0!j(YvrzH5!))(Ni4t#m57+B!O7EuOYCGe^-ZjeTKM(lG2 zkUbx7jkN8xLB$KGfC04uwFS<_!IKR%#ULjb2Bd*hT!{u+Wx;kbAd(CtJ0ByX8b*rY z<w(s`w2Ls;@)QviG13Z)6w!4z*0SW~6ZF!=GWO3S&&Si3Z=2$w&c|)R#gm_==W8x6 z%x=LY#K_JI>aqWK2VKy^z|COfAkDxlz{|wJYrw$j!p`i%!Og?Q?7_jo#pD4xfBUVG z{o8Yp>u3MokvbO_3tC?UI@u1?lTtQi61wN;=yDHqW8Oi~g>;~P2LJy;@Hunb3>pq1 zybP>??972+Q$sn}xR^pgM=hb43R?UGJ}eJnB;(>$*7mmGbLTRT^+Qgc1MOVWXWGid z#$W?J%}nIKJL4J9;a7}2I~j!kgHQDVt(O6hCV(a<pu_7QK(!>8FAUyk_yIg&3_dGd z0MelV)odWXgE1$AFC%E#otUr?gAhL-gAfC&P^2KAh@c=JA1@bZh>)9sn>CV+TZD~` zn>&z~R}geo47e!-W`K%B5Cb$f%?7&Dn_GavmrX*#0kWOxfP)n1DsRxhh@d!Vgolp} zv`2~?-mwOa>DV(090OHsAVOQvQvYqcfVKc=qYUIyEk;3O&>k5M2oJPr=7^yItfte} z)&`y7ZOq5aE~;#*#ss<+?~|yGrZSJCkdC2=q@!`FGI&3Xn|6MArKwwYbCv1ty*Z4W zJ)pfX(CuOROj{Ww7?$p25c>bZfe+Lx1NGRzhq!?*fpahcZKVb^GT8(K8GIR}1f-Za zr1)I~ctnLITtLTYv9j_gn0YYz@F+;BG5YXux^PH>XNRQ0r^&Jki!*tMvWN<c3X6h< zzB!pVm|%m7mWaKr#-P*8-%6g<);5yRH`W(8^VjH%q`+N?x8U>#ZXz;jYa5$_1{C!e z!DkiQfrecf*}*H+eova@UT*BKm1=I9rtM`~<~nK8eKU^S(+Lq<gMzk3#GlRLGP@7j zB?V1IQVfqkS0;ha*#cd4;Sj^g;L8qbMYDj$tXM!^6%iH^72;rL<75*O66NG%<6;mL z5Nr@+7UT=$=aT1Q;^O5MmyncV5E2z*5MmIE6crK?6%}G)W?|)IV`boEV2xzs6k+3( zVFRxVk_7F<5|tKXVBiyF5abHwXXE4JK}rd4jqIUUP+ir&B5|%AIT0K=(#|S@k}M#X zPwAhD{d*=>pq7c_2nXa&DwG5QNgIspphfM<ppXLx95Xb9D1__PPH~)6trsqjltyab zrJC;CX`1%77Met)|GP7#fNoS}?BB^C{{IDZB>o4eUjZJO7hv!e1r0`n_L~WV+PK1? zIc`up71ZJY?XUyY1>mV13GiNBaMlGa`2!!f&j(t(20F<U)QbWg1PjW);G+s8!R-|A zJv5+`X+Rc(2M$2vub^rIbU_9?sNF0o!Yv^v1gh;IH+zFOZh+gzoFWXqLc%-}kj-l@ zk_^85!aR%uynMnu!d#pj>>}LYCO8{t#F9;fn^AyUm`B)0gj+;}TSQV!TtZNSiBXVI zfYC=nP((sPkWGRSbhwFwI;Vt(1cQsD>;+jyepz{0CIKE95hh_KK_&qv9u95}Mh*!{ z4=GS8Kl>K6y$Y1i83pbg1I=Q-HPS9@2XA_V-6O^*a0{}dke@+Y8)?@JKchBCM4KPH z*b6DoLjiazA@r0fW@BkaV`e!<&_W_6Wo>?ab8Bg7Yjb^mcfA^i`+mE3``kCn)Nyi@ z^9u`8Qwt08lZ)OF&FKE`C8PMi-M+JZLAOwe{V!w$9px*{sJKlMe0vu-(!`*V#>D_S z&F2TGrwYp6p!nwjMS~P*uckPcxS$X>zYsScD~}L2FB_*g7cVH?e{cXDti>SC#m6cR zYQA!dbBRX^af=8EaWe`reiUMSB*X}cIR_qLF^?vpNkU8oPyufqeh(qBKw)-XHeMEH zK2}y<Hr_}+RuMi{K4IDavWyR88TZRFE|6ucmt{<lWwe)NRFGxlVv`YP6A=y-V-w<I z;AP-u;6ge++FsxOEogQP6c3;?JVCK>uN`?SC!>VGTO;k*Snb$YXdE$W^K0|-^S48; zd;>QM!4UyF-AmnA+}xNQ67HtTdd#21bTpNCoHe8LSNODbxGz->RJOKP;Zf4meI@H) zXDQ~}>hSs4vG@ALw#WL#EbSa16*IFWc#PA=L4n_elarT6K(L0*g@c8c$%TcB&x41F zos)xw!GnW?8FYUa_<pgsMvVG@j|p7(`%K^*sM0VrPz9YR0-Ci|6a<e!GAc6`x;Q%i zV?WDy^Mp0{Mys8m3*H$R89*ofmx4z+Jsh+c0u8tVxEi>axw!Zy@G|lWFbFUTvIlaq z@-YXpGH{3TGO#g)a&WORgNB07fogGx)mQ(X1GS|ELD!ud8VG|1B0<MjnnJ99!^rCh zIy!U?V=?2of0>}s7;wc78Y6W1@6P1Gw3R`YVXK3pB<N&l@QEMdpz$bi@cJxqPzOL9 zye15EhzH`fvk=g2XTmPLLM{R>47`k-yp{3{@{Awk886B+GRPaq2go<bFOc6L&t_F1 zKSBP0{0DiK0{I2<O#SkVj4rZrvP|4;ULxY+3<CTfLL7p8ph+%JiwBfH-=2jG`GHno zzKty`ii-uWZ;y-p@>byQ0dNHbns3!cyCDq};>v=ME#4@%q&YfbzbCEj%TDB*(hU9= zGQMVFWsqi==8!1~S`#D&8p{)t0Clv)K-G{KXwXv(bc(hZsD1+P+U5`l<l+ki9j7Ym zD9gwot02qdk|5h4yFm7U>;qX=0a?ZmvW$>}R%JL?!v)2}7<qX@`O(f=g?Sl#+A74$ z0^hzNc^A~GMLu>F>{(OLwT603rm&+|t?g|w4qpWw7K?oRD(Hecai*;d@(g<&1f)S_ zwlt`w0I#k9$1rFG6dPzHLKHkI3~t1MBpjsp7<`5JTm(48TqF$G9N3uH*yIH&73CEf zL8nK6Y8XaPAp|-ohS5b{L7s_^lLNGA-G_tCOA=I-OM=ppB=|f~aG53{EG8xho`i?S zyrsVJSx~e8?^$hQV{q()j)-~&O4yLQyuon~Duba>556RU*&KR39=oWrsUEW}V+XgK znYM+vj+!*9iwmERzJ+U$HV@O*Lz<5IvMg;KjI8WzOweoc_?VhoShSr(^+8wV88ASO z(v@atbKsT$-J>J{+7k%ccL-jt1zH2{;K<M5%grCi%Pt%!%F4>XTPrK;02)bQ0<C>z z@MVzU=3osK1CJJofqHmiq5^E7W+$7lFe4v$K^kbTTgX!XthVu4@UV#y=z`L>+5#Yd z!o3N)mkhKEPDBjsM@DnVQM-%~Kbjgini!g?>IynKa+@ofso80ZGqLtNTCgT0GI6r~ z{rme83lDQ_43ns~lL2I!gGmE?sbSYn1~%x5PEeeJ8tUM<=7QW$3vN%dfFfTUA^;kn z0`o<|{1*-ypw)}qE;5{qO#GaToYIWqE|L{el1yTd4Q^hHEG!ZnT<~ci<G0#I;Awo& zcFFkIx8Mi_rDbhxVL`|^J@^84JtpvYvY@iyJfT24w<r@)7XeLI6LDP)1r8Uct^fY8 z@vAt+S)Djx=xHFq!p6@0uZw9b^jN8v%p6Q?4Bp^F(nS7$WcmcUbB)26@%2vdflXYX zu7M7yAq486f;<G;3<UBysH5#5EXv?3584?nB*4w!3qBkfJm?2%ujqgVWK}?|86og0 z^cSGs1!#3TxFZ8H4!mAe2$YM2KqUe%sAA;>ovQ|#HgN#0xn<+vgC4vs$>0mhAfP4~ zX#5#8aRM5b24xXnS&-%6fjdqHUvTeP7)-x#5aAbM@ZkbY-yrT71NHMk9s%|0K^_6~ z`M_<o2M!9L<1^Vc!RK#ky76l;YE+v}Fuh>PByXy3ns3T%3>`l=2Kh@2e5?2e&~Sq> zgB!mrsP!l)#^5Up8XT3Ca1j?V5ton!4f%jpdw`@t_JRZ)OhDl(YwW?Vp~0mG3IRRP zq6a<D=zt!mkD>?4Kzd$E%xuhF?BGSO>|CIMY1pkv-~ni3@B*kapez8skId*TXv7C} z@N3~)?PK7vL`V}0T-AdseFz^+f(5}WVL3*~pbz+tOz?mr_`E++5pnRD1)%$Y)S-je z?0;EW3#<}l#KhAB-AY3A8I`SdBv_a%m>4tJOae2k8@m}9T_udXjjck>Wtka$`X)$< zSn(_BMKp#m2`HIqaLRLVa0;=;F$I(dsIOheF34(S5$CF|?;Wknz{DW-|0`1nlOTfz zgCS#~gQ&a)XuTCEDC9w9i8N?n72L!Y1NVSmIDiJ9SwN!T+XkdSLn`3Q$A!UdJn(u3 zPSCn;F);rED7ryMNQ0W@pc^|tizC3(Z(@)s0Ps*EXs{X7hGzmrCujyr0%QUBnmbUl z9-Pa;Cr63%Gx&;#Y6kQ3GXCdf<gGS#FitRLGUNlT`UZ6w!OJlCz{@aRZ0F%+@Zk%T z1?LWFNd{jgQ0>SBTBXmV#t^KkWF#UOE}^Ohme&F8>Sr<xmjziY%gf6y3Q8BE3ZTQw zL?zh3*--~Hqooth&Img33p7munv4Rk#y?{O-XDC;=&#W|ZEeWKw4g(JVhi6w(*!u? zk&*+L4_ao06yJ(C(gZ7H_JZ9%lugUlM?Jofk@4SD=7<DSJ3byhUlXTzTX{x#V^vWW zCNm~R#zRaVPRiz*;!KR4!JL+1mNGSs90Kg|X(H?v9AauNaTd&iiYBT&QlK=E6c{C= zZwETYOXR;hxE#`EFk}3%lfn4^4e(TeKB$KU8aM^zBG5=GC_2?Z8Bv(QR}B=+LSXs< zsGw2X@&5w2paCgxkl<nP6$O<Ne4sHxQ&3)00woqX(Bgl{s-zPj)8s(IvZ5jkzM`N7 zdZOT#8@~i-m=AQX5~$|}s>VR}GlJ~p0~bmUKm#$1pmLv+0d#aM_$D9FAqn6?M9^jc zutO9;>4^(8*(3lm5M1ntf{UFG4l<zjpbIar0vo#ys5H<4c~u7#>pE_r!(|=Bxfy)< zwHdXmEhbo8uwat6(6`99U^Zg_6-Q#=lcXS54uJ%=2{ZUIxCwzKxg3JQyEZ{PQ$Y<c z1!)&4adRnYGX)PJZEapdkX44Dz%T@r)rO#qX6U5~Ds#BlL4`3psLjC++Um&;S`Ej} z%gMvX%?GN2L8H>zM?tM<PzeHRasD+r2QP?Vi7r;)TOoM3`j`=D+8I)RX)_`%?*ZwC z9vlT>Y9k6GNNr7IaU_=L?P#6orcP9O#HYZtY8{73c%?T{Wl|xyOwwjBVLS=06+!6$ zv>*#KBLQ0M2TBj%8)iY*;V^^Jl^Uq=0<MWbQ_!HK30dm4V>9SfTF|CpG4LIf;4Q|W zLta585_q<gpTSoMlqex10N_avA<%@Ah&Y2UC#XE&5CF|oFoQO@gNA`YB^7vpoDaMR z27Idw_%2k?o-k0F1Qk-CmZcGB(+VRfNil*d81R;OHFbII!1Ijv8JQRvMRbDs<r)9W zGs;(+IhZAwF)5gUlDH)J*s}+qW&5Cm&9?C{_?iTQ?nwFIU;^4<3@X7y_yvRcxJ~)^ zLHo=2K_?vYn}~$6fn3WbFVCnCN<jMIl8kD~;o2a-Xn<z>w6)YhO=orRc6N18ps5>x zb~S-As5&DkJ%a8U05xc}wY86)g;ac?gr@!X=s9iBOgyxN10U%PE#QPey*Thy=FrrH znbx2aYOJPu%-E9_qU2*26^52}jLAms`VxqO4O`*oW1J5w?fN@8bR0tskqS3fjN(s} zv4wFrq{#dKpCO8YfngqalvoFRGN>2>1G6P_9g`Z<RR#t|cIH_OuNmer2sm&vGML&j zC<icd89~nmg<Jy%y6EXERONaG$dR86hL)0C0gMcKAeEqo?0<LgCBS-&>JFkBpe`AB z7FQM2ev<@cchE32Xx^3slwgIxMbirh(84YzkSwT8<jWxdI=g`bw9$tH6u%sx=wb&I zmf-SC7}{Ec9mSNx3%ckPbj2K~4W_57qpiiDrK+K+q6u1zpb46L)dUsGnhaVDJ}R0b zDk_><tbBY*kPaiL)CRAthIAO+SPfJZT$C#G<z>AXR8$xk6ttK$nUt6mIQRs;5L-U& z!E0V>^g&}Fpg9jCfotG-dm&4Gfg_L+D^P_88lVD?G9EbsSqh7&5JA%5^n<v{6>0CR z9*eRP=;UnJN_|Eq+q_@{Ionu!@a|c0J`HC>87UnLD=TqDB^MX<=tw__0BP@_AjTSL zhfIIlEI(Z)CMzaJY0C&3GjBs_UM9AG2bi}0+pHQL6Cx`Y5EcxMD>ueZppCVR>JEH} z?XK~fjNt2BdB7Fj2XMWn0BSqSfy!<{P{sx~nZZqEF>vh!Ua|o`mx2p4-7W%ZNGXG6 zn0Od`L5*<GI2btYKzkrSIbIwR@t{HtEG_`$^Mh&d4j@p|*#UGKmxO3I18AWxgR+t= zgRD}dGJ}XRgOXC9G7mSmv=FF?A`}chUqXtP!IxQ6Rx(h!MnjoFR)#^EL6QwTbp_k0 zYp)OL>bx})0Ik9U9XxU6A!zmhHhg@AL0jUj5j<wVRS1F?tF0{zx;GniJSjA)%)m3? zY@p4%u=~M&)Ygg_1zJP*?m7nNWD6;AI6BICx?2nSids21Fe+9TtB1Jig17EQ#iWO^ zFuh@7{UYP$Vl6CgWoN^{$iVmCg=reoRt9B8X$J{@RYrMGNhJ?j8Y&Mil0oTQ0#wt3 z@&w`@F<ww+;Q_@Ds0|5fFn~J7;DKXdQ2c>cwn#$j1PL$=9{mN^4MJc(xc>y=ZxLYd z1sxOuIW1mB%thHn5p<LwXqS~prK;+V{|7)d6R1PvAjr+&%iwZAm{FJu6i-|#pp%`z zvz(wks9Sgi7(lyLKY+SZAOQy<P|?ig!71vY#33Wi$KWBt#9;r{-q`*vBol#p+u$4B z1-@w?c>C>+Hf%#EXj}oDXFx<OBdE&&zG93Sdc_#xEF?ij4SpG3bqjApb|q6yd0r_& z#!TdMvs{>}MDotWYWrBJFj=ukYFVoXo~gI{i*j666*wlMnDm%f8I&2_9PCv=qXhEc zbq4aF*;RS)Z4!v^r`kPkoS<1G(8^YDj6q{&iy(uqjP#EG;G?aicl_T0>R3pFsycp9 zcuI=}DhDcxiqxutnrEQa12{i|u6e<C@0$w9gzcc(URp*8G*k_q3`V&Fjt_bVT&NQK z7C1;m3EVrXjd~TF(b2cx?t-dP(C8odU<*)8A!95T!y&~Q<_&Sk=T_Nby)6#qxGM04 zOHuz_nLvBrB^io$GBE%D0BTo&N)u251dYOj0tY->$0@<!E5Yx=$-x2If}srRK5>Xh zR!WNsxrkIq33~90Gm47~NivBt32|`pcyX}8%MoxJ08;RO6SUM8G=2-}34<paS-~ff z!V3t*(o?1=P8lODL0Jw?RuLXGdC1CI7tl2al5EUoOf0$vj75+Iwv4*qCAMyipxf0X z7$UbZOMs4!<OXGV(1?XNUm(=Y@}P?Wg(YgGL<9qcYa|6i#l!?97(^HZ**LjFQQZq! zpDKWGuAu??l?{sEX=d=9Y{>UBK$qo$Tnf98LHrXF>zzB`I>d!(5z|%%afTfZJd&Uc z!UH}U5IlATT7nKbI2v^F6~7B3s8<c{*>ZpypO8bhZ$R4u;DQGf4WNPtd?NyA?F1*Y z3m<5s3Mj@v^frD5U&cyF(1L6SInbUKP((?HFhQe=QHX=dgP((wgO3q3_-}0g7F12g z!cXh_rmbxRFHQvB8bQhv(86cLv3rQs&nV>uB(^~7ppj10gRLJ1$5s>*=<I)ShItNR z;Bepq70z6swY*#s!r}}*?4Tl(A3S0U_AJ|u{~N$-*u{7Q#o57Qe4zPcXa)z(lsm|R zGBz7yAdh&hq_9AsP>lqCsHms_Xa-e)4XlC-lylkG8A5qMr*hvr3c5QG)C+<pO(RHd z1WnXHhV-<xRT0?@u|S#eBT7DlMz}fBIdG6w%M6SRpa}_f@PtG!sS^^Wpc@kfUAXyO zcq`@g<^AQE^yTB_|I0J?%iou0LYZ?A@(|$TK$>wl2U=kbI+_G@9!zXu5$L+Jx40)7 zltn>P-&iIZTpS(w15JbD^thR}9@2F)kYHtF2ah!G{Bn&|K*J@{2t3UEzYu(5n>0hE zLlDvP41#=t;7i+NQRfo)L%|D3c+oF!I~NP}6L>~J;M?D$Z;wJdub{X>o=Sjv2lW;= zTYGDasf543`;W~-y2;J+zYB9b(^du(#v0H{xdRUTpn-6G(1G<jpw-Uci{e4sG1Wo! znL4;W1Gn2CD}z3OD+47^|6d3^*a<$b8_XAgp5z2M%?f<p6lm-Llu@NY$3ud~roar) zc4{yKw0;s)0yuDjmX?5;=AgMu&{891@PI97`5uVjpb0vnQ$^NH&P7&NQh`r^!AH_X z3UtT2kCc{+rYvYfoUDk63FH>eZITSWBHZkt0l94;rTi}7YqfPi8{7D}T(~PtWj(Yw zG&xk1`J|X6nRMB~H|Mg0_KLEz^E2@=adUv0H4dDhTdYMyOt?50?9ajP;?xG6$7&4T z4f73j9jCS>>UEq)A%X&b&k2A|0R^@05$#1pwFkPHlZ}ZT?Pg9yMpR=`=VN7OoXDlB zXP~3R!OPF)%qcEtBj@65FCk&?>>_6=AjaW=bnK@K8$U0Hl8%9%Dp!hRP-1$N3t#QO z+Y9$HM$I#7?(H-;@9b?hn)L6;8l=K%A){(7pG#DFVvr>0a@GG)Ovjm68B7?fz$s%3 zC}9}xfNZ`u1U1n)A<GdRR74njbtFJNG96IkQ%3?c+^r7koT-D_o9eJph%F#-P)s|3 z&UFAcIE6v$*+6j)PDh|+M4$#gNB}ex0iqrFLAsbhyIq+<ZAWELdxjfS?6HIT`DUO5 z0X7)Ka8LtDsK^D%>Vht31)W8$6{slyx(ZoS7PJKzku1RHj)BreAUHYjffmVuZ)G*r zVgn~P2PJL>UlqAfP4E@1y6m7^BYfB$KsT{qyQ9?v^^R6>x_Em}`zW|O0lJb^Qs4|| zF%IaO);rn~N3}r-!xDV_%sKFSH*CoQ+l{TD*>QMfMEL4fK0#LGlG}oaYg`Lnl^~Vt z=oh*&OlR84z|0`-z|C68?7~#R!o&pLR|C2z@0p<i=sJ$+j*j4&9nhk)a?tPrg9>y| zpan8~z{DV@#vvTQ#48ON6!`z2!QlT_#<fhK`$AkmSCBBz@&FGWFo{|_D~JX#30gqa zK}^hLQiGVt(9F=xFo!{EBQw*E|Nk9$71TL|1DJU={h4@WVVCeg&A!N_#sD^(q1gj` zXAd)zsI7~FXaKXIn?IAF6<j$R0|RpocnoAAcnrkg|5s*d@RdLZ!F<qGY!<MmG@zbJ zW0(WF6^!x!X9rGxaR#mcMn)c}d%+iof&JwH@mCDQUmWWC;M>Dgpz3V?e`Km)I>N-p z_!Vr4_5ZJoZ$XwaIf40LdFEUuHIO_bJ3}+WB)Gr7IPhv4DM|(~i<tN`iD<w*0x{zv zlN!hjMmC0Ke+FnseRts1G+<{4U=}s<XA%Y7U<SUfhygO(lM5T}VQ2;q_lP?PFflTi z+c78yFmXBfGjf^0t$>XBfUE$I`Y<#zfJS{F8jP(ZxdNCNZ2TD+4B#5%|9@os4Dtul zHBjI&G&5L${lUbz<Ns?1UI%wjY_oa#GqTyj^{D*^U2+ey<Qpgq8JZPA{s8It?!aqi ztF02iByI1{C~XYaA@lzu;|EX_GRc5LkKw;N;{woCu1p)id{7i-GsiHgF<l3{qKjb} z!yE>4(1NT3pz(L`F~FkWg;-!dxcmo=sxmT&h%s;mFfxjO;tiZ*4Gf`EnBSNVz^*xG z=z?8yE+hiJ76*LIIb?i|DI4s4h{erdiy`49sldP)z{IHJ&&VhS_X+4;<8Pq*rXcsH zGc;#`(=HPugSZ?Ue*hD^f<GfW$T5%zwEO>&=>*7p1|zV?ni-BW%z<1($DnJ%$q>NI zXXekurv*3P{QnmwevtVLL6ERb2b<5##GtAJR<7sI#HR#T4mKZ@Oqp01K=&6zv<or_ zGe|KgG8_V(lny?s5xiUtbbA?S$d^x;!IvFW2QY(1=|E@Ffx;NHVhvQrgZK_Mpb<OJ zummfp0$~M>UdW3FGt?``D>LdVGs;N>bJZ!yhbl-gN=UFm7Cb<1Xbk6P1aDvyhnzAA zsyx7lK7!hjpry>9dv{Q8ArP|EFNAb-z~?Q3I%S}07}C(kxFmwv$jn?(j!{^Nja^ZX z5%Z3LH=Yr!|2DHmdb&k$FnV)DU|c8g??ZZ2R667G_@JQp|Nj}l0SJm9PymASF*Jf$ zm>KlVI2i(1_$>UH`E=kR4hi9lOlr)n$PvWC%%H9ZR&L<W%m*q(APGSB|3@Y{u=#JG z3BVklzpgp(>Y1ua1u%)4`!kAa!>y4054v>}WCahz3JzHQdhEcfV=OBYz{GFr&&aO{ z*CF)(CzC8V|2zWQ(#4Ph$v+zZ4}cc3fzJkm%!Yyaps^qaP%>cTR8?1G3t(hbMa@5u z>!J?8u8U&mvSDDb-pQcx{{Z-0c93~shT0CO2-rk^P=k<BOjR9mr4%AdLfi-{%OGxS z2D|aoP6nI*2SDQ@*3izD6_f^D!Uhtzfbv0?rGfZnP`)XYHi6Q{P}&Ge8$xLVD6J2r z^`JE9U@MS$I#51n3IfCj9TWtjHKF35JN!U=ke@*`$jcxa>|<em245yd4lP4Pwg4tp zV}C|gbx5Ru{S8XT=-ANU|9=KZ@PcZ<7)T7;fO9z$qnMVVj3BB>|Nk>Uf^H#`8WT9^ z7*iRhGt6N~0>=la`3`oPBzWxi0EiEA8i)pa4RZe^SRCv%Nd{k#*BpdFv(1c_>Y8Au zYxy&>LhBv4)4?Sv$f>75cZ@+U4r5HsXJBLk-H>@;3#b(b3N<hTWHy)qG91hR1ss?G zavqog3OX<Y<UlY3>_QRn^ymRl;R7-SM1u?g(I7KGG{^`L4Ke{lgA4%CApPLRCfFUI zhAGGdun5QqFau-;m;o{b%mA4JW`K-=ECyj>bXV5|2R*isK#xRl$bc4ov4gYrGjOJB zX2^t;?M#de@@nF|0Za@U{)`Nu%i17i0c0r@C|`pvd}Cv1Hi5=EgRBYzsA^X8XJn9q zq#p)GhN%DUj2D=;GH^0zI*75ex-h$NR&a5Hj{x`JWMX6S09_&V_8jP()Yx+Z|Ng!` z0NTg^-Y{*dEXa7_-#JG|7uYt2t>6KVLdH5K(9&&P2T4x$K-NIU8ZHKIMs9v4MkeM^ zMg|VHP|%SKFf(HXzWsfB)X>0K5HiZ7Y#PR>@!ZOuiPijH=COWI_4)r7(;aXSyn+V7 zeTF%Vk9IQHLT)6m0p%fX@bPWn8w$XAn;U!v7MKst+uRJk;JnQZzHA6A4$j-$;3-Bh zADp+j!COYbd~n|82H%nh=7aM#H~4BeFdv+^xxp8=g887-0h-VNB@Pe`N*f>=lr%sz zC}n_XP{IJwwxD7Sv?vSYfh`~gsB{7|KwbbdK;;vd0rCWx0rCZy0V<-v43Iy-3{W8j zW`KMGW`K$*FazWlFauOjff*p*fEl2o3d{ic2h0GccaVEPJ_3t?yaZ-|`~+rzJOyTe zd<ABJyyYMQ(#FEfplu|_7{J0{;?K+gZJNR}JbE(6#Rr#RVgEswm|tX4W9EX!{bmMm zdS+o}&^2LD31DF`^Jiv&Hf<PKKy#TaVoV1agc;Nsj2X-s6+!!4UVtX5!4r&-8SW1b z!UCYVK>^UTHfT&8bRmf$sB<gC;A;q4h9LqPYtRPm;m`(M_O1<@g9HtLI2Z^s_)3CW zDnfz+{CvWEEUawo9GqOtT+G6JTztYl%v>VOT*A!E3@(gvZh9JSpf~~94x$}gKxYM- zh?rR}uzX<21iG<P-_qYQ-ZI~kjgQ-fr^3R_!$i`{5VZ5f(1DA=*HGI_nVFA?7rY)G zbl4Fi6Lh^LXtAyR-+S8HfA2w?HL(KsuABk!|K7uThXni<38b+kaL_Xv3o7festYO$ z8p|<@qu-asZUQ<nP!xO(HLIeiB9ow#g|LN_r>BdIlbu0!dZZi^tCXIDs;a%7l$4&m zs;YyYl(n6Sv7Mc<iQT`KkVBCEdaESn6lKRL|J}&6)oRj=iI$A@TCRrD(uS^DFxuYR z+uq*W`<3JW|Ns9nvNAtov}0mpC;@L<(*6I5iIwRPg9CFdDA$9}*0cqUNk}mG+Je>@ zh=FNv;cg3xMnNzi9POZ72C7LdLG#L7;N1Pg0kT9;2wWwAO#`KL(DDfIWy^wK@e3eb zTA&eW5e8on?Vt|Y_0Pe@;HwPU+bIi57_y)<{Uj7XJCs1n#UwyW_?SU9K_)W6=bbZx zmRvxV5Q5Kb2fIWBTt0mO-GgETQZE8_*$dF9w-IO!7ii2%3!D-`M-G9eJB7hJ%>_a0 z89=RQ@bn!MXzQUJsQi-zAJ++9bOz>&fXhShHhXJOb6gRWpyfepZlpLs3j(DCKm%}6 z7NC3g1VD12P0!%DIq(dKAU}hzn!1CQJcGJ}9lL{ry(VZT#0zxqik7^+hKj0|yq1!d zJgXv$9XqQcGm{-VyOz9GrX9P89lM>qH9H#{s}9(`Itt<pzB-^$J)J}$5L*azXSk3x zD3%~@+yQDkg818n7<{d(ot$?3UjaI=Q$qt(w15gq5DgmjSC>*$1Rqq!4L;l+eEXCF zcrm01croMwQ0N)&_<zAcQINscm=lyuITNKoo4BQ_9PPB^B?K6J<v{@`4-Ru-NSHf- zBGO1d-AqBAEtOSK-67RpLs3IfL(~{FU<nE=W6&~6&=M9T%Wi<2Z463$LSW0m3%`s( zQ7Hi7J1Bw5LSr*lL7sFWPzOf{)DaSr0BsZ&0v+8dB+APGIxrNpKN3QN@+0`Z4S@rH z4}kCi@CZGlzP7fWzQ8x_x7tUvwY5QaChH3v(bhK7*B1Dqtql?cEiyP`q+iR#cZ5+w z;9e~g%N5Xe7}nY&prr=<{NTj~{EXV5o1yqY8)A@(W6<a%$Yuyel7I*xk&Jwd{EUjC zifrKWz>djW95R#+x?Im(+0@vM(VkJ!RMCzRI{ghks}Xdb2OGPwDC1fwA4@ex*?+4< zef$)SHRSn(#bUi3LXAb4S=d>v<3O_i?lQ)4a{4=&ISC3%c$+%}83?g+m@rNk(^V8R zQPwrKX6NB!x8RkL)whx36cZO^GXcx+$xB+=3$vSZ2`L)c%8025g6>*3XJBCb2bzIm zv~ds@<Nz&u;|CoV!4F!D#wP&cgO*=9fL2-Za)6E=1g%8@r+$9WxuBpO16*Kbpp(SG zXMD102r__gL<MDA@M1jVbH!Oft6E@}S+RhMC(zbNQ0B`99jwABB*4hWDa6mj%PGXe z%Ec+f%_bzo$t=#n&%_|kA;1XQ*Vo7>Ai~HfAi&SfF3!Qt#v;za!^*?P&DO}uBf`qc z!^6!fDJss!%ETzn;le2*Aui;>3R)Y-&Ckln!Xd)M$pLDu+1m?=TWW)L``^3s_X6m0 zDuKJ_1dbWCvw(L@@$)kZ958BUfm|BK&(8=+6a4(B=>Qh(jOOC(q9Ckn%C2q>#*nQs z*6m_*+r{QOLa4)x#}6~^u{ZLnvp4cOvfAFrv)<mw^R|Tr10#bGQ!Jw!(<2662HTws z?EhbYFLD9}>J||OUvAKW)efNYhKrenn~Rx+9dt!GI|GCMU2Txt8THTJ(>}`pnm87Q zosJG(AiM|VvLfXBi2nZvU)fg<8VdrA7(&~S_Ds8(*cen{V?m4;K=MqNz<kgMV-ASV zI2+6djTNzg_zW6gz8V7q69<@Y3+995qriM^@Q?_&htG5bG(yUt&2Zg8fDg1ZgAcSc zgAcTU0+bxU*SUfipkfN#8W93-u6^O41v;?`q>K|(Q1Ua#GcYkQ2{3^wdnP^!P@<J^ zW2x4y*JT7P>vPcMWAIgS<EzqP@?Zd;@y7s~Uu9t6gsj;Gsh5@D<YMrV^%7=Q2PH(% z+BOi)2f1Ahd_;x17bo<NP4G31;Jc8(Z3%7gAtT^&C^i<{T7Z-Zh6cilqKc~EX>2_v zP@Xq7l4D|ruFZyQ#kOO*&*<@Q2jffhD>OY+oMNr4Vx3fAG-H*OG}>L6k#6~Znwoz3 zZf^O0T3UYjpm;-Dng$AYUvRkVfm;TkcuQm2${@y=>YybG+J-C&T2d-10NM*93Yv@% z1=Rwgph`y+v@r<05EmTyg5bda09w=!-d`yUUi|pMfgdy>1vz#Vbfg$)VE}k?8dNfX znh0P4P-OsG6#?oVf&@TO3RWNpUhMq>tb-F&Z3u}o_<)yt3xb!8e{jg)V(?`I%>grl z=71rKB0;lkkd=`j*D``!%LuZZQ37-r1^5m@Miy2<237_iF+mYAK?yNd9)1CC0Zukn z0WMZ9A8r8=ZUI(qaWM`-CRPC^ZqPms4$x9r&?SsW*DZoBp95C~jG%47=fL~+;9}ra z3b1UCg9e>Q2;MEpYz}HIGD(3hT9lW!G&h&?(DSgjlDC%!-M<()e`>#{NB`6Xk<G^z zw7Y^YUHt!_!IOc3Sppn;2Jl>z4dxqybCEg&DB*+V8Q2-P8Rj|^@-s8?GchuQf`S=* z?4Sy>33CfGGcz+I6G)H=EZEA#sKR8z#Kg+P2njWaC?lf^qX{DuD<dNVNG$_cRE5EW zp@o5&nSqgmiH(<=k(GzZgMopC(}SCd&4Y!5!Tv4y+J?8Fou<bG?j1X3XuuE~tF0Z2 zL}+U>78VMdDvC0iDvC0TDw-xSCjCohyvhI1mC?`I`rjsNYo@K%*06y;(8wuhWEPT~ znSwzcX1WIt03C3#WDHKuY77jF|3UkN85(vnF#Lbu;LgL~%gVsu!^bJY#~{wfDGp)_ zi|`3E@bPhq39uQkF|o-CgD<J)=hWx4=Va!T5f>H~^AP3ZV;5!O;NawBW%FPM`OC;i zAG|tI;H{B9Xt8vx_A!BLvB!ig^}$QsY6Xt8b4Un4k47~#0G+9?D5_{`$84_0tZu3( zswl_In4;upXC=)z@t?YonS-mMg0qb=KjW!?{XF_MPK-I4K3-lL)>d-v{=r(BfnN6F z2G+t>9)X~-B?bo2xd}{c4CY{egR(9BTmn!yF)%R8F+E~pV|omhmu6sKasq`DgEcr@ zTo@RbK$p45Gpu*umH|yKGJ^>4{XL+=;z4DbIQUWzaElkb<yIKX|KMN&n(}1?DVG;? zVdUp$7ju!QRAf*TP&80<P)ty4P@JH+L6L*gg}p*Sf{C9=4y06$(F=5zAgGN8q8$W5 zhVTi3Pw5d9WQT0Nx6}u%5C)xd0iNyy6`P<n!r&!8p!@?GT~;(z(qpn^GzJ%sg2sYI zb`TyPld>pderS=KoU^=jn1zdZxV5~qoLf=oc5R<Do2U2e!p$TY5B{^4FblW4_tYlM zM|(R1BZCD41Jf0zBMkbClN~HHLAy9KK||~spcVTXAa`q6a5MO-D~K@os(}ba&?;61 z4PFLc1p^)iUj@)j4xj@NK`Wm?SJi?KCIOAbgO)&n*5HB4GjM4IIY{mXsQC@5sU7%1 z2YzURj8g}#v6crdIRbT}IYC0~pbn5WXzM9xVIgQZ9<<gBT;G8f0)h%h5bdA{YILau zfCdpAK<#SqN_92xn7$e~cXEMNxqw#e?)bj|w2TncHUO2m;JYk@!FN`Gue1X39o#^1 z!o{bg#-c6k#=}}|$Y990!H}`ukWod}O}olKNzFrrgOAIDheHD7H0Vwm&}F%x^{U_v zf)ZW~%-}sN+S=OopbZ5^i~`@@9szG!J@OXRi~_|Y1cPcq*g0$3sM`i03ZU1g^D%+W z3NW=}hMZW=#{`OcanQmO$dNaUZ4M>j#?l%_Vzd7#+vrNEI7XS9M>(oU>Dnm&n=Njr zA#EI9%ydLMtRciQ-p^XZN=M2uFu~F>-9t^yBi+$5A<$7u$4bQ7FWxewAq;dEtNH)0 zOtwt?3{njG49^_64MBHu8-nJ&LHj@)%t2S)NC*fs_(*^%Knc)L1`nv};Ra2;fcK}d zf+|W@(9j0t>^e|zfsP{p4ex?e1s^!*KRAT)GWarp`nU|>8GP{ZF&v<Vivp;Vr69n~ z;L8kJSkDYP2hc}9P{?qC;Q_-3hRpnijLN|rbp{gQl5Eln0-&@QF2u{Is}arsz8(v7 z*br#LA-Dk&8!K?{EhxA_%jFTRQb;QV64u~*tq_e6aX}H#IYMg8dd$j7Y@h%)HnL+f zS2JZiEa`1#8*L+}>Y89JXRfcz#U&yq!Wn4JEF!BS9GfAi>0~IOqb$YFswtwN<&k9J zkm{<!&dJU4uU}GDkb~Kbneike8yibbx|X+<5*MEUo0uf@J~sts4h9(pGsZg5CZP`w zph0>aP>6yzM}f1T7^vef1}%m-LFEysrwKZ<2bA3eK|N|tZU$d|P>&R}aSIgOpnY55 z+nxA83z+#pA!Q8ew(3HQIM5M!pbVx98XMpgWbhRQ9f%4p@E|sUw-SNv-~*pw`T;)e z2DyUm1&9x7vVfZ?pf(J+Ya`CV&)^H%Sq2tn2W9VA(4q$|EfFR?P|2bP>fPvpROtot zIB@fM@Th`>Rf9$9Ef_2q9V`MY3M?jAJg{Jukqu<3F$djEz%Hv4E+YZD{7;<C5VZTl z5R|eEL)p0*K^uAAYJ>KUL5{cs<vSzr+?+P#<_pjbFQ7C4igiR9z|45!%7P|#OrVx1 zXxXnEv#1Cg_{K?7Ic8(fg_COFt1lo6viX=qr#p%n1z2l%m?_v==sKti)znBEs7kZ3 zN~sx22PP%wvvGPvIJsz;De>=Qx@8z@r!1!LW?&p4%%f%Qq?}p9!Nbd9&&tck86Ff6 zZ|>t@=^?4_sLsI1ApGB*@i2G``fmpwS<uxyvJRkiqu}i@{0zR5p!kDqlYtxu1(I-3 z;R0P10jj|?Ko=|uh>A1#2y%dmT0zi|q#&p%584pn09vlg#=*(p3qH&C0QkHf&_X}( z?q(2UD<6X|o2)EoC`cT9S`~N-8^m`I0^iI7YDn_SGBR;9@CEYk_<sPjWRhPz5H#uS zAP&0HLQIAsoST~ubOAL#A0Hp=+*<J2>iVGekoMm@pwkB9V?kLEe5uk~(1J7&4~8L! zr7(leasZ7f8p|=?l`+*-;;`dXF!nU$;9|C87L!+1RpRyFS5{GQ&?%{@G%%>@nCz(? zrf=EY(OQ-uua;X~4?g$rGZQ~E2ZJz!15>JlfIX-?WhWuV;A;uGO2ZTsn!@0r`48Z* z&<CwW69=zMdI8EF;DggdpnM@P4IZG;18EcmkKz9SkNZf1>Mv1H+7<=vHxmNoHPAjt z(BPi~NU;R?7Lga=;WiTiUa-~=;6VdOW&jn0;7ln2o(TX?gzJC~SqE*h1q~;H*R?5w zM&3Y2X*o#pG59ipY+(R(RBS-In{B{jai9Y#L3JL8c2EUvw-g8U62w7uySM;orKmZG zX%6Zkn1jn#&<Ubjghd&A!1t0%gL-@-pn_dQ1JwHw0Tt>Z;7cY!lj9DGpv_neHj1DV z=M+Js%ZmJ<qMAS0sNPB6iBTgMbecA3s2W6X1sNs|8sY%2+F}j1sdJRpW7HD?83ih* zK(qrlXaXK|>N<lAxS9Z6AqFm&K$|_d!p+%~K;vP&;3a}5K)bh;RKvk{T!L;#1dRX+ zf>#TIk6i}cbqNY|MbO?!(6TJW9sf6M<zetul;MUBlYs`xK=m|u|8b!)=-$slP`ROf zMO*tVsPzRJ2?N~=3QCTkvI9{|qm&>ZX;85Oq7j&pj~P~Rn40J@sUr?N1f6sUtyaWA z1q^7T2c+&{G*EXkkQUOk*VVMs5>qlT^)b<tHFQ#uF;bUg5#n|<u(0El&{2_dvr(6@ zh<37zw3L=Jabvv7tzqe*rRHIwEN|wfZsBX9ps#47t7N1o$jQUaWWg@2rYayQsURk- z;~3|v9_D4B;~8(HALIx+5y0aA7sfM80t`ls^$vPspu`{sYLJP6YGpCdII$o|Oc1nk zP7t(mjt3;c1Cju5@e>BGZ-HbQQBdrNf(2eUh=K|TUJX#H<ps5mctM)MqgjH`eN~XX zI6oZBKx;TfK#foE>DzoD`=q5DKxb`8u?Y(D&DUboa^My4&|-!3PfQ9-CYUfOgEEs8 zBWT;t56HQk!3-jdA_g2%jJ^h-K{8b#2490<zB*$`X+~+eaB;0rK~VbC0FQ9UYcOgE z3Tkjmfx6Tn`@vfu#Tk4-TOYv<O_0ELP{}DJ&Iocg_!KTyhHy5J^Vqn-2V#Pn>fp_z zLY9`2`VyeKmp~_ELK2bomBP26Lo2Su8XeJ&1s&HdA#e<I5-WJS4?LY95{@dUi6PF% ztPDyiX69hh*a&(w9i-W3%_s^w9YIt?j8RbDTw9(^lta<bP9?q2+ECdvJK4&QkDtfI zQqo9WLRM2x(Ah)OSXVAUA5_U$vhwn9GJ1L1IP!6u>$rOev0Jf<a)>BN$tVi3nzM^( zDCz5iIt2e+7<Vx<G8iyU+sUB+{|Bh4tp^&E<^|7TfVVhkfkK`ad<?<^2T2JAUnNkG zD1y!j5K;iew-9KX9axtHq<90h`azQqpk@k4w}TrugRd~CXb}bvT!UCbp!!@0)C3R$ zwO@ol<)9#_ND~A#TtvagXo!MZg`%J_Em2U($pFgo65tKP;Dy1UJ^`q(2GI_&pkWso z&_OmbAme2~%^Dd4F$P~5aGI0>wN7O~6LK=3L0B0D(BQfZsPL8nZDEp8P>^TP(&H7< z65@^2(-P6s;?)z<6XgzOWiXOA0_|G^7tEl+F!0=;da#_Rp@K}Po)D`XHybNx|AxJg zr6qXK^awZ^Ts`_$$kI~aU^@%^@(@M|fg?tO-~<Fu4iE-<@q$Pspj{vA>X6O=c+009 zi@Bnyq9`9LsIU>2V`kjN#V^Ee#>~gd#jjwjV&<VOEN|rCU@iLZKcl~xos*-nf}pC6 zwzjRhpoOryvK*%vi-?rGfT)x(F9)}hk*k48T0)qIrLeiFh^=>ce6pdssj?U+r--Zu zcrxCd@g);KgD67-Xh@Qe!Iu{lZM>jn7<ea!BzQdg0jS;q@xgn=L5$5D;7tEu3n;IF zvJ+SaG<fL%IzWIckfT<7z4(4{CXqmf8Zj;rHerEq24+Uk>BUC&r$B`=s5}OBfFS{- ztt|{WGelHSSr9y8$i^<H944FOZtbrx;>fRTsjcVC<;eJ%Lqy3w!t|dS<56=T15qxP ze^w044A%cYF^Pj412PQCjQ$SX$`*W}SvW||^1*>qh{0DLl-9so>iNJIecS-`aiw?s zzu~|Ss!c=<_&{xCUhp8_3y>VR=_?76;{c^tcF-ZJObQ?cpjH>S1q~Xu2c1X=Y8mi? z2IY7`EOrnJygMDdMG>-B^@IaRiV0*A7pP9>0-c7&1<GTf)C~$>(6%d3Ul!Cgc8~{^ zmWtxRto5oA3c>t!D&nE?3jPZD3iB2AE3hhnYFALs0IhO!PzBYdBH>&h4|1`|f?7hd zAScR#Qa||k5!rACZfG~w9<=3MKNj4NG7|WPG8O@g3PkDziy$_dLMk<3@ai{a=;}8{ zP1`6dSy`(nTNoW0#Qbj!qo|g>c1RElV=zk)Glx~?#PINmnN~3Ruj=wCUZsKBObZtF z_AX>#WC;HMo#6ra4E+EHWdVK#egl4HX3(esvjnK#VFpz;%o-dFz6|_~9Q+R8V~zzl zcszI;I0D&f_}CeE!@-M2Km#p*rS54%PSgZBKUQ3i*;LRN+yT6<Xs9UY$SEW%!pX=l zZ>Y-H`L9v})Qy8K)_eh84f+#2q$JG1z<8aRgF%_Gb0>rHfAF{wXiKUCXvL>8s9aM5 z5uigEK(Pio%^6(JfCRt^P7V~|a-b@d58UAX;9xDn;L9f<z~IXbI!6ZFv*rS^I6*@( zoFEot8#AaV;{f&SI6y@g2dLx(B@*yC6AbVq!w()We&7J=KQVwxLk5sB3<lu20$~PU z(20v%KwUHt!yyXPx)T*;QI(Tdkd;+Xh*abeQB>psuTEg$WMX9CRODf1V2)(r6k%cE z1lIuET$~KbN{T!@Jc=wTii&IsY_dE&EZ{zeAZRK~FkFI*g$>j_w+GD-2wCdu+uJkh z3mg)()UO3iD;@zIpr{QxS+kv^_6U3&8G6tnXh4zg$Pq?~V*-ctK?^S7+u_mDA1D?f zn30c>9eiykqdK@F&kmhH6c-Z`SJq=>WN`@<bK=pqbXMlzV;7K+Qjp*iX65Ek5K}f* zk>c`B*Km>YW<1{THp;NEx8FfTO+ZConv0dmjG0YbO<!{7zoY$`Z$L@mKd4k>WnyEP z03K}=VPF8Aips>s5D88i7XMusk1}&GxG>(`$zb>Yg@Y9rgReR$V%0%$t`2T|t0{oi zA*zAuHCa%7Dg&PGmI0Lw65!Kz#X-dY=!9_a8c1=_RXO6|WDcsHg&2I9KuZfG!1M!9 zUUveuoRx$be4Rj}1h$}TZVQ?}5(m|H;-Hiw3~HBw${Yu4LGb+pk_<j70-$<R1=OKX z0Uy<)0xHKKca?*7TT6kRB?ayVNP!9rP+<-#jzDdDP-_9q=LMJc9~@+P7<?^tby&d* z1>`}sxjd-Km6w;|76=A4@g4X<3ujo}OhNuO4OVdgc}AGQ7eqUNRzNreOSy`O35zi@ zxHt=oIWq|}IY){Ki--w3i!tdMglbxc+RB42pm*RGWbn0=x0L77(c~6m0^ON>P#<(u zIk;g4+BgY1NJd{Dx&8&EFz^YTpegRR+S*s%Y71O5(gqD6Na|~AYfA`RF#?|e3rS5N z5~*UwOm(V|#yo2E%w!H3rbB7TiNPCka!ib>pt=~P(l+uol2A1^7n0Lg6jZYG)zWw3 z6W}!0QnyeMNDML8k@I&o5Yw{v09DFz!f16km%Of>wvmUC3>&+$o`tNThpreun<Xp1 zh>D@Cn}@l8hKs49uDr<q{|t~t!GcU|pjF$TWX!<8_!L~C{R5w%74YAkNtB77L5M+_ zQ5<rOI4^@Q4=9;I?gV<V1ytLCMj{-9L8DQi@&w#k6R;44oIwOC>3Bh{G{~7m8$e@4 zyr3u+flO<G5)SwbBhY~$(9tbU(3WFSu>1+w@G-cx06P2#Y$Ip{1AMv>2dE_q+T{f; zHNpEmdBKMieQ+=WISzbVxiqMzlMd#qSJhW#6b9Xvp`rll3MhngGqUmti-j|TG8{9g zLIA}+sB{9gI6%j6ywwKnE=G!VP$Gb0Py%38QUhJ410FFF6BXfOQU@J11iE7cbdDLD zD5J8ekD#NJS)h4wmAp;#IayD2V;3z!L3KxcZyz36-s_CF&BE=JT01P0JvB4M#Z|0) zH1z$<<ziA97#Ym}yD+Y25@gV3OmPqe-=QT1TJa(UI{ID8K?FR$_5qaVz$5B>+zh_j zpdP+9sGtCCaRr4gND6d>5IZOqc)?m;fSTdpeaeu7!oV#X@HGdZljgxK9Bu|*Sy01H z7Q9mrw9OtAli&=?C&u6_1sZ*j0%d1VcgaDWkHJ?>l}l1wN}QF2nVp$kT#8vrJd&MR zgq@vP3X~SurDR2eLGuFOhy-n-a}eTV@a5+Mcg7$G_v(ThG<@LZ{0H#;G7Q18bvoi| zn&IsH;E`u8Nd{k4B}OR$eo*^b-`-eT2-L#X7XaNqAaE}h)VTsRvb8}g69hmv^Fiwj z&=e)3gaG8kC{VOv?Sg@?B|y%?YLEst6Flp()rji287b<?^1`#Lx~_A$iKvo>?ZN zh@gnER%i?(qk)6AG!KWcjG7cu?o?9X=GV6IH?mK4hn@xw9+vvT<PADNn?aqiVl%%c zBd9sB8Pei>0BTi&_~2O;P*2q%1#~kXDBK|@bb=yUGC+{QR|FIxBB0?c9#B%22gRK{ z==eoBaB(dQ+Jh+zl7=?)Ku2Lah%xv|fCiW)KrI3uP%#f~Vu0F{;M@g@4+lN)f-aD? zATz;hpTMhLSV4BPf_r7GpeZ9M&?*=y@cF*npqXz6K?YwTe$c?6gNh(1rp1E=80s|z zG!--rG!ryg0yGOWCulCvJfQhN^MfWkXdUQgeGd;Xt)>g16;(XI=RSeX6jli4s?!jX z3g>44C57#vysZk|l3@?(Wr31_wl-+M_sm-%&_;P}ZDVc7m<?pq1~m_Z69F_I$D%Aj zP!v^`V`eu|Q-^f4<(Q0(%$T^8>_SZp!|YWI{gR9r8D|S9N(#7`>BvjCIOt01*r@nM zys?gPP*ie^wf4_)lra}Fuyj&)sZ!(9kMPoUH<C(VU}O;a|C4b#6Dxx_L!yH+gCrv) z1VPPu$Zap+$`mx?!72e7ECV&!9i%|rX-Nhi{y<KCZbohiPTo*%25}L@BnQar*JAJf zy<-H~{s;CFV=Qbs19Ivts4WXR6OXZzLrYfAMwzdhv3WbMq^g9t3@-~ahm?_r9;2|i z`M>vCrgA*2EDVec0sp@;zF=ZykYtE+(3SwFAto@%0E%`70noggP@qVy^m^(2(oCQx zwUh`08v`$BGzBzP2x2(!fVzj_44|Y5Dq2Au342g`8$7fIUVx*m&8now1Z{1DW>~;X zJ|;#PD?bAfM`2|XMMV>3VMh@IKW=5)P$Ndoe|HU?wS<MWoDCV3|6Ml<wN++dW-$Bj z&UAoDkU@+=o<V~#%Yj=R)Dutx5unjOP;m!tBJlAr_)3CAML~^a0Z^p@I@SQxPU7TY z@MQvtf)gBQ76shE0yW@4qm$slCJD$HGvM)Iuz)B;08~kV6oOAk14%e22r&4{NPu=4 z$bfT*4ETyo@KqsPpv(f!S<=D`zFPIH!T-e>#kpkzdH$;~swe~t)oF6`fo??wSqnN{ z5mbnS_+V?n0+8DyUVy9xOMn_|APENp0R~@L<#1k*6}((*D&S_LiUKG?RKV^~0lPy5 z)bdo331<Kuw+A|85jt%GPCMG#peXu#FV^U<(Y@He&tO>yB8fC#2@-(y^dN_ufmZM^ zf<`(8?HE->#KZ;pm>H`*%EJt_{c;=}9JBqj4MHmzy<{yzEJ}0#{>d%12(gr95>$0f zvA0ijQDfS;kx9)Z(cV7ARrTLzZ68Zz=6|6a#Zghk9E_`(l`Vb#|7VZ^b+SNB6ozPU z%No=~Spl9Ux!@om32NO+f(kQmi3Yya7j`T;Xts<CROf@%(12#)L7RjfJV3_;fzl^4 zsBB~g6^6{96|hVoF(y!pnF(~37Za%C#xE7fD_biT$S=Yu!r0Ht=)lX!%O1>HBO}en z51#%4b)CQr&@>HrFaT5|^Mo@>v57J8LT~M}2j5cxISBP$Y^>3p*uT#NKnFyihO@S| zHmj+UnyDb#Jinj_o3bclOjnbmjERrFgTA+^jAK*R&JDfQjBMX*LrlaOFa6UKHwm%* z_U~79?*<0Y9X8*YS(*45EEsurGFbfwpRKP0O7TXZ6^fwlhYqOkFCziEH4`*#Ap=Uh z@*oy?d8;IdB?;;dSb<gvgHk1^`Ua&+P<`$oF2vx=FA@kIwB-}vWaMNF6tG~l0FUw+ zgHpJDuqG%pH9>=LnxKZ5ri!Hu_~0!W@NvpA2B6Z;LPo|yQcIH&v{o53Y7d&U*v`k` zYZNXII)LH@NQ@0Ms>=nMmkO6;<KqVn4uSfSU~CK>fd$Ry3ETrE>c2+X_hLcmSo<%C zdk!4Hh6dnd3sMCdE(1^K=`n#W2M4v?KzGC;EkG3q4d9D`iX~e{#zU+UhB}Te;&L*q z*6a!j5^|=VIwk=U68<JSo~CjV3JUDjtTJ-qE{-~e60D3%rpkG{=*6cShdF9-NOBr_ z#F@J#*~;izTI$N!Cb^l%c^Gm^a%ee*8K=kVxp>P>g)cEY2;G#-#{BRE1B1m*2D$$S zwtx=b08PDt88V=49v~6Wi8^2rrXBz7w#Z6z2nT4JfGal8PM`nJ85o$HnGQ0jF=*I> zPF;KWKbwJp!FMME&;KuwJF0lhxVag8co{%vzJN~R*#c#J*uu}x;KQ@y{{aVX0RaXd z9tS}|2!FG<ID-$^3Q#!0t(gb5M%x8pO%iF=fQlrjHT<A<2_J~y1ra<TLcjr3hQaMc zwwvJ**lrypyAyXZu)w^?0`?*+*o!QnSu~IrLBoz<FS3BVs0{KV3)qVsAj{Z61RIE8 z1raPD0>f$s76t}J9?&Kt1|7&olK*F5`>1$01sE6um>7io85!6h8>9aJ|Ns2|N2Z5N zM;O!?j3g1^6ad~1w*{01z&l_-&I26@40aw^1mrr{ZUWG@8BjwU+)9F6Vs~JZID;?T zdGHun3l0^VI)qIQpdQ>e2X2s6f;OOt2i<_N1yooG?)d-BK@7~Y;p1cQ5i|wGzaThB zU`B&X2CstE2YcEE;%SC_n5X%~WLbCum{{cf8Cir8o@QWRdJFP2^Cl!uN9|-V0i|j_ z1|RVDA&`^643O_Zb2nh$gGE5T2kkxr`5LtO2t<SS9)W0(0u<kCgB@uOTD`)|$)v`- z8MIB2`GF4u=&V5|E@MM>i2x=Z*ja<%i7&9a?GSZJV0DZVW+uFB0gPg%pv|SAZZT+d z2dr)<M4cT(ow%ep1A71?lLS<qIcP`{tZp|%-DC#He*RAmoPzu;ya9}C0uZJDKm7mB z@B_48ni;H=p?L=b=*n)@{|7)#e$WvVAR2T=1&CGxO~UhnXEwp|;4KTh48HPEaXBar zDj`7XK?MYe29*yWS_&!;I?V{g2OYZ(qCxYrAR07f528h(>OlL{L408-A9Q3Vh%X4` z3qWa5;~pdqI{9J?FKA2Yj{mnEc!i}{cmtT&Wc(ri)i!~YNuW%NjzL?p{%`yLozaMy zkx2~_7|f4$Ffe#KDD#2VJV=2EKG6OpDG<R2Iw9Lf3QRJCw(j3{5R?*T;SFGBQ}<_L zQ}SnI18;iQHZd^)m-nDm-msAlP}G1%IvClQA2@-dkV#xwk&__+vNhf0|9@z+7qmO< zI4BAkniIf#!<ax@BUwZOnAkM@8QBn9Bf+g?kg?2Q<;)MP!NxL*%Sy9|1VB{6jRpG< zqV7=w1B1JRDcEyrARmG~rv~yN#BXXaA2O)Pvxo#Rvl;s{vFZ6UvVr%|qx;V8|956q zurp4BawYQvh6B(;GsSJJl!XHr`K>|ej)9S336njeFEb|tGsEni42=J8fNFElaaZ6g z!@vyCW)je}nu7?K1)7Otb}(f0Wp+?!^aYI(I7otKaF{@1Odv5vaPbIgC4q_)2OiLE zgASlGUKp9d=7@n-crk&z7SFVviHV=7p6NUjGZTY;VUhM(ZASgG$o(=%VZ$iSuFfc8 z*{H$HslHg2fsvtx@dLw0rh^R347ng@pKuW4XYgf^kZ1H|aFCT_^kH@omGod{U;>rq z@}RxV4kD5sOpGiH%%DYU%%CwwA7;=}MR1xq09um(@+_!dz{sGlt$p+^=qNcxNzgfS z+IJ<6LPiG|wT0Q$%|ALB>oRRPz;l#=`TsvASH>!4Mh0#MaRzBd(arj@jNpUw_#xBA zpo0)Wv&Nv!9FoxWBcPjUz;j*_piwVz$S6B#bQa7Ph48`Kq`;%@T>K2a5}@&W3Gms7 zpz;vJ0I#o?mXrlA%n_7i@B!W8@?eXUG$ZJW#|I7?pg3Vr;NfTRVF3A)0W`4702&-- zkdjmYDU(zHDUk$qc_5329yowDrAYEPfQ;i2l92+H4h}q^qF9nsLYkjhl#@aKz}*Aq zj6jpti~@JgYD?Y$<zMhQ+@QhHyV^$tKsQ4{+EgGZZEfvXMiVn*(BYMA%1Uag$ZU3X zb4EES5fLd6{`UYxf-oavl4dB=VlgQxF(e!^M`8&$-+pDXW#)ht<t&qn85kKIjyr-@ zn>c_7dk|p<B5Xl~4T!J?5mq3=5=2;l2y+l&1|m#Bgb9c+1`$Re0=zfU0K{^1;N@rV zaR8I{VA2ju+JZ?NFlh}Yt-z!un6v<s=3vqcOqzm86EJBECXK+PA(%7(lT4smm(#!+ zTq!w%awsJELqif51u8QcAaxufU{2?Q>l&v29~?MM9Kj}nPD=q5J`knw>X2nY8R%RF z&`CF+9e53#*d+p(d7S;3ct9?I?D$64u{I2*<GKT{g#lQHi9ZvMA*`B*ItOM4JIg|b zsSMzflbK1-z!|K;1+NwBN?=y}aNxB7)zi#8AS*z1HL{}$;YndpA^2<uOK3X+yo;HW zkHH7DuX!to0rjCr0N8pre<mIWh<ia515zL&j9g#Hz!18V!2!}q0&SUg;N)fSH2`gf z0q>gz8B2^sprF@=)<EDMGrV;VI-3G?h!Gph<Rd#6qrfK;fQk!n1CJBDEfm~C12^zE z!M7BH`Jf$(Aa$TU(I6Uh1OSKzHx)R+!w_I~;HCm6w5h-e+0_VA2W~2Gg6FZp>cC9} zP6l6aQ-KqFA}d%Nv{M_jSOe@HZm4^>A!jdu#6c}m5Dj(@H}vcVZU$eldq4+Wftm)~ zQ1@_y*J**bvMWNx!A%#?H3(2Xxak5KY=-i|?%{^G#{o1JChY*46=PzMR_EXj&^BQJ zt>DF#GZ5n7=mqTwC}%>1`01^qAr4Ie#Dutl4k;lHYI*;^#K6Gx8r<<vDPdq_V_9%) z2ZNl0h%Pt|Au$Q6`E|htgBoaHUH2InnC?JyrGj*=J-CD6Dd=c=aLNH4D*#Gyde9UC zI#d9}A5<w7bfE#7FJ-0SzQj?igFO!lvds|B+kia3aQ6;|dtlFld-fpTgBc+2gBc+I zgBgP=06=XC<N#oRjJYFnB%B5I^<xGGrgIQqhl6~*?(`1E)nH$P3M-JOK{UwEAR6Rl z5DoS*<W>rBVFm6Db3zNJ0Vpg%{ZCL*1iP1t=XlVhFvXHY<zzT`0wDDijwA|?H53Lo z3U4zoFqJX0GN>`A{0BwhqB&C-c(yY$G1%CMiGfW0|NlQoO+7>n2T09&Of?LUUO1>F z1MY>hO!kEIp;(OVz`bx6P{Zi|e++e}UBN9L#{d5uIL%$aN<k;Lfm%EeBSEbZh>;6? zz-<vGMn+>-UbX-xF*kokF_5{CmIb=<wK|}-2&i}Y)PdL97_7tGpHU2PULCrQh1pOG z<c;0H%H8o=u+9sroYfjM(!eAJG95J90Cvy+Yv6Ia1F&&BmdXF){_kKg0S%~u*WiQ3 z<+gyDsUU`f5GR8#kAozLkN^>&{ugL?QybFt{r?EfwA24{{_kKg1DggOb^@6KJ~|!5 za1aI!wBa-F5(5J>Bhx_!bp{QFzo1SsxRfu3j#F|%h6%TDax(aU>h>+514=*!gBcJJ z(10SSrzXfLz~CbX+D+iY117=JpylTv4G!R$XE|`y!2>2y%M?%*{~uI!e1baW9oR9T z$|DZy7%rG&xL}UqgNg9LM8qK?4l<nLAjg0@Njwf95|_<jdqJf(sG<V}!gH{_pmN&= zWN!m3P$3MkpEzNosRAGtHj}}oK4xHGW`p>KkqK<-y3;!t%Alq~g0_K}pxpuvSx|*3 z3SO@PPBvhd2uni!D*$3)a|tA;gWSUq1|IkVWliwF7t7>SNY-Q$Qv?rusYA0Sy1LVe z(5(5_fm2EytQ0gV1In5ZrJ$?{F>*l$G%GNOX@E|$WYY9!WCEEB&I(W$Ae685fMv}m z4!m+=U>#Eaj7;K?mOaK`9jGVL4(W+7G0KT)f;DL2wPIZw)I}_Epy53xCXo4{AwFam zf%*&(4=hRs57Ns+yCL8}26Yoa4QvMy5Q~YCLC*|qv5r3@6ZEt)cpn53APDo;Co?cO z?PO4Zj0A$3?OQ;G5HuGQz>uOGd?V5S|KMU|CKIR_(FP6wQ=u3^HjNs^NH{pHYY&QI zgxLe?7&;dt%#KidsauT1KujGB#R#Ysz|6(W%An4m&9DJntby7J0XrFF|2u#aH_r|z zLw3ji2GB$w50nOFBG6zRs6GJEAU=374J-gErQz)sP=STTCQyrm1~xGmfyeqmMI(3s zpJnncXpwi%fs;=XJk~D{Egf~xl%C!OReHmLQ%D}Hln<ix|H=Q~8RfwPxe!wq?1dF~ zw;g!-6u`skivEmjAfv&>(f{55zcYMA(y?|fOvio)UNJtf4k3R=Hhx$E`TsL`v>jBW zLhM+02d3kv123N<SO;hv9bw1i|KAzqknC8u8>ZvF1FslpVuOhdWCv(+1LCUh|GzVQ zWCjIYv_U|^gLs>Z<67ob=KO&fsw`n-_7K8R0`X$p`%pp(-;G<aHxm%$f2t;7pH zrw!~raI>Blyu}a92X{kw8GOOr5MJ=6Jg_);jD#0DM#2m2hVVkiY<R&3qkz?c$830^ zV<eDmGhlIIr*}ZpG~j#!nx_FXKod3KF)mOe^|k}AnjSdXRQws)<RB3Zs$bxF15%~q zB*9q+J<8VaU;y0@CjqI4K-CW@$VQ_M5+1}x9cZQv-13FQAuoduBo28Qd_ZvsmjW&L z1Id7BumosNCnI<^4JmFx#RRmD2Sp~a@v9B)uU%tcV7Loz4(L#)Ie=ms6`BK#v%z(i z4r+5i4AijVWbgsa)PtM8pkM-r8$<*iqyi8%4&Yve7`Q*d0w%#4K)ni(21u_$4BVe! z0h8E!6^uPl$DlL^I6#i!f>;D@=5v6TV(`I4_!xX3BH|DcNUwqe+@D|plN?|>F>MCt z!AlGbjIB_6QJMo{{~?_TmK{(A*iWFo1jJ7u7B)YDO?}M3z_<WnDr$3p17sK^Xd%4{ zh}(G~ZU=`kL<AbN;9dnt1hj}7WHE>a@wb5b5+DHwaIXTFAE6!6+YAhhe&FVS4r+5i z6xs@4fi(w2q0Io0w?WMUP|g4~13*@RXb>OV8~_VoZ4RK>M62cicqKh(o(eL*%rZF> z+*4v=R?r5|FN0=EK;0j7b*EFoZ2_kLe;qhg48ckjAZ-E2;(So83pH{<4!B>!%*3Ez z1U^#T*q@0HWG=YM#ZbQ12UJOe=0Kh}@M<W4b*T6=@hL*;2k_k57bbqV1)!c%H>9V; z%p|8^4Ax+R*NSzJL*$v6STsPh=%Dk%Av5V<7yZBX|0B~S(9#bEBQ6F;c4+@Y)PYw6 zoClZ~%uKjJ!%qy5^6An4k4)bn%Jo3XAtefua!X5Y$P56uIJ@-!Bl8xfBMj;cMhu`c z${~Zz0pKNW;H(LnsQ@#qz?p`R!AAqe0L|=d0quVWsc``Bgtr2_3F0zDi2%0g{{N56 zOQAO11vg(oV+<+8+XPyk39<>iJd|j=wIRU*@@F>G_Qhb^K_d+nP}@1dzB&Ld7{Mlk zrf<P^ffx`GZkQTw1|P5pXz4Uq4Tu325fz1~aS#Bpu!jYx#sP=N<NqI-+n_Gm2X+x? z^!6a=9976dHSp=fp!I3s(}zI}uq7Y{WEPDZGD`;@#fD6$aWnXsfs0aZ1|RV0!yv_= zb{bd>sG$aCfLdx`251Ekm;q|5ff=ANSTF<BS_3mc%{4FsG;9oJfEsLI258_I%m7WZ zff?ZN09gt?-IbTY7vu?0iwEQf5DoGIhz9upM1!UoK{UwyAR6R+5DjuYhz2<xM1$N8 zqCrjv(IA(DXpqA}G|1f`8suyc4RSSz200o;gWL?FK~4tIAQyvZkb@n>c^Q0>V~d%I zK~s}k1~mGLNW`FsBaA^&3t9dGD#jq|Sy(0~fr~LlW+_$hdKS=O|Da+FvhD@c`vI?K zVL2TKF2)!^#h9EnSSjd0aD-A&cLSnyK`OWyV`5~G(gCli(e-C!1DOjhG@vd(C|~Oe zD#k#~+$RpaN>X4Qa{i2LpbKBH=>QdDEs$c2iBV2U7pwuaa0a&(>yn`^Vo?IE+F@b? znUB842h^8=cwkWic)&*))D+-j@BvM@g4^Vv8BX|skC7GFVgr9hHZ4ej3u=&qs$-}R z5$3H=0I$hZ`F~&wFE4`+XhIjv05vhe1{1Ua6x6V0A!tkF|Nr1Y-x=V>o=qmAFjWPu z06gFz1}Yl_9YDpBpo63&gAZh84l=b0N(Z1}Rc0G-lfj0O7o5dGMc&h$3~G?(FL;82 zjGhcVyAj~bAqp<m1;Hc}BZHtI3k&|t0roy<9JUDReP^)uLF2H+I~hblk<Q8B16l|N zE_1;x5O8pUOD`^HvmO+YAT<txAeJb&87c@S!5TnS4_F#p^&q<uHP?b2bDM#ISq<D= zvSIiRb_}QyqqCDi>Hh)nGD(n0V1^Q`eiH<>n?S8na1Q}w3Ik+`7%0brmx!@UW_S)= zBF1Ft0$w8K1#Nert31u{m;sTyZN0#XEm0JMvMa>c1q`oXnUBHJTUj`OiQmVckso9> zB)39LM`&2faEbvbhdWw=_1O9|@*{3iLf5m9krQT#oTU#~gD+l7)-k+*X<%^#Er?^{ z2iX8x6o>38P@aT%V-dqs25>HS0!26<gO4LPdifZ996%EnuwmW6NU-Jp{*3&dXt^BX zW`v3B!6tg|WN?P$AZO6vJTHT<6U<<OHh_X0Eti8Pitd4$Ow5~5TUZ9*1kcD|XQQJF zUNiwp1E4~InGvE~7f~pPI`G1jJ36ZBAe4h9diI0rdFD+FufYiqG@bu@Cxat2P(Wb; zE}`sUo&X&b4i*8`qF@H78U-^z^(dGDsz|{MP)!PEfT~h315}rS8KBA(%mCG<U<Rl< z1v5bPDVPDOP{9mPjS6Ofs#Gw;73Lln7y}gCU@1^|J4k@?h7&l9AYNx;WN>l<`yI3> z0i0_vYfNw$-e+K7-T)1hJK#V8m5jf4GPptm1-q4CyHTPTEHa1!4iYFPsDWZ&KvbZB zQc4*#P!@p$1=L1;3Qj4Yd3#Vo0ns4uf@n}O0nwmT0-`~Q1Vn?<2#5wH5fBYZAs`x* zKtMDoeSm0C@&M7G)B&PFi33D~(guhIB@GY_N*N#;lrTUvC|!VPP#l41P^tjY;6x$L z$>58YCY+r$LD`2;ngCT76QSPU3-&&!x_Ams6CmTUSqBmar3Vl_7=p(E<bAZ*H#8*5 z`=AO+2Q<mgyeR@S$-ijM6vlkWDmp&!QD+B0qgvoN=VR~%$2lLgea*+<3yyO>248TT z^MUU$0ILVbIUn?n6+Y-4D|`&T;5g@l-m$_5J{TXY9vtU<&_P^2@Ih{1@i0(9%g5jw z3Z+A!bTE_-g3^IdIsi)hLuo%K?F*%SptLuX_JY!$P}&1ZyF+O=C=E_~d<?$e#K6bk z3r>4{kO}}4KA^<lzzrVuV+ah?1*bb41rbKk1Zr1;=RQ0k(NGGChV^r%FhaVCpzZ=V zjg3Z!FFm3|8x$YVC9$mV^{V^R!L2B!|K}Yz(Kez$*4rYKo~j0SyO_j{Kx=Iw>uf=N zDaf)DkUGe^mi@M1BN_kSb>KwXvx2VlR3O+$MpaYLdKSo97LbvU>1U9Ukm>CGkx(P= zIB=qEZ$VdjDi>m;vIJ-z9x?}S^8f$;SK#?BkdctdpZ)8gMs7#n1_Pc&`v<zE3$pWr z<<udl(*F*e0-(t&$ix-Mp^%X?Rwgy*aM=D>uu`V~cN{p;HgTXUJyi&HD3gj3Xc!DK z1O_q^(u)N-6w<TX9|ks(5o9FR4IBuir=S-zs>p!4q>%0?$Vf=D52Oy#klfF38`gNf z<G_i$83S}iHF(<wLh&hv_s}-EvNfn33~2*{j5Yu7hFAvApaxzB&&D#To`GSxL!+Yv zXf2Qfn6w9zc3{#LOxl1+YcOdACN06F1(-AklV)Jj6ik|cNn<c+1SSo^qydBk?c*>2 z?Jxi@iWc>8WtRxp@&DTv@G5p~@P2zNr4}?ppnBqTKB$-ic|vA8GsGmwig;6`_3PRQ z?}IuGpu3vXKr7f8A>LPU;A9aE@MM<=0F88FUC)lygtcKXyA>S7;U+LZSHpuAo<m1g zvFKKZtZs+809iklrS4ettFf#DFOCKI9M%0;m%byJ0P0RaLSRuL%>AxA8L%#aci;sz za#=(ZqY#0ExfC9>pc*nP&j3kd2rJgZt%!wG=A4j9n$VJX0v1D;#zQOyE#?L-jsO3h zIT<`!p~AobO5R6yFtRi6W@=_&7+oUo3m!BfX2CW&OXSf)9<)S$G~_|6u0ZR0i4J*k zmdgMC&U_uR0=fj0<`*1;r1|d*48$y%|Nou&1Vnc#NcY-<5Z$10j*+u;{{MI8`H(=c z0R_Us-H<@o&%iJemeBwI&b$la`*4u&L6b0`tb2=rfgv5T)`k;&j39WaJb0}Q(mo%& zNt?)};@|<1+yB2aCxW^ipk?3;YAm4MAIKSv3=Ejd!T*0}&W5Pv067`dMFFV=%{!tk z9cKvp@5cBNJQcAMQYV17hD<ucz#!=$#Kd4`CL$8Rh_VnBTv4KHJDmuwD9<o3$ZUt{ z+5wuTKwg)Mq!ZN0fLOR71Ey2KL7bVv%1T5efQb>yGF7a)*LuKot2l7Nb?^9(b=fKw z6F`lOcJNpN$jitsz_N-Ji+(khb!jjcAnV4ms1->!sP>2Weo-<^x8qI*?2B8aK@ALM z25)ajz+kR%h1BujwjU%~5XP^E8}ChMtt)P8pi5pM)<7pdu7QhH=%QB!b;zVMxG@1L zSjk>Pk7g=(ZW?YX1#9X5e`lEro@f9sf@M$xt)&N<%e<9=fnlK5)c^m^(gt<ZJ8<5E z)-KG;7#JAnu(tmHcb0mn{h&z-H5O>W&OD!ifq~*R_W!@L%z~H>S`EXX#sV!knRhWT zFbvpQ`~SE9e`oOkPxF8m$s$Tp<_rb~1{y43#bP70Jf@Y640iwBm|4MP`e_CRQ04?p zLNT(lOk()T04dj1Lj6GVODJ<v;PMGwD|l84u2p6`6I3U-Y)77?LedQ?+aVT%r>Wq& zLFJ=LWDsaBhY`!H6;>0#^Hy*ZKxI741aKLTIgf?a6!1(I+!SPY$YD-wVKo6fwT0CL zEHhn5CV+}!NLYa9y5J@_?qt9|=LIQ-RkD&H!Gt;W1u2HXr9C9F5LSREzu;Dc5IYHm z+e+wM7{p5Weuw|xnSVj1x46Km3$$$tT$oBRFfbrZbD_<O{r}D)3(=+v(grWvnQc+E zq0Nr{|ITt7I_UozTuMS)@XWs%7#K$SEZP6>EN7sBa|awauqxyYBydLbY}x<sER&#t zvj`kGu=;Hi0|RK|vm@mE0q~GLCv*md6M7pnCv*md6FP&!37tWK4$gq4lZMKy+5hh> zYoWog7aR<*iuf!f7+fL20G=@6gie@%=IB5ZDx3_ypa~O@cLzf-Q8If5t@qVHvuCKa z{^%^4Fa2lH{(oml1$9xGH<coFQJGsA7)EE)d<SB36KOV$=@o1?jWMwhJXgc?|FZ+9 z3Fr(PCLYkpGGw*@ba~+cm{Nu=n9?r}oCZc{N8^B|BtSDTkYV-xkU<8}yb8D-1Uk?O zT+VQT52QZ;;)544a6yY>E@*Mg1uc%bpv5s4WUL&d9$XxAG5CTfw7H-Y+FZ~HZ7%49 zHWy?<+ku;l!IxRo&K%^*9q2QK(6b$prhh>rFpz<JCVrTI!P7z+4ncOHQ)_HNgbj$W z1`$>u!V*MSfCzICVFn^hL4*m2Fa{AuAi@wtfTwwwL@kZkC17VuVznPUi3bV9Q<dPM zgL(#r#RC(FqDX;=GKGpW06_@`p0-$~z!J=72TmswP*m`MHs?dqmK&2TEEzJWu}p!_ zVaac2hD<~;?Es}oWPREujNpg@pGU*o20h=CoiUN2g<%eZfCD!ZgS;+>Z~zl8XjLo7 z8i>jmm`a8&gi2{yWR?G)f=*#z+6y~{fx&s@4p389+5vRFBNKzHEGKBeBV>s^0~?y+ z{%JcH#CI}C|3Bcs1v>T-q6@SR6yjd+<iJ)2b;wi<0~_?}^9m+=MkZzsCN`GI;4^r; z|GP1Y{XfaT$6(?h!3jEEnG<w31*ZaNVWa>vUmh<%JM)hJ2e$GGGWam_G3ejZKFcU@ z@9(*D;FH6|MZ}CnL8q>&nc6U#8=sNaW)<Qvuwis?QB~4rVzMz~<^r8a4Yid)kYTce zG&ATda>$vzpp(tPEH>~48_)@94l<yjDK;MP<+?nA%nG30Kg^&DCYTw7I6-T|KxYbp z7!K+}48EKWpyTQ|nKeWie3=zMH-0gLj^buk0AC~ny6B8qkU<}EOVb(9(F)-6WsUA= zp9M{wL6&R^+cCoL>jIr5%U&U_!YLpuB_Sy%EUzKT6eFZA%ql4$A}22;q^_pS$H2(o z$dt-x&h&~wltIsdms<ejBvv*-0X6|{&>BN7DMnvz2GGs*3=I0(ceT$Mf$pt8%b;y& zAPl-BT^W4m5g(JNvYH*!9tmA#VMc9M0eNX@c>z{!Mqy=LrdI+A#!Bo`lET80QtV2` z3IYtw3<gX=j2_I145AE*47v<WptDCGI7oxG?}&lU{}odZmSFIK9M}!M@Em->psE0a zuabraEBIhw88I;dK{i%-ZAn#DZU<dPUv3A`@p9ad!~Z}`=n3Jh4Eo0Rv_WAcaL?#0 z#P!AkXSD@E1SHTveo=yY0d(IcJIGVwNN2{WBeC~L$qTY;Gm0qdNl54^i!f@l3o3|% z4v+)kJs<*vnY9JQ#n~0~<@ovK^cC2}#RXR)ohk=9VGevo0BCF(RO~|rJNHL|ODyOh zv4#d{Xn6-{6#()I2yo{aJdO!2Yaz=eK!dYf+ZY*CR76Aq5Guf>A;j!cxiGWA<JJ(v zQI<?VwYf9C1Q#q2gQp~e*DQccW?~9pWP+6p5C?$DDv0q@-~*{(<H4iUsG2~=|G&e) zz*Gw^CNw~$GlTPj9SoY_BS0LuL7T@A2mCs4fmVd`fXfe1NdVo3auI5PIz#{e#Q!@O z*%&k`85j(9GBAM72nL<@!QjBd!vH#s@PGsO+874#>9Gt*20=EofShs}>=bSCodPld z;S_E1oWc<G|2y*xrmYNW4B89~44|2a9pHOUc$n-NlbAUf1Q|pb`9Q0_!I#U4g6<j- z0bg$T!$DG*!B<>Z$N^MZ2!YmZ`3QlFJ0b8Dh>~Dc;7icJy2QZz7Y;n2yAef@3Mwnq zf(l%!fv%`y2A!t~K8c(ebhi_;02dE~4>RaIM`i{wAz?ugQBKe;ERx_$QVwhZU40EM z&a{z=Gth0F(Bcequ01oTOlAh%-N+0&UlLKIX@gI7JZ2<t0Cb5v=%{Q^4G$^azz1n- zgHFdZf)v&m<(w+Jx;YO@S;uIt(P)WL;K|A^R%c*h5NEPy41)wM!wm-)Aqi121|K2t zwa?6;n-G{mXReD2Sx7SY3MqiRB_s?EV_^p_J_a9QPzVT%h=L1tQKWEGM-4{>5itfI zNXUU>g_%K2R76l1PuVX4x@UnIbQ3JJ><3*u4L+D05m2CTLJlTK5M9$Y0*8;b_Bqf= zrl8d?7=dIi&aN(w5=gHt8#OS(=oIL*Qziy`CVNIp=0XMm1~rEJ4jRlFpm1OYUFN~8 zAj#m%Yyjq4fNp0N09`R4zzRBFof~xZ0%#8;H}s%UZqNy&Vhp|@3D62dusAQ6|G>cj zROPX9D>5*MYbYu?aPc$vC^c|0_|_}+E6rD8RuU6emvPY5WAu>$7w<CQO$H!7h~Xdr zD(3~2#5f#4;m*MfIuIDVpjm)HU)%VO_OWA-lAlrF-rakJ+S+%|30dmH?orj&HU`yV zpo$cHiJ!TeDYTg9V`4YQs1FK6<yfV}Bt)ep6qUKmuv7v}F?_PT0@6a#($ZpT@?va% z&})GI|NsB||DEAIxOfGxNn=dh0q#C9{=e<ODFixKfsqZgxf-;54Rkc~PbN_J0j!jv zYX`I><`ZDy3P7ksJGGc)FKMS1gAYk&X4CLTIw%?KTwgjIjEo*IEK`sIMnMRCI0fi1 zZ&1Lz`2U@efjJR&Of$>W9SjUQ4xEr9nVCUbv~D{H8fdbB4r8|PXJXUwXJiBE)rNQr z+@xe;U|{$GZUTc3O=nE}e-P3HW@O|LV_*zmWMF~pEcySFfq{{U8Po&@t7PaxsAOS7 zQ3+axFAlCybdW0)7I4MH&)@?&*&Vb+7*e;fAXP~qNBzIZz`zJH08ydnkW-;BFfqt6 z*)v9fk5?9DWZuaj^#21W<1vE{-34V_(DC!&ybH>qVho_;ls`C_fEuSlpvEZ+v|Qs9 zXJh~udJGPr%a9a6=dv?^u0mq~7iJ91OhTZ%4lX%&{C}~9o0q{yXvhBt;47AeK!<WW zfUj!d2j@<HPBA8C(0M6aKrKuV!@*bxbWj+m1mIy{6crKxn*_QxLr4L1a}TKTE2O~9 z;41{W^H<1#lfhSr1=ax8);2z11Sts_1+LxI23@(NeGgm&YHJ&v6F8%71S<x$wHevf z&DlY>pFxiV23_OL7@^T<87-<IFDxf1AtfxpsUpr4BfD7r6rZx1x{#E-oQQxVtFXEd z0~3P;lRaZHBsdvfI9M@&N<RkBz2*$yD@_<cH5@~L2!k&J=)x050}%#aaRx>UVFq7D zaCN~5sxBCrn8CpXI@b(zI}!MPYfx}G@Pa)C4RkEQ#>fB)Ekv-HAcBpVNmK|_Qwf3V z4M?yFfZ|CARLcs1YFWJDc2-;aj<yk~u7w1h(J}bN=b*!{F~UsCvQfhrCCtD_W6NTO z8FJ<EZ6^co{}-S$zIj0@jvss_8~E}s(3NZs(xCmWT%eRE2(Cw1IY6}uE9jnhP@S<w zOoG7&+!rwhT@%kOV8O}YD<D8lePM`NUkLDmuR7!fU1P=zy3ChX0dylNFDSY3f|46A zs5b$wP#75W!6{5zTiXany`pVt32Lx_>J(7rtF0}JNO7P9r;bsjeBxG8l#mvc5R+n+ z6ZOGXrP#BH$*YM;OG^t$3-HSF-N&p_Ap1DMRSJ0ZFk@l@w4RWX;sqV`F9j*cpz1*N z30NINR{{gWWzgA2kj>Jdg*o6I$e_de!3@wbIA8|ou$3*~TZutkRuBWM2E+iX0Wm;o zK)2a|i~-Rg6(Aa<07Qf3L3bX5M$N?-e8KXd3*4Z5kUaPZ1&{$?21o^%0W!jYAJn2? zWRQ|UZ0dysGAPKAFsLAfcolKXJ<DF=kGYpu10Qp5;*WI1KBV3NTLPX|gsj&F-4g%~ zNoFdaq7S-CNEAFfeHgs(W-@FT=#K-ZiaslE023E%7zkbI>0EGgg6aP^2Tl!Buu>J2 z=m&M!z>a2_0!t=3a^N5Z9kT=)@_~2`RJ%gdO-*26&~T83TnE6+#K>S{3JxI`e<n5y zf9MfVkPrfgJ!r^CA6&D7Z{}c3WJrY8tPG;cEIa{>EP_xMK~#cjR<KHjF0e{#@ZR7p zphN#b*FAt4ptjx?5fO0ha=?Kf#A0L=6h@c^P70v70e3L>GVNthXRu-D0|g(ri^;uJ z5L(%S+z6^@nVy3cK{}D_3^oj)9+$L(5VZH`0P5!;^%=q1KqpvcgO>*zgHNsj&(MNS zums<X13TN17j&2;XygQZf~6ejL`*pc2?k#|2b2>r(NFPb*zUk94L%!RT0mHg!ABBw zH4>LNgRdlL;767RbSEX~a7~cO+=3DeK0HF8i;O^F1UixgRFP~0bxnjM!6*MiE|7n) zm6O3&N*Z+bzk@U=2T6dgZIn;|6}S>?;1l=R7-T>?4nR)am*SU};1p%%XTWyizL5l| zngt)e4`LkwkD6%<i-1nphcum8k=W|u?CMdVp%4&eQ~;46d^%J!3FDM~k0law&;w-j zS_2wiYrw$BV8i6j$id9Yz|4^5AP64IWCC^Xm^46bLD0?3pgJ3LM;z#uEe23eUkrRJ z<^=~X(B<IZO`43LTT&$%d_fmkIB-gU+C0n*4NMIBQfC?UrJ%QJfHSDJwzjZ1yL#3< z#nsHLGt|1lTTehMaaftum?4Wm!249-D>OjMY1|w@3vs}sV9486K%+^Jbv)ovFvu<! z$od_wZHy{THp-wosIb@o-rNGW0W?^p0<i*RuM5<8(2ZrFA`@c#6!6|@0S9hI25(!C ztN87pMHzUZ3Ahf1cmu2w)e`W!9#oAWOa9;a|B+cAwCsbyD4l_k4P0E=?PSmfZQ~VW z@BtmSv4vBR!3T8S2ABapa6=Fzqya9|z(;LB4x|BxGPtO^`2Qm_187nkK1^n0!N4Hm zz{Ai09vuVg1P!Z!Ro`J?V73Cefq4^Xu_nw7#!xpHfvSCwg`j<`U^jp^vO0j>U;s9W zk-^YV2eM@w$qft)%sj|$VBUn=4Gb3lzcYV;xB)T-#w-9H)GuIiXS4w)(P9Sy$hZTj z<;^SrxoG;uW)4Ww1JOt+_5tW#CWamVcQ{Cc?p_C9KnS^OTmUpC1G$VGbWh7x(0z-{ zj12l`8TGZHsTH&x1YF&NI}VICs}<)lUFuew!NAB+#$?aPz|6rQ$gq4T1K<A-pyl~| zpw5q|ID-!#=xSTgRq3Est}yiWUtTT-Up@vw@KBK;xEX5-Dv7y4eJXAZP@jn#EXU0% z#0MVh<^xX?@PV!|<pW)x!Uw7V_`v5|fqG6Lh6A?{gD(eox`2a$K_4_30GSqG6u5U5 z<nl9+Mm?x8kJJif12sdRqBP%`?9rO;;B7E&Oz`^(VB26=CNWH5U|>uhy(t3JV#T!$ z2DD-wa*7GFikSguh75LL1Za&IIB$TW2OkDCV;H#pqecOEKMX7iwnNPZAI&p}j-dy; z2Xyx{G=xB97bt&#iY^chD!D*3-S6xmbz2O$V7~O<4RMDAY(+H7Bt{O<l2Hd~<Vyxv zRIE%mgaeSSlmNvxWSZgre|N;C5)5iA;00(blNf$7FfzP@+%&)qJ>qk)pWH9U&ESjO z+ps$(z~07@%E2`aC{W?IOfW!KRI^NCxWK@`@EjZ(TR?47&|y?y2Iw>@FavZP6__!o z5}6qG5Fr>0kN>+NuAP7_{brd2I^Kk#fU-R^=#j+C!~nit0h&g&O(18tz#|D9Ew}%> zA?{FsE&gJe#4w40fkDiHpGC#n6_UPTcPfB$2uN2wXpIts3St`#%Or+5cy(R-@5Tf_ zz#Fvq8nQf=WfH?%2JlTvJpaMR#DmAYKs$;-3<uCu5!!74D(Z^ti~;bizMyjR|06WR z!Cf?PLkwiNEqJC8d>9|dP{d^elAu93B8<QE-;EjRuyCYOVKoB-LlrpYz~?c6jR(y} zg7!CoPq2iD@WPJ!C-UwAF;GJs`E-00ZB?kJ;V}rB{Qv*||NZ}N$Y+NmiW){xe569% zM#@zLN}PDEBEaGha14Ng2<a$sL>^}Z-NfY#at7oKQPA1i;HZEE5$t9HG0-$H9v_1( zdHmmv8R=wkL=Ixyz`(#z3$=v07Zr$sRwv+b7^F@IJMZ>?H{^rH5gCdxj)8$80yOAG z$${ezT%ZP|ij5g4CBTyb4x7PyDOe`a+-6X=hMxh2lC7UGfOnEYPB>zOoCU?C;^V3U z>a>80N^lYM=)W7{P$&j<7I@BOWJlExUjB}$AC&HpPKiRwnnxKJ7+Jt)0D(txLF;tE z4A25PFavZX4445r69&ux9RvhsScAJhe9!|cL6eN2WprRE&^kIW19Tb?m;pKt2+RPT z4FhI?4g>--Kqmr$8IZmdAA=91F9pANmXE;)bTSKA4QQn?m;pMQ1<U{+3?vV-9_?fx z@M%%ZDgmC*z$A1;5U7wqIyMTakk|$Zr6Io{9vn(&2MB?Wk0K$IKxql-94VwStcHOB z)M^__E92!rYt_LcHIN+0#K-_XPl|-_02Q}L2TLIpx6>ivF_f0bgTezXeSi;`A|X6L z<vaYODWuZf2(maEbZ_J65l5sQaU_Iv;uNz=LKLh#gI8~m>J=vmul?XhPcf*m!0JAK z1_s8)oeWlxBM(6p0BAZKRQ`i#Q1K6<L8U*41{MAw8dUa!=+P6w{vU9lG=3nf=OM!y zTVd;oAp;r=T-%sbROC3I1GZ8B-I$`7Kx(u>mq@crVpzw(zyKQ573BvtctDd>7XRIt zt}q>8Qe)l(RRcP00Hnss7@Tq#7#U(17?@0%wlb(PPITZ_22JtGf@aFaL96G47<|P+ zV|0Sx^>9BNIK>%!1tr87d<8&b+>&7W2WSLQ0HjJ3%>S@ikinN9x`cusWQrJs@4zX_ z;L8cJfCDsV16sez0WymnWF3=+0E4eMXatWFx(tdFY9t5L2<9FCFF1(uG589xv$2SZ zu(N<x6R;$U3W<WwXm#Kg5MuBVRRGPgithNo0d$6%C}^0G2kdn4&6Hqu)|?E!A)<_; z3W@Uapat{V^4{`H@(u#R9`d{*!Vdi69>RRQ>Qade46HnAYzjOgY{IFmtURi!45{D= zRv{h+UlA!r1}R1+DR~}#u2dct2K%>0_IKlBW3}HJ3kq54zcn(pXVe!sXJjPsR^Zyv zE80f|jkRO71>PFRLh_}cvA|nxBY|2bmLo?5jzH&!5QC`@MIchx*vQOW(Ns~7QJc|J zkzHAiQQVkanU7gnj#*q;k6Aq?G`5K2-xWq}4iEDsj5oPfC@QnMbK9_Jc_iCPI=ebb z1jx9$J4-UA7&@9dnwXjj*{Yf9J8B6@xCe%+s)q-8h%>M=$o~Jx^pWW(gD`_UgEoUb zLj*%HLp_6t1D9rOcohpPKQk*QgT1|wxTT>1qX-|9vXUN?sfisEXaO**5}T-qn6Z(W zsfik+iJCHut7>9quFl8EF2^V;0v=2?*JCsnHv?%AH`ZfR2dy60V^Y^+R90eUmtzF^ z(vI2KNKD+0(H!JGB{gBNnGge+K3XKT_;|M@Sz0Ewc>A;@S^S+REF&W<A}h-{L0&*i zT2@#@=HD?zSv5I5E^dB)ZZ17JHCaVNMHvMdLp5<GH)cg?b}m*PabX@t0cn0iZbeaj zNq%VoMjl~t9#$@PX+>r?CUG@G89~|A0`gK~LUOW<QQj?ymX?Vv-af5K78XgZ(y}5V z($XR#vj6^wi^+<JO36Q0wH2`uw^!$o5#*QQRI`(?6S941!zZGwC#~it#mLCPFUrHp z{BN}tHy1NAuP8qUBcqg?ri_8I2s@*$gqWbBq=c}{|Njgs|G%*1GIlViGv+fe`~w}3 zFUXh<8arbwVTuLw`<RdXJHo)un9r*H?+61QgC_$6b34;k24)6!25ts^24T?c<_z)- z%HSKD^cjp9%-McAbg@aRNt;Q#NvBD-Nwe9qOEYq^NvTPhNx4a-NwrC_*|JM9a<U1F z39AXG3A3;Xi3zC*r3tY}vNB4tFnTgZGBSBGL^3dOif4*5af)P$FtPEO@ul%Gv+<ho zrtva!a$9mUadKF4Fxk#BTxH0VX;^94X~=Boz{TgmWyol#-(X_xp<l1jufe3TgYm#- z9eodt9gGbQ${N}pvouy|FlA~~YIJHaYk)OqFlwlPG}No~t1zkTU_9WUsIBk8t0Jqy z^jC#(mdYv>rc9Mel};6A6|j00MisLi{~H`QO{_iG%^1y0jX*peUSSV$BXuKlBW5G* z9sd{{gmeu(47C{*v>6$-1+|$zYBOrX)oH71n`<*`tMB;7uvyp8Lw(0Ths_!~9_k<k z|3L<+tE-!<GpoyRC@^v=DCvv#i!yPFGFtbG&KLbJ${Z)kxL5S8=wDIhXi-KVQATM| z##N%bM42i@8978HM47lmIfWUmh532xdGdLfZT)%rdG_<X=V6iKVO-1emWQdDhtY?J zk(-Bc70)dmCQF`79wrVDU!NnMgNc)a(fTC^V?W1!4kkGc#<v`d)f|jI9E__tZgDVi za9D6KadUtok%?W3(O627K|Dd6$u>Z|L41Pv2XU5%;vdDCHi<I^i!+LdyNEMw6=y6H zUnb7PE-o(4#3jzaZ@{0x&urVkzk&Y&KeHgeBR|tae#RpHMf^-G{4V@VSNI)l8JF@i zrtvef^PBTCaq}~98E`S#PT)Gg#iYo^_>qgTiHk9Wi}48;<5n(4H!enTE=Dc}7RzVM zjLd8TlJcM+<TP>hkXO<%wJ-%mgr%XiN1`dCgK2;%lcK4iDU*OHBcmzfL(`9@Oq)y( znO-two@~0<l&Q&-G1;`(lqtlN(b<%7swtx>$SfXSArE#_aZ`0uW>cjd{}~*FC1gFe zDjik2s>HlZiBZY;t`4Kl4n_wD9tAZIZ=GlzCL6E-gM*m3jK?aST{@?9m~|TXggtZ| zxOhEu<aaPJY&UZCkT*0`Gugq!u-U-c!vva0O+blMS6SPm#-zuDDcgiG#)Q$^gwdpd zU)aM0l=e)dm9>?vm6??fh)8)TYic{`?)cZRSw-DLcgMd2o8^=}ba(t`*eoRrq8&C1 zN`mMH2TpDQ4|ZKfE?qfLaI7+N^^j9hb5xLYkPMJakZh1#Aju{!$;cqdXnjEPfh5yL zNk)h$Goz%Sq@tvwB+Eld#zm5cB$=8e8H*$tlO-8hBvm9`B$<~=9+kW*`Baj{Owvs< zO|ngrg<Vozl8IYVfsK)!jnU>B8)E|-V>uh+LpDZdHpZo3Q69ET@tNXG9O8_|;_?Fe z0`>y+0xTQ?j5g~9-U~3z6JYcg=oet(6<`$D!I<FiU(3@+AVwfZV2!{V0ak7S#?u0f zs|0olFi8q%3S<f}a|keU3-B|`Gc!5fU}o%Rp3ltmmznV$Gouf43^UViX2w;_jGWAp z%uM{u;-ZYAqFnqE`V#&U@e=tG{SxaX*sb?VoR@en!Msj_(H|_v%qJlyp(nxIE3sDM zti)Rh79WWmi5dxJZV7D(rrQ#)C78M-R!N+aVD^;Alwh)uVAPag<d9(Gmf&Y+w7bd9 z*vHOT!Or-Woza_}aTPlw2RkDVdm1kzJ1?U#FTWFmpn#x)V1givZG+$h!3Ba31X&mb z9R-;l3Vsx1S|rFAESN0F#3JY-$doF$RB)@{Q3oqQ7I8s!K_)gqMs7g{CIKcU#|KOw zn3$F`F$OR(Ix;bCVq#p(#K-`aVoYEvU}9ooTF=4A!R5$cf40adJ~qBEzOb;UsHi9| z)+jDEHny-3#Epx!KN}Yd76EZ#k`Q@_Y9UMgSYxOTkZv$Lwy;neA{~pSA8Z!bbg+R% zAf5JSjkJy8AZ-s2yU<8m8)Uz>wziO^zOlBkwsx#`VWD>6S)<rkZKGIisD7|}i?kU9 z?it-N(gqPl=gt}##loBi^$JKe$YltB#l;FBoCPuk>PL{uLa0)NYLFX2>Wz#*PBe<u z2I&AfL*OjLHL-<7+C?CN*jR1tA|nXP(0~Etx7gTNMv#MIW8<KZQP5bBS<qOJ(Ns|s zj0KGaML}$+I8M1NSy|bC>lsb{onbWjw_X-Z%m#_AV>JDDn$h&%I#~#j&8YS7+P^Iz zlBrTw_HTx)EF)M<URL(sWsoqV4~WF8gn^I2>HinrgUlQZq6``g<_x|JsSLFYlNpvX zY-ZTcaFO9L!&gQ|#@Ra=y8b_K5abhJ@M-1{6k_m+R^S(4@CgC6k_8!jgQ2twsI4mi zKCbwNg9xY<YzvzCx8)FI@HN!nWAN1jHK&EZ^aW6FO&Y}K2j4dPz(ERRJS#{8D@eoY zlLxl^-1X@i$CP=K_AxRr)G@R%GB9*7R5LPc&P-2>aO7oVU<hOIV`N}RVen>TVDM#d zU}Ru$V$f$~+$3kN=E%v&z@Wh(%*enX%D~OYz`)7K<iNB2<@0l&uO7MohjZp)M+rs_ zhDnSp4DF083_Xm@42_Iz4Ec=A3}uXL47CiIj7$t^j4TW(jGPP+jGPQ{jLe$?1CkwO z8JQXU7+Dy67+Dyc7+DzH7?~NYL3^7RIT&;pnHiKB*%(Y1nHeM*Ss6qaSs1t&Ss3^j znHiWF*%<8K+S}W|1(9%u{oAt;5&JXZmW%?|{@!_e=j{Oy0mWwzyaloDodGp|K{tYc z8s%`T3Teig3K|Q78o_o<=7PrJc8s7_E@(@kvXYv*pt&Hpd20&Z{l~`6h)0W{vLIL$ z9}~NvGNZAmvZ#n06R2q{4%1|gO}i?dHn}*+U7*IfvJ#s*v`sE1Dgs*G0BVsNiHRGl zse_iKsjDd~v5T9DE2*=K85@~{JRxpouFS`1j?@lke5R1;Y7=B67Hi|quOKVN8hcmJ zRo5ciMqbq=!Nw_BA(qcr%{xj!#Zp7fL6A8OU3Ha0maAQmp-3#RoRKQOf~+`eEK}*x zqdXES5?aPwLVRwzrmk9ou}oXgH8Orxvhvr}^|Mk^vhvf_^|w;`H;r3Jh?_@HaI+8> zuYeF2AAh5WpqQ{Un}7hDw6K_<NQja?vnI1L2P>Zh6Th&Wq=<m9uz-l9oG?F=1RpDh zGP5SLz6O^NKOeV{Fh7TovO~BjqsqSz@j*<?%uI~i#knOkJW_1zQ{B{rIGJ7w{PkpE zPmb1bGnSR&`&Wvo9%O`lgz3LKi~<HOTEa}sER6p=nK=ADs2j`j2($RIi%OgMn=oDw z_?w2QSp(T;N>=^?f;`-Ug4{fUb9woNx%v2nOT|>URrplJxkN>{#Z~xJxK*-r6$})a zg!x%`l{r>&DD$%L3o|JiDClkjd5E8vM~H!)fr~McaVIkev|ne-;LniAkjo(9z^@!? zr70C}>LlWw!OhCg#Q;kE`1^KLPDeXw8l6lAv<Oc|1_qY5j7$uC3`-ce7?>CY81xw; z85md<*nTpwGB7egfI9;N12R6rz`*c;fq^N6fq|8sfq{KC0|Spd0|QSS0|U=>1_oX? z1_r)q3=Dh^7#R4~85sB@7#R2$GBEJ}U|<k*Wnd5l?e2+SU=Y2>z#w*)fkDEIfkDcg zfkA361A}}z1B0R`1A}5B1A}5E1B2o(1_mW(1_q@g3=Aqg3=A4O7#K7+Gcag3Gcf3y zF)-*AGBD^}W?<0QXJ9b6$-rP}$G~8i$G~7@#=u~#&cI-t$-rQ8m4U&upMk;r6a#}L zD+7bIAOnNVG6n{Fdj<ykZUzR2g$xXiSqu!$MGOos2N@V#iy0W)7#JAbTo@SK7BDcl zJ!W8VFJoYEpUJ@BA;rMpIe~$}Yc2zW_e%x_pFa!?{)`L^{<9ev0(BV}g18wNf)W@Q zg5?+(Lb@3kLIoHY!o?XF!tXOML~=7QL`g9)L~AiHL{Dd6h~3Y?5I2>9A>N09Awi9S zA!!o>L-JY%hSVtx3~A;J4Cz%24C(6_7&6Tn7_x3MFyy>vV92##V8~--V91kYV92v& zV949bz>puxz>q(cfuX2`fuU#~14A(r14GFi28NO+3=E}O3=E}C3=E}F3=E}P85l|* zGBA|!GBA|QW?(2^!N5>{nSr6gl!2idgwq%pszLZJ14C^%14CUQ14Dy014E-N14Cmq z14H`+28Iq628Qk%3=F+o3=F+W3=F*y3=F*)3=9*Z85kyPU|^WU$-pqFmVsgNbq0p1 zhZq>9g)=Zr*I{6o@qmG0#wP}bSy~JXvo<g=%&uo(m_40=VU7y}!<@AY40Amh80IrE zFwECyV3;4tz%aj=fnoky28Q_$85kBNGcYV($iT4dH3P$P2?mDcP7Dmo^BEYHFJ@p^ zeu05u`7Z{B6)Fr2D?%9<R^De|SjEY}u*!^qVO0VH!>TR@hE)d{7*=yKFs$Cnz_5M> z1H<|c3=A8j7#KF}XJFXynSo)WECa(P76yhb^BEYnoMK?u@`-_As}uvnRwo9At%VE> zTc<KG><nUH*!`7(VUGd>!yXR?hCKxg414A?Fzh+Sz_8~N1H)b^28O+!3=D@f7#I!( zFfbgdW?(q9n1SKYX$FQvpBWeqD={z}{=vX-LW6<fL@)!xiE0Lh6N?!bPF!MOIPr^t z;p7(vhEvK645xe<7*5S$U^sP*f#K9U28Ofd3=HRW85quoGccU5XJ9zLhJoSy6$XY2 zj0_AHG#MB!1T!#PsAgceu!4c%q80<gMJon|OC1ahm$ou6T)NA^aG8sN;j$3}!{vAe zhRf{?3|I0Q7_OaVV7T^+f#JFm1H*M628Qb;3=G%jF)&;|#=vm>0|Ubi2?mB6HVh0m zQWzL+^f55p*vY_fGlYTRRv81stpyAWw@xrH-1@}8a9fIj;kF9{!|fafhCA#G40ld5 zFx>gdz;IWYf#I$(1H)Z!28O%I3=H>-7#QwFF)-Y#XJELugn{AS7Y2s=EDQ|yO&A#N zw=*!@U&FxgK%ar(K_3IdgUbvI556-nJk(=gc;v*u@Hm};;qgQUhQ~V@7#=@hV0gmH z!0<$$f#FFw1H+Rx28Jga85o|uVqkdwhJoRQ7z4u#I|haq84L_BCNMC(ILN^8;u8bI zOL+!{m+lM<FLM|eUQT3Sc)5>(;pH<1hF1a%46iI07+xhYFudwzV0f3n!0_=b1H)%! z28J)^85q9&VPN>`%fRq;9RtI+ISdTnjxjKNd&j`=U4nt(y8{Em_bdj6?~@o9zVBdQ z_<o0h;Ri1R!w)+KhF|Oq48M~Z7=HIMF#O)m!0`Ja1H&Iq28KVz3=Dsw85sVwGBEtv zz`*e576ZdyRtAQ@%?u2GS1>UAJ<GuG_Y(udKXC?zfAbg^{{3cP_;16&@ZX1l;r~en zMg~y^Mn)C}M#c#Yj7&ugj7;+x7@5v6Ffx5(U}RQcU}O$tU}SD$U}Rp;z{q@yfsw_Y zfsqxoGygsVBZoW#Bj;TPMy~Y?jJz8d82LLH82M*2F!HZuVC3J=z{r1*fsy|)10(-e z21WsP21Wr%21WsG21Wr}21cPc21b#321b!942+^m42+_!42+@=85qS(85qSTGBAog zV_+0FVPF(*Vqg?M!N4e?!oVm|$iOIZjDb;7gn>~qiGfja1p}kxI|fE669z`92@H%< zPZ=1cjTjiE>lqlOZ!<8;XfQC!&1GPeKh40Xz|X*_aEgIZ;T8j<A{zsvq8J0Cq6Pz_ z;%^2<<tGe`D$NXxDl-`vRX;K?s&O(fswpxsYM3)HYWOoSYNRtTYSc3@YD{Nf)L75J zsBxZwQR6)WqsD&*MooSOMooDJMooPNMooJLMooVPM$LEzM$LQ%M$LK#M$LW(M$P#Q zjGF5i7&Z4ZFlwG>VAQ<Nz^M72fl>261EUr{1EZEa1EZEc1EZEB1EW?j1EW?d1EW?s z1EW?a1Ebb#21c#542)X)85p%LGB9dAW?<CjWnk1UWnk2P&A_N*%fP7P%fP4;%fP6U z%fP5p%fP79%fP5Jmw{1dEd!&@UIs>;vkZ(ncNrLU-ZC)i{AFO&<z-;hm1SVm)n#DR zwP#?|4QF7~&1YcLZDe57JHo(daGHV9@Hhjb(Jcl><6jJnrr8XPW?vZ?Erl2ut-diZ zTC*`Q+7vP{+Ey?y+D&3$blk|m=p@X*=;Fh`=w`#f=yryI(KC#J(fc$5qi;3?qn`@{ zqu*)<#()F{#vn!p#-PItjA6?e7$c+^7$XfC7$Y4S7^4m_FvheqFvhttFeaohFeX(p zFs8^cFs80&U`+eVz?eCofiZgy17r3Y2F6@=2F5&B2FAR142=2D85j$uFfbPGXJ9P4 z!N6EDpMkN=gMqQUje)UBjDfN09Rp+aT?WS5Ck%}B%NZCOCo(WLpJQNbz0APa?!ds< z@ri-4>jDE~PYMHLPYDBKPY(lQFDnCMuP6g!Z!QC4?`{UhJ}CyqJ|hOkJ}(Bwz9a_5 zzA6UBzDW#>eXAH4`>rrB_WfaC>{nx8?Du0}?5|*8>|emZ*nf(FvHueT;{+!L#tCf< zj1vPH7$?4CV4P&mz&L3c1LLIk42+XC7#OD<XJDKv&cHY|oPlxb0tUvZHyIeGF*7hu zGhkqx{*r-l<}n7wnI9M!XGt(H&T?g7oMXtqIPWk6<ANm&j0?^&FfRDUz_?I>fpMWX z1LMMC2F8Wm42%mGGcYdP&A_<uDFfpoUIxa+A`Fa63K$rd%w=F)a)N<z`DF&i<-Zsh zSEw*BuJB`ETv5TmxMBeV<BBT`j4Rm~7*`rFFs_VXU|iY5z_@ZB1LMlq42-LU85mc& zGcc~IWMEvin1ONCX$Hns{}~up8!|Aij%Hw7-ORwadOHK->X!_RYs45B*Vr*It|?+* zT(g9Mam_gf#x>s<7}q;7Fm7~aVBDC?z_@7z1LNjd42;{v7#O#|Vqn~PlYw#9VFt$C zD;XGfUuIz3{hNVtZy*EX{_PBm2iO@H54bZh9_VCXJaCAC@xU7f#)BdZj0a;G7!OWk zU_5w$f$@+q1LNVj42(x7GcX?e!oYaq5Ch|>GzP}A&lngld}d(0Je`5@@;e5`tK|%g zH$E{i-V$VBy#1bm@tz0+;{$&N#)okXj1Th|7#}@iV0^ltf$`a82FB;t7#LsHGBCcH z&A|9(1q0*TdIrY#Obm=4w=gh%p3K1bg`I)%t0)8GH)#gO@2(7tKYSP%e`YZ-{yNFP z`1=S0<DV7=#y@u$82=hFF#gM9VEjLWfr+7lfr()$0~5nd1|}v~1}3I_1|}9Z1|}9U z1|}9Y1}2tp1}2tt1}4^b3`}hA8JO5_Gca**FfehLGB9z(Gca)+V_@RA#=yk!jDd;c z8v_%kAp;ZV9R?=OHw;YN!3<2iw;7oDwlXmB-DhCpd(Xhce}RFC{{aIN{|5#p0nn(M z2LqEp1Ot;m1_P5o1p|}d4F)D*D+VTEF9s&zr3_3WWeiLr=NXtpZ5f!v)EJn=?HHKE zKQJ&!b}=wXt1vLhEMj1i*~Gvkw}OF5{sRM(f))dlA`=6Xq7Va<Qa=NeiaZ08ssRI& zY6Jt5S^@);dLILmral9cW(Nb4<_rcV&3g<?n(r8xH2*O$Y1uO{Y56lSX}@M*(*DiB zq!Y%#q|460q#Mt`q<e;eNpC9ylm1c$CWANzCc`xhOhzXen2g^uFqud(FqwEVFqv*; zU^2bSz-0b_fyw*>1Cs><1Cxb51Cxb41C!-J1}3Yc3`|y68JMj8GB8<dFfdsMFfdtf zW?-^YWni**XJB#&XJB%QXJB&v&%osRl7Y!xn1RXT8v~QK5(ATu7Xy>;90sNUM+T-~ zeFmoBA_k`5EeuQ{JPb@Bi407k(-@e-PBSn?q%$x@sW33bmNPIV$S^P^8Zj^>RWLB6 zgflRuu4Z6LyTZVf5zfGrS;xSXwU2=*TZ@4ydolx4_CE%uoO}kR+?xzc`O*wbh1Cp9 z#mgC(N}n?@Rd6yeRirU6RqSM7syNBOR3*p2RL#%8RO86NRHx0rRKJLUsX>H+sbMk$ zQ{w^#rlvLqrsjnVOf6LmOsyddOsy#lOzqDYm^$AuF!fY1F!kMMVCs9%z|=3vz|`-| zz%=1K1Jk4q2Bs-O3`|pxGcZl-Wnh|a%)m6mnt^Gi3Io$D4hE)K7a5r5yk=mU&%nSm zUyp%legp&4{00W5`CAy6=09d&n*W)BX#q0>(*gqqrUelUObap?m=;tpFfCZaz_j2V z1Jgo&2Bw7$3``447?>6=V_;gije%+5F$ShZ;tWiS>=~FA6)-R@n$N(r=mG=NqW=s` zi}@LtmN+mlEh%JRTC#wFX~`7^rlo8QOiRNVn3k?$U|P0~foa)O2BzhS3`{Gk8JJeA zVPINun}KPi4g=H5ItHfIRSZn4S2HlJEnr|;+rYrIaVrDUW_t#v&6^mQHXmYO+I)$D zY4alnrp=!in6@x6Fl`ZHVA`U@z_hiBfoW?O1Jl-73{2ZS7?`$4FfeV;U|`x|$-uP3 zlYwbRBm>iqOa`W%jtopY0~wfhCNePXEM#EX*~q}OtCN9g&wK`^eS!>32b39@4i+;o z9qD0UIyQ@e>BLS3rjrc}OsAa~m`)#IU^;z;f$8jd2Bx$38JNz#XJER}#=vx88UxdX zWeiN0)EStra4|4l{mQ^}Z6O2Gb$<q?o0berH$54cZe=qt-4SJAx|7Plbmt5M(_Ie+ zrn`q3nC^aIV7eE_z;tg11Jk{i3{3Z98JO<3GBDj=%fNL1DgzU!EB%m{f$3oZ1JlD9 z3``G?GcY}T&%pG^kAdk?1q0J#eFmn-+ZmXiYA`T8b7Ww8mczjGY#syCvkMGN&lwq* zo*OeTJ#S-Rdj6S#>18Sd)2r(YOs{zvnBH(PFui49V0w3$f$80I2B!Di3{3Bh8JOP3 zF)+RFWng;0pMmN9I|il?vJ6ZgvKg2@>}Fv4=)u7B@c;wUCou-5Ps<pXKAmG=`t*~5 z>9Ym{)8{A#rqA6BOrLi#Fn!@-VEU56!1QGk1JhR~2Bxq53{2nH8JNDsGBAC6%fR$K zoPp{4K?bJpZy1<<h%+$#@MK{6QO&^gV=V*IkNXTvza}v-{V8T(`g4$h>8~II)8Bav zO#j3fnEvH3F#Y?(!1O<jf$9GR2B!Z%7?>H98JHP@7?>Ga7?>GA6U0v#m>Go`m>HcI zm>JhFFf(Z}Ff;98U}l-lz|69rftlqM12d~612d~P12bz412gMt24>dZ49si^49slv z8JOAUF)(vzGca=$GB9(@W?<$x!NAP%lYyC2mw}lxj)9r8pMjb4Ap<j)8v`>}DFZXt z5(Z}Ooea!8=NOoI&oVIconc@W2xedwe9XWs#K*uaWX8ZOl*qs=)XBgsw1t6L=nez3 zFgpXYun_~Za4Z9}a4Q3|@CF8E;TsIhBCHI|BKi!>B3~GoMfDh%MGF|1Mb|Mfi@sxE z7BgUA7Msk#EY8QkEYZQhEXBmYEakw!EbYp`EaS|;EOUy1S>_u9v#b&Wvuq>-v+QIB zX4#7j%yPmE%yPjD%yJVMnB_h&Fv~|WFw3uJU{+vfU{;vJz^o|4z^qusz^o+1z^v55 zz^wF*fmzv*fmu19fmwMY1GDmX24)pY24<CN24<Ce49uz_49u$C49u#B7?{=WGcc?F zVPMu|W?<IhVqn&u$-t~@$iS={!@#WD&cLka!@#U}hJjhXmw{P-2LrSIR|aMSc?M<! zPX=a#G6rUYr3}mldl{Gw&N46?8Z$5(u`n<jRWL9c*D){~?_gjyzQw?7BFMmOV#&a4 zQoz7$vXOz=<TnGesSN|OX&VEx={yEzv!4vi7P1V?mIVyVma`d{Ee|s=Td6THTXixp zTWw)rw)(-qY^}t=Z0*CqY~9PiY<-=9*;b8#*)EEK+5RvCv;AuZW`}DG%#NlE%#KqS zm>u^sFgw0tV0IE>V0H>(V0NluV0OC5!0hymf!SG?f!Vp1f!U>xf!Xyx1G8HL1GC#c z24=TM49xDb49xEC49xCL49xB~7??fS7??d`7??e}7??d4F)(`?GBA6IGBA73U|{x@ zW?=SBVqo?wU|{wyXJ8IsXJ8JnWnc~{W?&Auz`z`+&%hjbnt?eeh=Dn1I|FlY7z1<g z1qS92F9zn2E(Ye1Qw+?Z9~hX!#Tb|))EJl}%@~-Y&M`2@<TEhGOlDw?+0MWmbDM!V z?hXTULNo(&LN5bzMkoVw&K3sdJkUXf3=9lc_Dnf-XKg&c%~u9V<`<x$ypvtqB0%(2 zBLVgQ$^UJ54>Ip#U|`^6V1kVGF@9k%0?9LgR&76FU|<wt2x9DD2x01F&}TAX@MgAW zux9FDFk<+?5W;B25X@-Cz{z-nL7mBj!Ghr%gCV07gABtr1{cQk|1X(L7_6C07_ym6 z7+jf57@V0*7>t-q7(AFv7<{2(PD~~Y_8|2jy-X$y<{)!GG-C~e2=f{S52i*2E~YYu zD5gXPJEnaMQB2wlQA`{RQB0E<?3lJNL^1qeh+=%sz`+#G5XJb7A&NPX!ISAILliR` zgB?=<l+I#^Vq#@bVp3rcVp_ov&Q!p_#XOTig(-nSn#q!Zi^+gNjnRxD1>|N%GX@SO z69zlRSO0%ARxsEw{$(&=a%Tu*Ji}nl<j!Euq{3j%Sium+WXoXAWXTZ5@Rq@i;qCu} zjPDu382&SaGRHH7fy9~I!0N+5dKk+X!Wh>uXfQT21Tk4M1Ta2e5MgF#5NG_%Aj5cz zfsyezgBqhfgEk`@Lkgok*nUtr=`uWH&|s|ie~__*L4)}TLmuN*1}!kIV6b3Wz+esX z6QeML3iCY%M}}t%USRbd3|=7hj2R3zj3560V}A1gA7cfB9Mee#5wIJC8T6PpfZfgo ziepgxLgUCE8us#FcRXUyXRKhbV76lLVr*j2V?4s30+I*CFIcRC!Gh6_K^82Q&Y;bh z&Y;U!!BD_t&k)9x!Jr3<Q^pF0B*t_GTgI(m{d&w<47O0L%UHu82aa1%{Gwsz$qa1Z z_y)x_5@z`E|1-l625W{N|9^ro(-{T{SX_hR8yPd1K;s@1-^dsg-+>HKOd$+mpt!|` znZp<i!11jDiEmJxgD}I}|8E)IGMF;F{r?$+nVi7k7zU1OP<$g}69x;gxFtgXD9&LR z6yM<Vbc=xz<bRO;7<3|o9oT+cG$<dWGnj+@4@#4uFao)i5Dm&Rpmd9i-og;YWXWK~ zbb&#F$&!JM;m7~`3~&G61cf!ozYISZm>Ayv|HJTufuG?m12@AD249A^kUYS2pCO9z z7K0loFM{#|I6uB;2xD5x5XJb5A&l`GgFnN61{Y8sW%$pa2BH~x7(&6cDMKg|sKolu zzzd2`aQ-<8c1swH&n*1^Ka(YcESwL@-);=%pg0GGGmK`+Vu%9eHz>^urNh8^WetM{ z2Cc#%1kRV}G$_r0;*LlfRGxtHG^jiQ$1$i(InJO4E?bajhVKjrw?OR#g%@K5Lk%du zL9rua1w#*G1w#yD1%p3h1%o8ad~_OSAH$UY$&Bd?Dvaq2ij3(DdQhy(n9iWZn9iU9 zm9u4p<yD9}Lkx9`7?_!tFgP+NG1xLEF=#_+UFIYPJ?10^5DgOtu@#~6AUSk#kQfNV z#6fC7e2_jZCJzQ9rZNUDrd144Oxz4o%sdPhpt1zy4^Y{`q|Ct0WWylGn9RThtv5jO zp!xw+2CFcAU<hMMh2nCCFs5>bFy^}qVa%X3XTo63n8_f?_<})^@hgKOlPH5S<3|Q1 zCRqks#;XjHOiB#aOtK7?M286^TnUF=1;bo$nODIug%QMt)sIpPGN8HzRG%T&GvG4c zo`C~YhNG0%!VG$#av4<SG9F>j1DBQV3~Wp%7(|#-7@|Ps2a`L4I8zjZ98(N~EK@b8 zzJino%mQFFpgPN($%et4Nt;2PX&ZwLlLmu16UYDOOw$?cm_YSk2}2lT7DEK134;Vv z7ef@N{$gZd5M+GAAk2J}A)M*x|L05=3}K*r1;R|;4Cc(Q80?tk8O)g$Gl+oQ0I641 zK=lff27?--_WuLmykyKE0;WOr?N)|xCJ%-HaQv=h2xsbJ;9}BbV1(xjMFtrrB?d#r z(+rx7M;LgSco}q<q#2YMUoogL9%L|PGGhn>*GKypQkX0mj6mVS@{S>jrIjIyg^$6U z`6ojZ(_@Ax#y1S1ENd8|m^U#*F{?3z!OB!v*#-6ksC*_eKQpN?Si{VK(V%(>R5vhf zVz2{+2e=IaDo;UekM|6^Fgsv0lOlr*<1q#XQ2s`CGfW?gI71Y;Z38Ro8X34i^#RNt zxH(X9m^+xX83I7{5x6a+!eGwu<Ns=gxBr*I%4e897|oQ$;LljbAOtT9Az|PSZVQ3R zI35OJ#)S;Lp!O7#C4(;042CGCLk#9jeGJA-J`7gyxPrL@TF=1D$3?@+CdSnN@0fNm zgfX`Kf5*I#!5oA^eqkzRuw%T*V9vzCAi?C!AjG(aL6PwogBs&a264t|3_MIN3=&Mc z7$iXHmC1r33gl<T9}FCf-2X2!i!#`O+Ez>z3=)iW41!F)3?hub7(_wg%~-(zDnDfz zD;Sa)D;TsGD;Vq;D;QLfusSrKx-wQUu)#1mJVAcD!XU)>2-@akWl#dOF+u4FRJSqz zX5eFCXNY3D$sh!(<G}TiI)f0?T81dFpFsJuj3ElFe<gzu$V_H`1~DdW26?brP+W&I zL@}*k-~+c+w?Nw|ApHSQJ3;wIok55>hQSWpM%@Y)cVRxp5XF3sA&TiRgAlVBxD68p z%Kyw?7^0Zc7}P;&hsmA6o=J%zjIoy?jLD9H9~5Rx*BOKu?=q+{`7nfmFsMFea$(?O zy3F7O&c~p9Va)()tAg9PjGGwv!FAXt1~X6_72Kv{V=!P=We8)|WC&w&W)NVy#}LNE zz!1iGmqC`9k0Fdngh38$7pNU#$-oA(pYbk(1*mL*wvj<?ri09W41COi;5ITF*d0C$ zVPLyJ_JiDUhCztQm4P3YrkEZua4~5xh%lXHh++z02xCfOkYM6w5CpaDL2d_yGuW;V z3=)ie41!FN3{gy?3}MU(3}Q@A!Qn3t_In9K6q6uB7}GR{C?;QUy&=iO#URRbj6sOW zmO+4N4?`4_H$xb+GD8$o7eg5HM}{coaE35uCI&mEAO>?#dlcLz1J&7^82G_y`vHRp zoX%p9X8gh+#%#qP#59G0pDB~Ug^7#7fT@tdg^7*9fGG#ko?+Hw5Mk<P5QEX6v;|Hd z*BOME!Wg2Mgc+ikHZbsk;|J6pb7u&Hxs53iVit2IgD2B91}{+C3zX)-@ea+Ku(AV` z?!fT@D*HiY8I*?GrOf~d7ibv;PMe_g0ZN}fka8cnT?LPi7zSBToHMC22!Z1borby* z7BAp*dXFKD#gicl)E8kgVbBHl?<yFKKyC+>e;~Eswg4#a88E1U${~<_ps)ewF;IO4 z?bk7>GpK{)!1)c7Uo;rh!2LQ)1}<2BVOC;r0oONS4CYMX3}K8n7=%H7Vf@5k!ML2k z0#vUt1u<AN{ACCRr9n`g2TJ3hei`Fw21vUdlpcK;0vHc6m@~yQm@}?n5Mi=l2w?gK z%@aQu0+^i{?7)7=`v08i$N%SyHvgY9{9s^VmjC~pY5V`@ptQlP`u{mo7=t;;ZjikU ze;Ldf{{H{K@b~{Vkek3}g3Psmn8#SbumsW;{QnQ64=;xGo4)?P#Psa{eWtztFEQQt z|Cp)%|81tu|F=PQV#7>X|F1D+{eQ%)@&6LD<p2B3LjNx@>;8WXavxs&`TsSh&;K7W zZ~T9WdC~v-%(MSrV&3-uF>}iQ+aS#Fm%)hPFM|%l-~T5W{{G+1@b~{`hQI$WGyMJk z9fTRp|6gM?|Nn^b>;FrP&;H+My#N0a<FEgZ8CU$j4Z`5?0F?n~m|5WeHD;6lkC;FG zzr_6b|9$3_|1U8g0lPN<Is7o#i~c`icKd&yx#s_4W`qB?LGgtbi~YaGZ2A8Y^Y{Ok zm|y(A&%EycCFYY*|26)<4T}p{n8Lz};qU)fAiu-ij})IE`(XNT(aa4DTwwg=|0U+9 z|L-&J`G1M|`v1qwb^mWOcl^H%vj-Q={N?{O<}d#rv26N(iDmKs`z&+*Ut-z*|1nGI z|Jxu8asxihBL4pxi`D-}EI<BVVtM)hKFj+5msn0g^*15>jn)0IG+6)tDN{WI8^|Aw z6$}-O6%5@_8dMh>V0HVF|96;{{J+So`~MEJ?*EI-TN$DlD;RhfD;UHXD;Vq<{{H_7 z%PXM#0_DE|w{w2{|H)YR{|;l}|BImU4MtEqpn}03BMta41TcXxG)_Qy5{!@kzr*zL z|3#+$|Bo0eKw~5z%wU5xoO1u)VYd5!k-6yq9cGvR7g=~2EI?x&j1>%J;QS8?0~~pf zi9nuVCY0ttW`o)Ykp58&GarL2+av}R=8FuTyxSR^7!NaWfky6_m;85Tkm23_Kly(O z??I3VcoYvxu)JkpX7Xj!VPIuoVA{jf#lXPO1EE26G{Y|j1}1K17G^dkW+s*g3{3w6 z7!;zRS{Ym%eH|Gb7#M!DFmwEOXRu&i!mw9?fnk0aXj=d)(^dus1_cIY21W)}23AH8 zU}a-tWMyUsF~Q3RRan+>TJRnc@?kj0ae{$^f$RTY22KW^|9=^{z$7;V@BhCHJYbTS zf&c#>20jLX|9=?x83h0TVGv*t`u~SP5KIa&2><`XAj}~0{||!*m=t9Y{r`tSj6v-G z9|my-@&CUWBp4+B|7MT`lTr*)|9>+`Gf4mc#UKMFWf^4u|6-71ko*6WL7qYW|4#-5 zFsaC(@c$=+5`*IZpA5<jO8<W{s4yu1|H+`rpz{AGgBpYC{~rwM3~K*>FlaES|NqXQ z$)NH72ZI)a=KmiI+6-F%zcc7CX#fAtpv$21|2u;onAB&``~RK6fI<KNHwHrnga6+c zj2I06e`7FaF#7+E!30d2G8q5=%3#J|^8YJ?IfLo{uM8Go(vrda{}%=;28;h+7_1pA z|9@q$VX*rDg~1j~+A&!F|IA>|VEg|Ig9C%z|IZAL4EFy&GdM9g{Qu103?^L|oc@1e zaAk1*|B1nk!R7xa26qOx|DPB<z@#UG`~Qy&UJRc9KQefONgoEU{~sBA!K5F9_y3O! z{tUkVKQII^`2GLL5Xj*7{{ur1ga7~c48aTm|KBr&Fa-X8&k)KG^dB_x2_nN8LjJ#J zh+qi)|DGX|A?*J<hA1!@%@F?o9YYL5<o|aJu?&&_-!jB8ME`%s5YG_v|1CoTL+t;z z42fVei6QR)8-`?t`2TMhQWz5dzhOuPlW7b||KBjAGbI0i&5*&6^8XD(CPV7~Hw;+} zY5!j{WHY4yf6b7?kn#UDLoP$+|5pro3|aqQG2}C3|9{0$z>xF*B|{-Y?*CT|MGU$B zUosRk<o$oiP{NS^|0P2yL*f6I3}p<(|6ekcgUJeplK(FlD#2tGL+Sq)4Al%}|6efF zFqHp)&QQxx`TsdX9hj_VsQUk$p@E_1|1*Y0hMND+8JZYs|371BW~lrBjG=|0{{J(E zR)&WE&luVm8vj3IXlH2p|BRu7q51zahE9g&|4%{JDgA%S(9O{L|0zQcL;L@y4805; z|DQ1QF?9Za!q5*UCopvVf5I@4q5J=1hDi)P|DP~SX6XI@m|+S--~Y!9QyKdIKW3Q5 zFya4WhUp9w|36}w!7%CnBZip_lm9<rn8h&V|09Ok3{(F<WSGM+?f)Z&xeU|)KV+E4 zFysG2hWQLL|36?@z%cXwLxzP6v;RL}Si~^r{{x1_U~&n=-2V?4mNLxyf1hC)!-D_! z8J06F{C}Te1(;mPu;~ANhE)tp{@-U<4JOwxEd76vVJ*Y5|MwZzF)aUok6}H-ivRZ* zHZZLGf0tn+!>a%H7&bAi{(qNYGsBwycNw-Yto?tNVJn#2#<1@H9fs`;8~)#6*a0SY zGHm*PhhZ1PrvJAYb~9}Le}`cY!<PTI8TK-4{ePQbAH%l)w;1*_Z2y0o;Q+&q|F;+p zGVJ_+i{TK%?*F$K4m0fee~aM=!`}Zl8IFR<V+{NL-()z>u>b!}h7$}2{@;L>L>&MB zjg~~<lISbLXh}3$5{;Hb162|Y{Wj4LDz%A5OQO*>(P*3KF2g{TM4)~UC%C`E1@7H& zgZnZ(;GPRFxSzrY?v?O^dmjSezJ?&UPay>EM+k#^4I<z^f+)BzAO>#vi-TME65#f{ zB)APP1#YoRgIne@;C8qyxYaEOZe7cRTht2R_Ol{G%m1egO5paeGPn(_0&elDg4?%h z;MS}<xYeowZk=j^Tcldx_NO+uwW$McRqBG<kb2-2qdvH0XaH^v8ZsRIe}ln@;l%$N z48{y+|6gM;VYu-BDuXG*mH$^5%ouL`zrtY7aQpvd1`CG!|1U9EGCci%k->`L#s3Qo z)(mg{Utq9d`1t=kgDu0C|K}L&7{32M%V5v&=l@yI_Tm3$7#tZ{{-0)WV&wRLiouzY z=l@9t7e;~qCm38AMgE^)aATDCf1JUcQRe?K1`kHX|3?`-8I}JZVen#9|9^zRn^EWg zVFn*Yga3yZd>KvtA7t=jH2;5q!JpCk{{e;oM*IK!83Gwy{_kT5V)XdGmm!$Z=l@=Y z5XON2dl*6)L;mk(2xE-+zl$N9G3Ngch6u)l|2r5W8I%9-V2EPO_`jVYnlbzTHij6+ z{Qp}SVi`;RZ()dIEdRfmA)c}3|0ae6#`^yo84?*={%>SRVr>7vfgzc(=l^<!6vqDl z>ljiQr~F^bkj6OU{~CsL#@YW@Gh{H%|G%0clX1!aRSa2-EB>!!$Yxyqe+5Gh<NE(A z7;+gm|6k6K$GH9fGKPG{J^z<76fhq6zl5QX@yP!r3`LB`|1V}JW<33W5km>%`Tq+U zN*S;GU&v6#c>VtZhH}Q+|K~GQFh2M{kD-$B@&CCDRgBO7&ta%$eEok8Lk;7H|Faot z89)D@#Zbri{r^mcddA=XXD~D{{{KINp^=H@|8#~XCXWBp7@C=Q{!e9SVdDQkg`t&6 z`2Q4!HYTzElNs8XWd2WN=wOonKY^i>N%{XohAt+x{}ULxnY91+GxRX&|L<eyWitNX z!_dcM{=bKzpUL`vH^T%b`~O`G6PcX<cQH(2a{u4SFqz5ce+R=9CjbBK3{#nc|F<zr zV+#M@$}pWN`hN?<45qmM%?vY{lK(d|%wkIa-^4JRDf@pT!yKl({|yXtnTr26FwA2r z|6k8ApQ-wP9m4{qhX1t;3z?e#*Dx$%YX4uuu$Zaqe>KArroR7G3`?1&{I6tK#x(tZ z1;cWt+5amTRxr)~U(T?SY4QIuhE+_<{+BYWW?KEflwl3i`u`;iYne9xFJ@TBwEce( z!+NIO{|gy5Fzx$az_5|&@c#maO-#rC=QC_(I{iP7VGGmw|G5lXnJ)d$Vc5oW?SBr# zcBWhZvlw<T-T$A-u#@TW|4fEmOwayjFzjY}{Xc_Y57WE<=?r_BKL1Z;*vIt!e+t8X zrr-Zl7!EM~`=88kkeTU!GQ%New*N^ChnczmComjg=Kr6-aFki-e>}r6X7T@V49A(J z{>L($V3zwI1MQs+{Wj6)IML`h(Q}5;aiXC+P6QeRmtkOF`M|Bt#=y+X%)-vb$<EBo z%*w{h!py=32h7ZDY;3Gt?Ck6uY;5ct%&hEeoE)6&oa}7u?3~=}93bWFtSoHotn6$o z>}<@;tjwHj?Ck6;tgLM8+^p=ZVAGgcSh(5QS@_skxS5$*SXf!PSvXi(xHv$jbFeeB zvT(AovaxWoFtf3+u&}W+v#_wUa&vRDvV#E^JIFcA%*@Oj9GoD)#t9PVU<Zq{v2n7( z<k>)0v$L~-B|uJO1F_jzA&`Te11tej4OI#D7Y7^2Y$)bnWo2e(<zi!FW@Bb!VPj!q z=3oOsPBsoUkiR+E*;zo4jTHnT27@huSjEQ8&A|?mVPj=xV`XP$W&r_K7La$iL2^tW zJ_=^#;>H0u*g4oZ*_fC?jsZmj2guvp+-xkYY+x%up@m`;n8V7#0t#d<P((t)nwgcA zl^v{^ot>2f6nJc)*kt1Zd6k)!n}dy$lbxFr6rikZEUX+L#he^qf3UHF>|$kOW@TsQ z<K^b&VCCiF1jREaIG7<m2Ya0hWG*{9D=3iISV4-}*?HJG*x1>)I3bEb;SLIAkP1)` zf;6+Sa<H+3tpY_2NCt#C*jYK)xVb?Q%+3mOJUhq)W>DC3va_<Vu!21bB0$dLVgosd z6%@K0FyC>43}xr!0C|d?gPWa$otvA33*;g;b~aEV204a}6XbGMc6LsX2p1?-bArTK z*;tvGnK`-Gz^R0Tos*r59jp`_vmg(FoX*b94oMm8+#nCKa<Fr-a<H?ra&drC9y=Qs zI~O}MCnrP^C>%IBnL+xvIN2cjIN8}i-iN0vHc$cuMFJZu7bh<_8#@a)jM+Finb|ox zIoLQs26J$;gKT4C2W2p3Hdb)X1LZz8PF7BCc6JVCW=;+cE_PN>q6UR2Hz-Ge5)K%1 zaBzU!2ntk?zd1R<9tL@ugM$kUKrsZtptxs+=0Q$QZf-7akU9`%=j7sqrCEp!7Z*Fo zMQqH>phyEn9oRZ{E^y-J0tF>VBO5n2FE1}I4-ZT?4-X$7FE0--L^YV?0!J@cH9I>e zCl@;#s9*v+iG>9efZ%9hV*`7Im5l{#6c@-vEG+EoU{Q9MXW)+G;AG<z=2mBA;L!q? zcdVT3+#JlHpkZcZW&zs^CP40IX9cAf4t6#U4rVrXHc;$xf{Q&~c20H{kQ-P*>4u$^ zot>GP4OFs${LaeG&dm-k@7URySy_45*;xhHSa_I0nU9T!m4lUqn-gR@2RkzxD;KD2 z0A)WG7LX~dDCHeD)FsT!km8P$ofG6MP!<4(3KzJP0|f|JAu<7S7aW6<6G9TigJ5t$ z57G|8oNR2&>};HD;Ia->-m!o(AToxOao_@nl@kmh#z073ZccWH3T8G|PF9d_!HJ8P z8w5Gov6Oe<2*XBz%R6=^W_Av6s$=B@MJEprJG4Gx$6DTjYy*`D+@Rusg@u)khXv#a zP>BKZJR7(~0M%X`9NetT>})J-+?=2+#KQ&3YOL%mATNOm3Q)da2Nez=|A5Lnem))^ zPBuPnE>Q8p$qw-^hy-U#ZZ0qrTq&@#GV}BCaIo`&EMwya6@{Q!U<V}-W@b=ef`SO7 zo()t?fU_W|kO0X*Fuc6uWMySz1s7v%pz@BD6I=j-JPF0%WX8tK%*Mk5N*JIp0+ntc zFL7{#%R5e_@{Si|4XCbW134O8M1y#siUnHUfr>G3dB+9HY3%ImAP0fUE|5-8c4KD; zmG~TNpi&wX3S1mKpg0G4927(B+@K<kot>SFgNuV1Bm?p%C~UbQ<sCN{J2>n>>LAqv z$OceqhA9M<sJx&I1F59I<sB%JI6(OyQr<x#h6Pk0gHk^$I~zMUr~$&x$->OV!O6|e z2F{u6?Cjj&`T`X7Aj}DlS#Y*tV`t~$;siBiKoabrqJf*6iwlx$KqN>Nyu9P$;o;^1 zD+IL>xVXXP7%0(lf@QcMl@mLt$YEzkly}_RAbkivsC?t&<KqMA1YurYettfFK0cVq z?4VQ$idnD#sMvyfg#}WJgChYf#|~=waImsMTM}H{Ts$DhLOclb4BT;`Hkt^x7Apg< z9y_Rg$I8jh126BuxeH<_s6=9C<>3UUI8IJxHV$@fPEbsOtOF%y7FHHep5R~u)ybgp z4pcaDaImsL$~!g|Q0d9c%E}8W@7P&+A>|z}sD$GImv<mTS-IKR*jYi@4-`2Z%&e^J zY@qfX$V;4@Jm97YxG>@bS3&GtU<pt*0lAVJ6yxAJfCF5-gOUwM98@ubk`kzS2Ew4I z28ly3hzG{(DCHffWyiq=s;$^r!Hp1bg9Ti}gHtdU2L~$%f>S=IP6ZVY;5-4cijy5& z-hnbVxY)!}-hm<$6~ik#Py#^4AP;aM$~$&0P%+QT%gzeUtZd*~1=T1h57eyV0auf( ztZclj%%FAzxQ^i9VB_Qhg&QY3J0}M>sPW3m#>>gh#l^wP1#Z$o$~!I&E>Oz|WDf*0 zvvD#D@bmF<u?g^SfdY&Rl!BoC22(uTpwQ%C11AnPkYY{_KCmJlZir$~xPxL5qyiL# zpa27Bc#vJ-HakcLggMz+IoWu5K~4a-KR7^58g^z-*mJV8K|KkjK;<0=DB*$Bb8>Kk ze8CCI{G1$|+?*g!ae~V|US5#Tc|nPTjf0&NR9ta@)N(+|J9cg!4o*;`3|!uU8b_c^ z%E8Xg!N~z~5-8L_B^)~&2RjcJhy-Z{m9ShKyxd%%fCUEwD1U(L;9%$G;0Bd<9N<<a zNHGr=xTxphhLoY;@($Enf|(C(-+_uoE>3=4c1~7M0I;)jaxsIv!VU^1PEKwPPEgGR zYM^s~iaC%|K&cz#Wo}LmE*54kkRnh65Y*fRmv^9a1Hzn~TwI_40+nQ-1`;<HsGI`D z3#jbl;Q_U~pux%tYR7;&N+5k499-PUtuRh*M4E-jaPx40T*S`G3QAqv+z?$H96UTA zeLV1zl9yM2pI?BVAC}Dd_yh$61o-)(>Oqd<0lNU~C{VG*!w&WeD=RCg=zx}YAWJwo zIk`CD<sCOSH!mwI%!6E95YNCI2WlpAiSg>OG4PvmFo3$TpajnX>h`d(GP6Q$=iuOA zX5rxAVB_QD<l+Vuw#@9D9K75-oIG3{oLt=eoZO%S7F3^sBA$&CRDiMaf(j!xHa1RB z^BPnegPMhG{Os&(!W^vppe`&MJ3ku_8!I0-$R;jM7IrosHg*nH9yS&ZR#uQHpu&=u zmzND>5+^4gIG=-Z3aGZ=;o;%n0Tp}PpuhpSlowpef#f0TL5ZG|6O@}kIgpbBR5^n% zHzzkV(}Q$@TEGwtYG;CUgE2QdJ2R-Q3(AljEFco3jhmB)6NGs<c{sV)KoFF~K)p>) zHcmE(F%XiU8`2o&U}pwNgN6e@sf(ST8w9!8nZU7$OtA5Rn)ui-CnpyN4+j%72NyU- zIJrUo<L3wUH^Es76!^#{f!QG2Kny+*0qXSdgQ`6?h-NM>c5WU{PEZNJ!NtYP#>~ae z3M!&`csTiaKv{u}gN2QYjgyO$hYOm+K^;Rj4pw$fR$)N_er|RlJ|0dEW@a8vh<`yO z2PmBRc)38Ci5(O~9PG@(f&yHe0w7yC_;`6BwJs<<F@v;10})gjfdY>koRc{?I6!(q zm>V>rz|YUg#m)g59bx0-0>vE2PBut80^~_3=HTD~HKdr?`T4myxxqCd2Nwq~2RAn- z7cUnN$bDSAoLro|pmfW{&jo7ofm(bZCxHW$9b7tcaPx3-bFlMpfNOS8c?T{(LCqdc zkdt`0K*<VJt#fknaf1pmP;h|?R30vVULH;^kV<gRmKR*`b8_->@^UhRI3Op05-T4M zGbErmI6!3~FE=O>L8%LDJ|`%Nf-tyf<QC)yb-_UDIk<S3Ik|bcIlxZf=H&#%AgFBS z<YeUlHDjS^otvGTmy?s5m6?Z&8`RWg1!aFuPF^-PR#3WuU{E;&>U)BU6HZQ0p#TvF zl`Nok7cVa)*?>q;VGqj2AQe13{QP`;AQlL7feJBbiUrH?^YVhKBu-AKo!~M6q!QA8 z<Av+w=NA$Z5)u@IYUULX5EcgUL2AJmlqx|n3+3?e@FA6VY@mh&ScVhiU~X<MP<;+I zi-(t20OVMR2f4W+o`E}#i-&_plFyW#LBN880o3c^;^gIGVP)muWMN~5mNOuOnOQ(x ze11?xz{$bQ&CCHR?|3<Rxj4AFx%s)cIa%3QIXOU`Bu-9HNyW?oE$`UbI63)2qZgn8 zjD?L&fRhtc-U+aPI%6CHY&`6&{M;;{0-2MAgN>Jsos*51jRjPjb8<1Wv2n2T@$rG$ zcwlS5E@5V7hV=e<Ie9_80<|+i1s4Y&sIAEfDy=~3kTED(frf2B$%u=cn+u%2p$b3= z4T3@KOptCc=HcLA1{VjQRvc1!$IHnLPQkpe@($7`VdDltPKZGu5|rz}MJ+oE2Rk<# zqP*h=0d7tvP-LQDkWWFegNZ@)G%qI;Gba}$#e$1@0Rd3k1e6gFV=pM?fkygR+1U6& z1B`5}Y#aj6@{S8s`GLwiP?^BZ$-&LV2O4Z*W#{MS<mKfQ-~|OJJ0}asL)@IaprQkm zn?Ma5&;S=FtBA0m01t;SKQA{2GczbWK>h_`PEa`W^MS*c1C-4<*_lOz1-Ur|xwtvG zIr#Z_!74xj4+>+D3Q&~+(h6!0LCQ&P4oDFJlI7-P<!0mO2Nk)XAt`n)Zcur_3JQBr zs|Ymu2=_9m{SO)m<`59%;RK~qE-nsGRB>~I3JH*>xVd<_xViZGxp}y_1R&)dCl9D0 z2r7+1^&+Se<KX3kly{)g1KjWC;{%m&oZxr|)$m*(EugVHPEd)@$pLZ_7f6;%094kq zgS-kV|M<AU?LkgnE?zEXULKGVkWaZd_;{Eh0R=LXo0FFZG!hPy0(qGiRNg`A5DpG@ z9&RB}h5<E<IXSs`!Q~w%FE=QY^K$Wmn&O}aIu}ZL#|!c=7Y{2lFE=+Y7dxow0}63o zHa0d;^8kcF?FCRMfJ!@1iw;y$fYg96sAvF{X`qr0f<c)J)Pn}e^74XOVIUR=bMqqC z(E<Ye0(^X+QWR0%foua|K0b&nNEZl$$~RC62eKK21qDS!ghhmeKx(-_gMD0FeBh`B zDdq&_cYZwO9S>++5M&H5A1@!TAR8OZgD}s4oh<<BKJ#+&N()%9GYHyng33E~ZZ1A< zP<h7%D(|4Sb8&Govv6^7u?cW<bMbI;a`UiqaB&Lq@N)5Tb8_?W3W5q)aCryn;&QTa zaWOM<u=0a+g9Zmc<sAnbD;pOV3mdy2CnuW-C#xW+gU`kx2rBOcxLH_1YFRkg_}DqP z*!bAkK<?)Pmv<ce{QS`JP5_iaLD9zwY8~_P@^bNlDkg4Fa{?4B{NQ0dP)-7w2FIWg zOmIO2PJSF*96X@f9;5+`xj|zPpk6;04;L#l3^Ma_a4>Um@Nhy#=0GG!8xOdt07}8U zT;LK8G+G007;v+5vqMxsNI`y3u>rD@1=MbUly@9#9D@8H$ju2607og5U>5+DgxD~s zp62CZX6EDuhZ83+C_xAca<PGO9w;+HO~9Z)qxb@_@=g%c$6(_E7nWRH96aE57$`Qm z_&Hd)IaoOac|ci65L{k?N;YnGZXPaP9*{pkO=wO|4t6e94o+545g|cd4iN!9ZcbKa zP}2$GV`z>R0QFJ8{bEpdWEK?>;^q?K=HcSz6yWCtm3!b&2Zb{z7(syu(#{DAJRWXv z8N~@MBEYhoY&`6Of}rfq4QgL<^YC(Ua<YQMo|6MK+>h`wsFVctYd8djKnC)HN?jgK zeoh`9E*?Q1ULGzkZZ2MK9&Q0a9zHHEK~Q6hgNu`g12lHU4N5HFa+Zsemk-p41T{=R z4O?brW<EZUT23xd-1BkqaC3n=gY29fTwDS?pwbSM{y-%xAGaVsFW3Xz9H7#Rj|W78 z@)I97E2z~Bauz5Y1bJ9NG6MXZ;1(l4sJsIwN|2X9CV=b%6_1=8eB8o<T%2s6GMkf& zhnJO$SCEGjTy}znfx&7y*txh^IYHwNTwGkBJ`67>FQ}wrW#!}H=HuevVr2!V8eTRw zc91tg7*u|M`mCUmjDv%ln~#^54<ru4prQd(ra{U&5D6K?0?Bc4@$v}@3J5^hTwL6I z{D=Y%ECZ>WxVWHpg3=gRF+abcpnxDq7R==Wm2V=V!ouJ}ikFvHNJvanL{wM=A`2cA z<OjzsSOC;a6M&a@pj6F)>=_<jP-`D-6lf4o2vpudCPQGJfjApf)$wxi$_m(UFbF$y zfy+A{ZUG)vHa1RfRyJmKaPtXFFoT8z*@d`yczC(FczIblxw!;+`MLRdKs{qY9?&cj zXuKPgo<U6qR#r|nK~UeG9aKm0gW86yY~0)|?Cipvob2LUtiqu3j-6AOotJ}Eh?fOq zIyWmPJ3l)oH>k@CnwsF@VFrzw2nYy5-2pZbH0;U>Y8~_Q^K<iq{RkRe1qF)$cvKG* zL-1h*P<aR{>%e6iq`Lr0FT9{hCa@SN4}c~Rc)59b_#h(&AOl!g`8YXQxj`ihsMpF0 zYKHLgfFM5)FAqPsPs+m%f}j$Oivv{JakGQuVOWqK*79KmNkfK0IoP4)9WN&{q;5qb zIY1*A0@wf-H!l}IH#0LAHzXwZK;9A-=4J=wJW%#SG6jVRn!yqR4Zg6kvU3W9`WWoo ze4uFI;o;;3GkCeUczFalSb0G6R=hm?{M?{+8xJU(u!A~AJp8<%EDEYpK$x4AgPT=C zOhlNEQ(TB2)Q#ZhhWQwzkc*p3PyiI3+?=35;^G7;=H(XQ;pG9z@WaC$6v!YIpdbVp zz{Lq7L4_-*{sGB=FfS)NFNdHY4-aS<g_DzmhnJU|i;I<?n};3TZvqeKLVXPCX7F&a zvT_QG@PXXK&BMdR%gxWt%gfCx$jc8Z>UcnrCoIg%&&?ym!^O=7YTAP58$m@hsLjvM z32y)KadCnwYaR|x&`5!R04M~wx%s&Hx%s&HKt(sG%;VtZ29@|AOF)>Ho1aHmfFINw z2DSgWc{l}m!37)-KMy|-E2z~A3Kw2(9xg$CR*)wJ1wai>UM_w<P-_q*3Gy<?1ZYC# z;^N@v6A|VH*E^tbDSlRvSGf2=WjQZDD6&B1CI=5U8y6?2a0L~M96Y?7pnT28#>&sj z%g@cp!^XzO&BMdP51O+8=VcJV$HxnrbpqEtkVX(FO@S~UAE>7(AOJ3Hzyzp604fzh z`uM^9PLMhf<`ob`RMKD>0YOj`oQDTg<bcct=>cIuL1AGbVNg_p^zrZr3yX`3iHnND zs&^4l2?=p=Q8AEOFy`SA1V=1bIVisifu^Ow?g8~vI62rsGN5)GFE1Y-Xj~9%l%Rm1 zfCy-!1mZ!MXCTfN5ab4xSqehVoD3pvTnwzNtQ@@Dg1nIOjvb@C;}GWM<>BMz;^kxI z;^7kJ1H~j4Hy^(+w7lcu;Ns!r;pX7sVP)lF69hSkos)xyTacR*w9tf`o0Wq@gp-p) zoQqWi)YxF>65-(IWEJLxOs=qUaR`8>lms|HGrAl+Jj|eB2|+<Y4sfx?D+J1^;PQ@- z59~Q^eo)_!mj~Kp5d_bTgK`qcX3(H3nC9exwilquh?9>8oYld>$_>gI;DQV!3epS3 z{G42@JY0NSppuRYT;72c@bL)n@PSh>KMyFY@~}heV(>@|L<NKt7DSYHpy5qORm#C3 zEC_;F%R3=K9Dtjb4_w}XQa;Gl{Gj%&hzK`mmLF8ZfNBSD;y@-qb2gx9J7H*f$0Y(P z?>KnCg(VLUCm%l#4=59J@$v|PR+q4G3iI*s^Ye%Z@bQ3}YOEkH@$rD>Cb&64iI|(4 zi<5_ylbcmiTvUXgOF~$Hmy4B^p9kV!5XlW{pa=<qN-iEQP~za`1S#g_5#{CM;pGw% z<Og*Lz?C;BltC&$fe6;h$;ShVWl$Bv%?*+PVO~ylJ`Q1F(6AV&IO5>t1C^Mp0zBaI z4i=;k8dlzMiHL&S#LLUW%f-hn$j!&c!zauKnp5WH0Z9mh(k+iL4;Q$E;O7Fh|3E<j z8qNfDbp^rgKLKuT(C`?jHz6p<!^OkN4UTtE?DK%MfJSk-K_xz@qU7P?<L2WL;1v<% z=K+oHg8Dr?+(Nvd79kIh0IvWqs~|rQH^`r$uodQKg#;8R_(82gP*W5n3Gy;O$UWe8 z9}gE7CqJK<2sbwes4K(G&BxEm!!OLo%@0bx;PQ?K)W7E7;enKQJfHz}PEc*k&&$Kl z#wx(aE5O4AD(^t~fFIO71~GU*sRuM0$_s6M@$i5uS*R2bKR={o0v9b{0@RiO%{YKm z2ndLX2n&N)Aj~T$1TXIdKs{1nK_O6X1eJFnd47Hn3Bp1`kp3!&4Z<QK5)z;i4xEqq z`T0e~Bqb#z#l=B#U<dN@3V~8E2Uq~q8x}?_@7TfR9Y~g!mye&9ogLH><mVR>6cQBW z-~bhne4z3k>KT~hKnX}dNyv?pLDY*IT;B2V2=cMAvvcvVvNJ<01DVOg%nF(x5aH$J z1w|Y`D;F=fFuwo~s4nN@2Ni>$h8!0MHz+Yc%R6wWa6rmCZZ0-fHXa^U4h~UHP7Vof zR#8ZKCkiU>M0i<2rgO7#aR_p7@~{g+hKqT4!RzdVgoL2woiMoj3eG9~pcoS15rC9; zkYPM-A@F1jNFMBD7{Sd0YWssQ7buzWa`E%Rqk)G9o?StS4Wt)}`MJ1QK^X?r;eue0 z0)8Gr9)2DHFy!R`K~N3L%?V=ha)8n-3=0c_s&Y^b4{9cIfF~M285306f#jG$92Cqc z460bMVQyYNZUG)<W^P_^By$UZN(NC;9u6)@dkH=ZhGHIgg%vx82za$2D?67cq`c$j z1*LahE`9+}aDrl!N0@_^kBf~{l%GdHfJao2kB66=lZTaqmxGs|M}QCH4^UYL!r<~w zT0%@zfJ;(Dke8d46;uMi{LBMtqzDUvxV&7TK;q_Nm6j0W;}PTK=i%iR77_rf07VQa zltC&$0S8jg&B@OL8hZgXm3Y9x3zp?(=LeN{yj<M8ptdY8xR7QQ<l%+Pg(CZxhligV z<RC6lF@9ctP-5rh<^x3+KQEs!zW}H><pZ_zL`C@pczH!Y?LAH&9#DD5%>%BQxwyFn zxIq_afvR)?P?HEU?<XY014=^Bcn5ie2UJvY@^T9afSd~oE>PMK<P`;Z5Zvzt1%wbE zh~(uF<P+p$732rG2BeIaTUd}4BqJ=u1Fks*L7gOU^#Jm+0JxY2wIg}Cxj6;+#6@{P z<sCOK4-dZpD=)t=C`v#vD99@)z{|_Y!^O$N!^X|Y!_CD5D*iy_od7>CuK+v9PC;Ij z@(z@4AQ;qRh4o>1c|r9oC{{oiT%w5x34x0`PzQk>G#Cn*)8OF|5EK;^5do<KVLl;Y zL0E|;2$m5N29=_S@(yAfFR!q$D5$>*E)zgHMMWhgB_$;!V7kS`rKKdLBqU(!d3kw- z!LbWc%)`egC@6wd-htNNg0lihmY0`bfDcjL2@8pV$~%Y$VV;3I4pe3-i+OP}hzIh3 z$~#VeUSWPVc6M%FHg;BMIRkP7D;qB_FQ*tkAD;j(DB!vIctiz+_=Na)cm?=HK?N)a zcz%zUn-?^Gz{<+a2JV}KC&Yxgxxw>)ysVs@;@sSvQao(p;LOJ@&dJZkCdSXo3bKQZ zn^TCBi<e!9lY^IogOit!m6L;$OGHG36A~(-;IRU5P7weX3%o+0VvnB>+GG&{uVDnq zgG_^CZctYPl+r+n2!sXrzy$_W0Y5JU3h)Xd%}KEea&xnSN*NwDFl6Up1J%<4yh6Mn zEW|6s%f|_Vpc0LT3o_CSQ2`-Eh55nF9bQl~k(U!P(ZI>cB`OSp{M?x39hayG4#2}N zz$3)V%*xBl4tBI4sC_Fg&dUK=)Wi##OavzmWCAp<2U;Q}241|y#=$Ml#>&OT$twUF ztl{J177zplCnz@gL^xRax!Jfx1$c#oc*TVTc=>p^c-cYSG65bTQ0CzR^&@$BxVb<h zNUXBb65@i~GGfB~Jglss@PPPO08|(9^N5Oo!jq316hyq-AjSN=5_|%@AQ>T0s|4JR z1H}?Z1t<tX+IhGHc=`E3jY)nUUS5z42=jAu2ylvu^7DZP?zp%)`9O~0VFiUfKd8wG z9<T-X$v8QAc?EeuBNE)=5`ugJe4rtB9syoqUI76<0Z{=V0Z<1ABq1s)Ajroj3M&7& zczFfE>np(1gxuWRg5113!os`)+}uJSS8{<m?5x5fyr5zl9Pc0l_;~ohJykv)P>By} z0fX9h0(?UJ;vzzPe4ymU&CkonEy52f$oY7M`Gxseg$2Qicm??QctizRLCH~6gqIiO zbx`fc%MD_Iyez~EN}^!Q!^0&gAR*4f&B@Ql11iFVSo!!tkpzlDenCDVAwEz`l8cv* z4K(t~4H|^w<>ck(78K;?6JTc*5&*RZLGu)#R3pgA37-*zji!M*3!t$vVIfdC1yThn z`@}>=#Y999<sA<<Xl?@{D+KC?ib2@CyaK|a!l1Gegds8_qTopZUN%s9!7C^TYLI}i zs3@fW3ezbrE+Z{1BP9jW3BnSRva&KVQqmx`e4ymb&o2r}!JJ?XpxF&E&{Au#qd-k1 zPEK&)#K#9}$q5MZv$J!8j1d$O77>=<<b-)pP!Qr7nB({bgm{J2#R9n)BtjtNodBPR z0I0m<V`FFKgxb!>$H&UX$H&Jh&d<*;$jc)j$jSqXHeo(tP&5dN@`F|hvGeh8^6+u< z@pAI<v9j{8iGp-<a&hwU3iEPvv$L}E@v(7oN^o;?O7pTwfaZ)jcqBLlx!A<{Sy@32 zXXD`%;pFCH7vbap&0F*Fg9-_5QBhH7c_#)Qt^?&1&}e|LurQx6N_i&=FYiD$!!c;! z1zeFrhDZ3h1^K~Q9UPLpyaId>2(E@fhC{FrHxDbQ_X;lU*m&4^*gy&d`GomESQykr z;^gP#hjx{r<sH;0kb&Ux4%DdRX5;0;Qr>~)yP-K1N^yya;s8AS0=&X}EUdhI;1tU% z1S<a}B=|UaKs|9#4Ffd-g@P<r;}i$4FlOW6kzixx=H}!R1Puc4^K%Od@$>QVf?|_T zl!H}(hmA*EkWW~cPeNFLkDr%|mkm_j3GxYpiWtx!7^q<2=40jNWtEeWln~;P6$h7h z!bpAwmu6z3An)_>fP#pZ8>Co(Pm*7dkDo_O6x5Xjm1mIE4j>hvAOxxB<r3r*5CAnM zLGmCO5a#FR5abjW2aQMZg4(kDphB95RhW;T6I|XQ`xjK+@q>B_5|ToEkX8q%iwVke z0-}OoPw@-$3-XJL3kvb^i-VezTztHc{w_FMg33EyJ|1CVJ^^lSP-V>rDepu?_;~oZ zd7<$x$j1*FFXH0i;^P$+0#&5~eEdA1a!^=6LR5$k)X3!L0TuZo0-#cjpHD<UM1WO9 zkdIe@50r%XVdb5eC@-%duOKg|)a2y`NrAj9%m+)Rpq{OefRqFuXh05B-U$k`@(GFx z@(P2?J7IodVLnhlnv0K*jfaPi7qs+(n~#%EkXKNUpI?ZBRalT;n2!fE%?Zj2f}ETj zAR2^0t3d<>`2|4DBhV<Du&}TQNF0QPgv7-_BWvKI1>C*^sp8@S$?@?Ci%3X_i9^_Y zd;%h(BH&_1P*4yg0~&z?wLbXx*g)k4AELYy6O)h-mk<?$>6DO=m64H^mWJt;l#-W| zm6eu(%7YTP7`VIx3xJww;+)`(F5p!Mpe1)u2Z72)K_LMS4oG<?Dk3T(2`cYEi(B{w zV4i_Fj$criPgp}DgquM!f|mg_gCoEvD!|6U!NbSKfl=OZftsH}e7vCYj-OXl2vl?N z@(T)z^Yim@aB}d02f_LH;N=}?r4BbJU4Y6vc5r#eCCSarDa*?y$p)Gn=aJ+T<YtrL zXJZGIckDczqFmg3pe`@GyyM~)6BFYCd7qzO9F$YR%gKa<gh7B0T)FV`gUdl)UQr%U z;DCxNP#}O>3t$bP$puIo4pQpz2=NPn6hbj5w}G%ApAgt=kbYKHRv{i9R(>8qUS2j{ zHePmK4qi4;YgUL)ginYM<ZoeqP*&yV1ocdKx%s(35R_(NSX>k`y3NPU29gF9-Jsmb zB`ykr{M=Z|J8>}_fQMfYT;B0P%R6CyegOeVNj^@{B2GRY#Cj-@4agX@h)e>s(2Sjp zi${`;otvAB4^+(Y^YilviGV@}lnn*MpyizqpRh2$qzEWWbMvur@p17B@(Bxq+A5Gr zn}?f^otu|kK~_pqh(}IB1XR2T^TT`$YIXAR^NNGZJANKeAn|dtD#%I+@JR^>@quK7 z`9TE%DBwY%3{n9KM34cX@=j1dfS(W4G=c`OFh8`s6X56J<p(t~_(2(n7g64UC+wjf z<^%UuSy_1`rG)swH3C1cAfG6okPyG1s34@@B?>O@goHq8iI<;`n~zTjw2z5j7~C2J zm2!N%pz@A~M}!x&_@4(fS}ZEc$IHjf%P+()%rDFb@(8FI%FE5qD<%wTv<dR_^MXoP z5dle2VSaudKG5(ZFF&uS06(bx1}X1Eh4^_v6Z1m+{Jf&Vte{{J7v%#NfTExQ0B%s~ z0tE=j1ZauJ3+gdTOM=TgUVc74L19*YK~W)4l<@KMi}H&IgW7#O+<g3OyrA-qA6!)O z3GxaF^YaUHu!;!si|~WXJ5Wpub8<q4xWJhSv=&4FUfzNF6`)uFVPRnjad8PTF<5!W z!2uco;pPTw;};Q?loXc$sRLm_Q85uvMg(DyjD&=k7--B6RNjGtR9G0Ii=ST%JU)jg zDJ3Q4<mBXJWI#GWSV~$!UQS*{7N(wGKtK!}u^?+ey;%_nY~>xu5`KOmVNiJoGDcWT zR7_L~RNg^62=ffwae~5pB3cp=+zit3d<<-C9Na?uVnS>j9K8JO9Bj~X2IK}dc7A?- zE=fT_0bzbVAz?ON0X}hIQ2|jwJ^>+NNdZBA4lWLUUM@ZX9szzXettGKUUmu4AR8AC z7e60pxSErLlb@fBi(8t9hf9HvT^clj!^JDjCC0-pDaghF%E9csTw+{2{2XGC=`jI8 zHqi2D2?+@<NG%`<9vcP4186irR8*8-R0y<aQ2;!Q$Hynm19mAJ8=C;gYS4lokN_W~ zUKIqn4LmX=EGP^%2_(tS56T=MEX*$qnE?k`z{V!R%gZLfD+DTH*!kG`IQZB>3WWJZ z`GxsK1wc@c3k1P^79IgE5Cj<m!;+$)CJm^DXXoP)-~yHIpmAR=Nl_3K<O8km0Smwh zZb@+*KtNE4Pn4epv=R;MW^kP<EzQrx%f|;Q$w8BeaAV*M(Bcjb4lYR!(1I~`E?#MN z4jvvZ0b%eoupqCnsGtBpKPWZ@B)Hjxc-i@+g!x591*FA<1O)kb_&K=*xdem-M1_Pv z?G%1cGk}+epM!^=LrGpnT7*|YN=%TCjZIVl=3^m#aM33zE-WC#FTe{5B7Pn=C3zVk zei=bfu_!4H>Piaof&2gpWRMC_5P}Tg;}!<3KotNrmH43n3{uS{%q=M?D9Fnv09qL! z2+BZw?4kmKT!MVy6((@sa`E$v@(J*<vGK~thzJM^fF^%HT}*yqVF4j=VNq~bKte!R zP)bTzOh7<JkWYZ0TYz5#+}{-jr4=4NJ`r94J~43t5k6i~kSn=)L4*C`;{2dCEGR4l zL<NLF{bK=8e^Y=DRN@Qrfr3kzUr0bqNLpM}05qs9$O|gMC4@kvAitQ9m=K$|hyb6k z00;{5Nr-^^3X<ZW;1}i>2Mqx5fTTbHA}SyN?Q#h6@o|fZ$Vv0_aR~~7TCBpNYy!gK z!u+Db0{jAk5&~jiwY=Q?0_=Qz0(?9I`~skcr3kNxgrJ}(C!3hCpqKzJXkHeS+$FfU zI6>VZ5C*LV5f%{?f^=~pojy>kfUt;&l%%ATxHxR?i-Q9+0K&};(kCDwDkd#0DFspo z!b0K_VxV~k5QfNzO9+6*HTc=tK?7(K5)fSi0umC^(o)ih@j7W~1qB5~IXRF{5SEcu zQc_TqlZUDYWq1iL&@v&AHT*(CVq#LDK{2qSKuslX9&q6VvINw$6yoIM0vRJAAtoU% z!v$XM0`eftGcd;q3XAfK>PpA+Fvui9%R2#aA$CqqUI9>f2en;5K!A;1KtO<7N>E5p zM1WsNn1fG{Pg+DwP)v|dKv)=5403XDf>MD1FDMxau(9#6gZ;|I!!5us&d0~g$-yZg zz{brj!^g+1$j2@ND(|@YWVpq6*rf#7I6yYB^KpxF^9pc?b8`xCad8U>vT<|q@JdKX zaD%)rC@2Nash}|`5fN}pNI(o!rVD~9eNZ(Z0U7}Umsudw;22)sfjSQ$EFvfZO5gkf zU??O2fx-eJ0+7uTAd*d#myZq9bLE4ScAR|dpni#nfS3RXiwTH<@*I~S7qq<N7T^}( z2BldTmJ}D{1C7ZD@Pf)aZs-z1ZXQW-5X4sA@kl{p43iWP6y_HbU}57I-~#!IUsO;~ zNJvITfQye0G%$}?)r83XpcQ)H5iTw%PEad|otsaFor9N`TR=omKtNDXh)+Ze6r7-z zfPf?qhY%k-pNxorn3#Z!m@uTg;}+x=5Ec*<77_prt${WG@$m|9@Ca}yE6B=-@+nG* z3-WQWiGedQ#Mj{RPD(;p08|8kf{34&O<6%!NI+IdL_m;FN<vHkR0M#+9TdhO6`=AC zq?sR-;lWmc3L=mU2n+IYiEv9v2?>G9J5X~+L|A~Ik6lbah+7bJY5_MA5D*aM6XfII z;FFaV5d=X&AwFRN2>}riL1AfOF=5d9F9|_mL1`IKaUct_h6h&OiGqTa7c{CT$PX#+ z#Q6F71$e;aorDCa`7Z#DcToWmK|y{&P)g?!<d*;qrtu342=aqUSTP|P2{A!IP`4G- zl;e{S7UUP=7ZeZ|5*Ol-5E0}D`BOwdkY7ra0~8EW5(1zWBfo@*053l;D0P9nEG7Uc zEkPw5ACH8Hyo`VVx1bPcyOppQhoG>u2)~%HAgH_(6oc5tBOnMW@A!EIKt(05fC!%` zxV+;K6BYy|LUvHuBOo9F>aRmGk|1>W9@@<j6a@8PK|uh*qM}k#pppq(w1Bn^bMk@L zu7KnO1jNKaqihhifPk>Lq&O_OLS!T)LAg;t0BWZM%tA>?85wCANlBPa85t!-MI{9V zm~L4)6=fwQ1qGOTK_MYYaO8qDfL5<abAvYafm{XJ;00Pp36=sSW)V>#Zf<U{QJ~Bv z%gqh*Aj~sx#|euGi0Mlw@iNG!@iVZqb8rg_NC>lYa`FkVbFzVR7sOOZc_%F-BqSoh zFD$~rC&(`?Dkdl<#4jKsEF~l)04eYIL9<5Of`aVqd>oP>w}U3U_$6TF9XmI-EFT}Y z3O~CnXpIavpDd`nlNMs<5D*X);Naty;N}(Jl;Gw9m3M+dY}{PDypocV+@NNhkdQQZ zdID75iHbtYJ5WPh2sB&)ss<!MF)jcK5s=M-pac$L@CovPS`b3~kl|n<UJ)TtP*#U{ zOaL-rDl8}>C@Kir*aN}rqI`Vpf_x(U{2XA&$<HAw2!i5*qJp4uUQAGk8w5cuIDQ^b zX(zx9%4IMtB_RYY?>IoxkcD&H+&oeeASi^Tea9mWnzF)%1qDU;#RXW{1O&k`!Y>92 z4_R3OF3_mF0G|MS{U>I5$H^rPEAM2%<(;4?sO2Lh#3w2)Bq%5VicKLY9u8qX4t^O? z0WmQ_S#c3TA$}eKPEdI#A}B5*49Z3Vpp~1T@{U)4LsdymPLxksT3m>qgIx?<wm^Ih zZt6)(iU<k|3i5#hNr0DKRY^`*P)<lxP>5ezQcMsO6QF<xg)&G5C<sBC`FTVHL4)4{ zyh5O^3@nKGctm)mrG<p}_yu`*`FMmtj^Sq)2ZcSh@=l1KgM&{_4irx!LPA1(A_9^E zqN0K#(jsCaf<gj<QbHm^GBTnPf<khjl88qTR7MK&3yOi#2`?X?7@r`&goL1|0G~L& z0BB({XkC}2Bxt}_2omoCAdd(N^6~QX2=Yscf@Ud%1%>!Q9cFQ1S&#>LK?5ToZ%T=P zNFhN9VF_UlNl`(5kUv3TD=p3el984Km7$<sFlYh|<YrKSh=IyBa6eawpN~gER6$li zh+7C;-id(AJ5d2KP?aSlB_u8mYWMN+2ncfU^9%BWW?4bgX%c+m5<)^^oE+jJLZI@F zg9E&>6S=$twU<DveL<}R&=NIqaZtMq6fXh-Vq((Lppprc&p?=ylb;VXEe;AIK|yf| z&?p;3rGS8l1fq_H$Vf^-$~z7Y(7I#^czGu!B`Yf<D<uWfDJ!e2q@=8<2ri@~BqZeI zRaKN#6cr)12!aNzq!8sDX!VK==vX0;gE%-qd%AeQ4Nj0HLPDZq!tnA=LP|o8n;Ygq zm}g*)6A}>@5I2-f<7JS`1ebR_B7&0O@=lPQlMULg0=a>WT~JVvTSizIR91+Ha_|fB zON)vNi3<w|iHOPy2@7&^a|-fvgKBsIaCyhiAt@*%2%7K`<d@{<2kprb6lCY-kpq=? z0_<|2^)uZ3a@=CP>@vda9H7#PgO6L1hfk1GlAB8qR2d0@PL1J{l9J*EHRFVZr9nAW z5aeahY>&9OxS%+wyb}hE$AHQ^DbR>Fw7dhA2w>%)>KI&&3iE@8NI+OrNEB2S3V_xo z3JO3{A*i$yLoV;c`1sj{_%X{n2|-aoP>n4vB+LzhkT#qU4+z4H0@1RPpe`J!9%mQe z72<)Hce0WoD9jHzz6KnnP=Z%R3I`Ap77-8^WC4|Tpt#@{2PFqNIYDmFmTORX2Q>kM z0_`r80q>S#=jNAV=iuYx2A6k2Lc;u_5}@!A6%-H=lIGzM;pgC&6BQH}7m||@5fm2S z732bWNmNK&1XOr|h71G*_<036c!fCBl;!2c_*G;ig!wtx#f3n=g<t_tmtR^+R0x!- z#RbI$1^C$2l;uSP<%LBBh54nW#05brK;aH5??Ax_3PO-(0bWr-5zxjBK4EB+2rSFb zBgzXJ<l*BN2F(%(i-J1g>=J^)+`|0uvo#>z78Dfa2aQ|v%gc)ii3*Ad3kma!2uccy ziVBHHi;9DWJ_MzNM1|$#L?wmb<((LKd`}FNRQW)&7(xP4Qi5UveBuJ&@(y%95vaTq z;s=#?;(`)_VnRX!LV}<rO+o^o9=))Dh>(zgsGx|DgovD!xDY6Tf#&gq`K3ib4LV^# z2@weq4oNYPB2ht6Az=Y&aSl*0NJ|NV$3-MTog_X{AyFZamq8|g2KqqFL4F=dF-18+ zA#Pz|eqljDQE?6-QE5>@aZw>bAz>*Y35Z%=K_PH?$0sBRs?h}{_{F7!g~d5JBt(TJ zg!n-j1{Bj$+}u3iTp<KXJ>p`bVxYkRP`(is29-LXlm)`#;xaOzk_nX0Ko~R_23p+% z(JLV-CnqBdQU}5!lF|~eGz*cDk_P2QK|u}<(1Zb~X%5jREiDI{kATll$jPawD61$b z!F0<jsHv%_Dk(!$gNNrq4LVRuO%T-kmXN?G??4U!St2YfDlWpq!woS?Qc6;un_E~I z;z5{aV2%?O6&DmYlFj5}P$&=pweNUEg``C}xVZR*I5^p$?JAHP*f@lQgm~mcM1;kJ z1VzO-1cU`-#U+I$MFfS!L}i7Ag}Au6gamj%DPD+2NQi?&fI|l4Y92lwApuDN0e&tH zE+HXy9$p1LJ|1;J4h430US1vn1s-ue4mn|Vkm*7k{5(=Td_tU3JX}Jc$Pr=V;pXL& zk&)p66>B0Qvfu@3pg0g02RDO+BtgwQVPWvtjDUbNXaq<IBrgoI8nkQ@<a7a0PZiYR z03|XI784N%n*@>+5&|VO5EcV<J3yKtSb|@GLs&peK!8JlLx59&OOOMS8YP9qg(QVR zP=p5rK_!|XAE>kw;t>-<Kv_x9FczpD=Kx7_fF`*>7*yJU<iI;hAUPFE^2*8J0K&pz zf|5e4Y(hd@AU6w2fWkvTL5K&`Di-7y1l0~uLl6|mGA=GoZaMHU2nUaV0tW{lACIs& zc!XI*KwMHpSV#yIn<6qi9HIgo0t(_nl9Iv-Qewg)f_y?;Ji<IeV#1PQqM#9fL4H0# zK><D?4n7eM4OK-&2>~@ZDG>n<4oP8zpM^l3GFfRcVNk9H1(6^>NU^Apl8CsFh=8oL zq%cSY$Pb`E2B`oAA;<thUNIq2F)?8weh~p7XaGwJ3k&dw@yf}Gh=AtR`1yH7K#mdM zkQ5dH?PUO+Fa+`?1Pcj)M&~#<1QZp;g~f%%L_|bDLux|e;=*FGVv=H@<RB$1CM+ih zDh}jD1cU{7g@nXG<9ouAAW42c0gwq&Qo`Z_{E~t~LPEU!d>kAc($Yep@jzj5VM$>L zA#q_5L1AG~Ehj7}BO$~uA}A^>A}B5-CM+eYAT22@44Kyu5D}0O1$7!kgrr2JL^-6z zg$2cgK~PvwR+0l046@Ro;0KNR34x|jz~+O3L>S!h6$TBo@=A#-D+mekh=>S);!2W3 zSX5SANKy>6end)G3S^tG0H2UBhkyWR@re*<f>%gFKuStPM1qS$N=#TvSOAnEK*?Q- zhldx^ZV(m*wU@-iMZn#7(A+0z#10fIAS@vPnMnY}5D0T|fd+s0_#k>Er4$t8<U#5{ zSWH?LQp!k4NkL>}WI?$RQ~-b`45XwWx`c&gWfc_U<z;0-x<FV#K}}6fT}1_^T}fF} zLrq;p6{;STz-7Vl3Q`Ojy_1sT2A#bEauI0lGdB;o@CI2TEG#Y|%EQY8HcCcXMp}`F z2j)STXJC#K5t9^>G?OpjV^Ay+WZ>Z7;1v@Fmv;if9Gq-CP}_xth1ocSg@t+LMMXu# zg#^XKIRr!mWyPgLq(J4Jn5>A15El=ZumBIJ{ubg97Utj(-~`XT@bK{p3qs2~VPOs) zUPV4W9t}Y*MbMf+9sxxjDLyWF5e^QJ!#M?bq<Q&;Ii-2HLF*EQMc6=t<g&7|ypVMo za-cp6DEc@g#KE2umI9S`A|l}NdqF`NM0tmBxG<=a1$7og1x5HFP+UYp7#xgX0+a?p zSRB;tfCMLqln@Z$5D^d)6yyX$E<sLEE)o}(5(Z%@VJTryRuuuSCKcoZRjfk1AY))y zR#F7qy%XXGm3O>o<(-HCXoWR6Lg56Tyetj?s+FXKS=oeyxj-HhloSyW6;)Ie<`EDS z0$CyiHv^dg-gm;uBM;dz%Ojx3#lg?dD=Z-bO7Efq5>g_<!a|_f6p;m$cbozW;=)o= z!iuo+j#rphNL*M7RK)OuD{WAg;@}tI&{9)Uk`PdrmlhS|-~bgNFh2`{!dXs6Tv$w4 zL;w^-Li`}bV#3Oz;=-bWaxzlFAQhls2L&-m1t<_f+6DQ<g~h<-ov5IY5J(1uMFe=n zdFACnOXh_6Kyx7CV!}d#98$ufJfPJcJfKCvFfW73I}t$+4gn=42@!E&&~{7#F(DZt zad8nbS#c?G5fLF_84+<21qE?w5fMcZL17_2VNe+<EC?#^goQzK-jMkW2|)oVP%DTJ zT;9pZfF`(vg(ZZgKqa1th@gltXbMb3NLB*WXaiLbptK<^swg8VBEl~uAOh|*$clkF z4I;wQV$xz9G7=&}AVr|!QdSZ?HX<h@1a8sENPr>{Bnk4elrXqz236960({aEDvH8F zJffnYc_VQt4v<%bq{KypghXUSK*gGXumGR12&bR`Xz>ZC8OSdTD(^%^B)K@G#6_e< z1cW&`IYFrgRNjG#5fBy?l?1I0ftPpEQc}_&H6Sc0DK9T4FDnb49R)A^01f!U$~!4( zMMZf9kU9_+laZAM=R^=8t)wWgAS)*#0$MExwNnaY8wktEDJm)`$jO0pfv}>YhPt|j zswzk$2rH{-X=-Sws)5vsfReYUs2mSyK|DyYFsNH9&kH(U2jnVHdB+1<I0Tjx77-Db z6oZv_veL3LN<2I;55hbn1$CULxRkJzg?tG=gL1VH0|y5epSXyuI0rYkpa>@y8!yy$ z5fKqKP7x6iUPVzcQAuGTaS0AVQ6YIrX;Eo0ArT32MNv@^ZeDH?L0(V|FU%_<!oeZP zDF@Qc%g-kwC@TosE66P(!oka@EFi$ECB&r+ZfpoD^Gfk^DT;D%iHHabaSHOv@(GA= z$?|f8HgbrFv4IAS<mKg|<(+~k$b+DqA}J{i0wU6&hPbFGsIMX<BqS#QlLy%h$Dolp zNGlH9eGnCp6q5wEaX_ktg~df6P(nmf1Y|Z8O9={chzd#w32}lUw-BeK2nfoENQy{< zp%^a+f?9Aw{33iH2<o}Ou%fJ}AgC4>5#R(#gI7oL^6>I0%7UP%AS+~U42k4d1XZlq zu!yLHkhBOZ8>omC5fKrV0*zlPD~s?73PG|Wk|`)mZf-7KMQ+gc6D}S>Wl&AcD<UZh zN={;elG36gBEk~FLgHfbJRIVJoPtV{BGS^L$}$onVnY1FTp%w=h)7F-3NLV_Ei5P? z!X+TWrK72$A|<G$C?hJw!67XI^Ra}8FsPlZASWRzE+Q%j3M63x4joMuaS>H9NfA*Y z1vzOEkP1+^gF+dk0u+QG1BCb`M8qY;MMVTeg@lDcG9WA}$Sc98s3;~TC?pD6dnYC# zE+Q<%AtNHjD+=0Ofb3b&v>C`jf+{LfqLQNGqM~Ag62fxAl9Hkl@)FV#p!NMSq7tG? zN|Mr|qDrDdBEozk!qEOMD6t3#2ucZp#`i=d1qDIbUxZH(bRL_W9B9B-1RU>D!jht* zLZYDdFQ2HeoD`_hCN3%}Bq<^xDkH8eCoL)}AR+)N)WwA4#6>~veGwUP8F3CdNl{^t zKSA2%r8q#rpdbe-LnVaeBtek~nzaXcSy}|78;pen`J^S)ltn~%Ma4j^NC{~UQE_=m zVQC3bVG&UoQ5k8_*rXu8h$yF!popM=h={0w2)~Gwpp=ZLm^3$sjD)C+sGtZZCn)cV z$nf$)^AV`^AT1>+DJBl;5()~6iGlhRpdbKYDJexo(99{QWC3ArZXrQIA%1?4oQR0D ztg^DA5`--xA|Wd;3(^b5vMS1oN^<g|qM-gK)J_?YT_7wk57H;E0Mn_gtfi%?rLGRv zDI+7Js-~->rLC?3QVYgnV)EeF1#6Iykdaa3g`Fk^YAW&a@quJOn>|EDC8fl9d3nJ` z$;-;is(?HS@gU4IGBC$UNQ+2YD_09Js5S|M$~%4uQF#eYZf-$QPA(3l@ZsPT6&2-E z78e(n5*3z^;t&!OR+N$zlNA>hk(5*v7Z>H`<rWp<6BZK`6X6pT<=_zFQ~*sX@CopV z3d;%!f%YJZigNPts|pG6X$y0yf`$cng;e>Z1h|yNIk-ec#YDM;_~iKnMY-hpctm-5 z`9#GzKui4;6cqR%>ok<aK_LOkDWK6$Sy@?8Sx~oDTns!-D=e%a2nqs_Jg7Vn69cWq z0TqT~(3%xAq6ETH;!>d6Uj(!^QB*`i6auA0rA0w{p;%f-h(k<BQdpQ%m{XWbm|K_= z)Gv_|l@*l|l@$X)aXt_PH6MiqK&71sA1KYju%fIuxVa-L$OV#yEcN8!gO+#VLRiW> zB?TNnL|jr>R+N=PM3e^<0>Uz&@K9A1<rNYZ0S%vlY6o!QKqmNjL5t?Nc$K+%!CU8r zR6*19d}31KphmHTkd&;rn5d|fh_IxXA}@!85T}rml&Gw%n5vwln7FWj2sfy_lM<7a zln@aW1T~?BMT7)Jxdg?y^tIL0q=j^p<-~<KIAq0OK9&>}0X0&T6r{u?M8$+aK_nu` zp|7nbA*wDeB`Pkgq#!E>QUMBgP$+{`fPxTYfH1$5sHB91n5dw*u&4+~286|hc%}H1 zl*Gk_ghd4eh4{s#Bt=DpIb_Ae`NW05i-aK~?jTZBR7wcsAR#q1X)!5L32||8AxTkr zQ7I`gNku7HNl^byPFzY{Nl8jZOiUTn<K-6>kromY6Bd;Qr4>N|AsHdq_?|4NkpUhL z<dBya6&4c`5t9;=6$91qpoS=DEuNT&f;4D+6BJxhqLN~A5~>O^V&Z}#pcz?VabX1s zkY~k3<s{@JIOL_pL?lI}#H7T;g%zbaKr%`Sq9ULklDsr%z!M||^0KU$7^HF*5*HTY zmyy&|6&2wV7Z(y26_t|Z0C`13R#HqvL|jf>PF4&wV<;dh#wjc$Dl8}_Dkdl<ASxv! zBO@*@!^0sbB`zl>B+ALj30lc6!^g)9ig^$Q?QQ_g!Gc=aLPFx=a<Z~=pp*r|GBTi< z1O)|9SqH*AJfPk)XdVNkQ&vt@Raph34umD;72$(0vhr%G$|?$qVq!u<qN1FfVq&7A zGBO}NAgrhe(x<2h(gnh*syaG4x>{P`LP|zPMqNW+Uq@F<8>CiDOiWx{LP8Ogg84v- zMM10el#$9i(2Oxy24so2xRi_pA9&zPMn*wiQC^LY59UFbXJC#Kmy#8gwNq;nWKe4r zX5i%H;+GUvkmTgy5fbI(<^UTBwonvO-l>R7h)au#NJ?=CgQ87VTvi;k!Ua^`@$m78 z3WLi#Q9dy-4h~^X1(4hM1o*{-Wrc;|<(-<45TA|+mm0Xd6ISDs7T{75=Y*Gc{DPv~ zpz@B7k55dT1GH~WQBe_E-YLV&J85Zf&rehq)D#vM7YDT%K;<3Ct)QF)vKd_7ff&MK z!l2|OE(|K`Kv-H_8k7J)ibX|5CB-063e@cYX@+1KAz=<s&lOtU@rZDO`X$n0a$?fp z(hi*G_{71hNku@_D!&LnD9ysKqAaKj2dbwzLDG=&jt^em34_<4gYqdH3n(k%0HWej zB66Z^9HL^7@=g|%9MsfA`GkeRZ7T3i7PvV`3?3eCJ{2C&b~`R!VKqp3Ck<MBATA*+ zEhjD}CMqQ=A}OxK#~~@qDWoDTCMzqZCMP8(E+Qbx16t84B_<~YDuf`Fwy>ZWsJt`K zQCF7{)>V-Q6)&=4Fds{aiGspeSwTt+R0M!Buc#o0fsVSQn1+P3n7FVqth^HiRfwRX z5)z0Y14IO*#3UspA>|#kLIIVIywZHi$`TSnBH;2)LRwNxRG33fOoC5b1hc#o76*-t zsH@9}ON)UP&4Py1M5U$0r4*%Pr9k~!P<f}KA}u2>4l3_N1;j*UK;@m7G$@@23JA*z zi;Bp}iAf0w$%%-HiV1+`KR6T=Kpk!oaJ<WiN{fq&h>L;7*u+IZEnEo^P)RB+CM6~> zsiq(+jwtUGCB;E)Uom+}c}Wfh8F3MiKc&UQMWE%KvVy26s18+-0ZpKRQWq%5WW_*Z zfgl?}GvhK+T56#3PC^(`-ib>pN{h-$iHm~EJ2`QXS^+U}PGKR?VmnarCm<#xEF&i_ zE(<R2<i&+S%XUHDlj8#w1E87`gk@!<Wh5ja`9@q^UQSLPqy~g#WmS|xGo_%i4unz5 zJ2`pKyaGra2ump_$-_G&^6F|Ts)|aW+$bi-$qAB|lLOfW!b(aYeM(9oQ4m&B)6><} z)7FOR*3dLC(9_q}0jULJ2?-@|LIx=olai8`S3xQ7K;;8i3RE^q%S!U|@qvv}R8Ue- z2YD3YL6~RcV2+cL6P0sNYZYYB=n-My<m48R5?7Ss;^7e%2W?jb+YBZ+xx~fA`PC#P zC1k`zrDV8;B}7zZ<R#=KMa8A1RV5_Ec=&k4MfgR;g~Y}9#l<-}MYuqXL2>X%tb&NJ z5HB~cxHu=jfQG0jzrHB91}CS00H25kzpNm)ngl1exVX3&moUE~zmPb$B0rBfXtGR_ zlb=smNLg8#AJmMKlvI@f`H-8Nn_E^!9t6bYLA_cD3Gn#6h=`I9NFEeNU?;-}&_FP_ zD=HxbfijY^;5H6OwV0TcI0VXw%Zfub|AR<QSz%#L2@z=#5iSuf5pEG4Q7%xwL`Ga* z9E9b?<;5lWK@e1;i3*DIgP^oH0;(!VKu7YqK+=#caf18;stO<|fu+0?R8__S#3iLg z<;B@J#l(3*o)VP<g@=ZQIG>1!sEC-b7<`uwIE6q7&}rGA@=lEhv{jUwPecPWP0ueb z11bq5Bt>N9CB((WWW+?JC6ooYr9`+y)Mdow<;67=q{Ssg1;u#zCHO&pm6jG469QM- zVj@D~+(P2qMtWMBav}z53X&q+obpJ176WzoRh4AKrNkvfKtUuX#A&3bB_*yUDI+c^ zqN*e>4pIRMcTgCERDgmI6lkJ?GUC!QQW9c9k|JVaAQ=#r5ayQ=P*s(b6b7v!5f+e? zkro#d;ZzWp<d+Z?0-f;znqP!qF)>*Y2~lotVNFe02^n!&NeM|2X)z@+85s#_RT+6{ z&}kgX5;Br%>N3g_5}J}C;$i~gVzR;#5+dTVpkNge7M2qc7nN5KmlYM37Zno|7Z4WY z<m6OR0uA_zi_413i_442N=S%Gh=WEdBt(_v#Dpb9r6eRpWyGb$6{Iwj<Rw5e8lV*@ zk|N5|poXiYxT2Jz6t|KrsKX0NLXx7Ya@?R`P*oBWlM#~<Q<4=E2CdzbkpOvFUR(lN zJxhv;2q??yXo!pROG=7Jiiyd{bA!AhCNC`^CN7~Yp&&0IAuKK;C@#Sz0-Ayo7ncAH z2+E7dD@sbr@p3E3NGM2%h;wm)@~*fdKfeHI_y>eR?IqA0ET}&uA|ffNpdhaZ3IY(8 zlT%YwRZ~`mt!?7r0S)*H3WDUs#pM+>G*s0gY;kdEB^7uTt)K{+aZpwP<wkLFsGacj zCMqf#p!o<Dm`)811ATo1U0s-VEp1~X14CUskn1EQBqSxJq*OpDm>;Y`T3S(2jh`R7 z?h3ruLqGr|1Bz4$2^l#l0ReunQOb(SikkfVFb~2!19P0DjJ&wKvqp~)gVtnG2GAm3 zX$fU%E?!;{2`+9<eyHsd5)$C$%L3|BQlM^-j4Zc^q^P=_qNJjfn7FL8x|E~@FF&t@ zsDP-1u!OjPgaj9tD7Okow}6m<gs7sZs4yQlpM(ULfPj{$sDPmuj}~Y!NkCLfKwgMP zLy`-$SWuE%L_k?USb|$wfL8((IZ~Vg{361ts;UB@W}K9iniR;3pz;nh`l+a>D4_^y z=1EFQLYitS!XSB2Jb`S6V^IlkH7X?v84i{bmX(qN^>#pt#l^*CBp^^$LQVp7EExoI z$%}|^Ns7vdigJS?uNXI|Um_==C;`HX5{eR10w4&QToe-$7XU$72?SJElmrEau!Jxd zNE*CwPC!sVKwS|8B}LgFxfF>MQdhwNBqU|U6eZX=#U&syq97$DEv=;`!4Db*5ElV$ zFhw#4$^@-+;^pP$SLX$9_!JP;;^7t+7LbsW0wpJDQ8`5^2?=poaWNSwRRL~kQEpL9 zISEBYNi8KA2`Molab5ul0dZMLMOkT3t`Zj(784g0mf#ka<Tf?b(UuoA(om8T<>pdE z^0PRoxuT{bD=8x(DGCZAabYe~Lme3j9Vs~pDN!{QMG24!P{4yi8KeRfgrGnZ6OxsX zk&~8`5S9`Z7YE6Ju%xJftdP38l$3~=gpja^pcLq+0#PnS326aIG3cy3$e$oAE-oh` zCC1GyqOC12DJKDH`isbltBA|VNy@6rD#}VqiA$(T$x3Nz%Be_7YJ+;bf)e8LBB1d- z@UmB7VNnH92{C152?a3`MKN)42|*FidAlkq5}-XX;CPo8my?tdlav6>L`aIM$b%Yf zGLlkaauPC<O43>?3X)R7pn+gXQ7KV187WZPS3*ffNrqcRUQ!I?PgzMRF*OBlP%x;e zfXYx=agZtE!g7*wk{~ZDN`S@!K|?H3Vj_Yn3VK=+;sR1qB9h_~vWnc2(&}>Jin5a8 z;*zRTN{W&owL%h-+@hkO#U~Pypa~UuQAJfLDFt3`C0R)&Nm0<cJ5XLw6#(r)1l5$F z!bCw{R$f{fl5eD>Kx=<NYCu>)0W_1Kq5{fiAk51P8t@eo0?A28C@N`bscS&k5)v}X zs!FO*ps1{^rJ<>!2Fi^R65OC1Edi>kAU3M0fn?RxU^=z6jf{+p_4T2eRdsYtO^uB8 z4Iru|K?z(96swRTT1G}mNkc#Yz77F2V=M?V3UtJZq@<jJjDUau#3*G|WgP(lDJh5t zVV;3GPD)l$LeX7gvM__*B5?*TE?yxyDK$B6K0Yxi(20eRat1_lf|j=kYRk&XC`d`j zDR7I)h-)gUNUO+5NXpA<%ScP{3Gzva3yMpNN=ph#NpW+F^Jq#*OG^ui2uX>nh>L;t zAWBJb3kvCpiwl}d@almY8-n6`f{G%%+R|LSQc{wVJYs@sLZVVUYJz;Aa}%UwI0Xeo zL^U)t1VP1`tgIGzff^_d6cxcGgOm!Wl_xDN4az~{;_BctW<c`NAge(eSU^q(bwtEv z#pT3hKx4e3GNKAHilD=1B&EPmP6`4QK-DlvGXyJ%iE)Eka^gH-$S1)As;3pDRHPK8 zRHQ*rMi2x+gU}Kpl0qOTFNJ{GDxgs$P`c&;NyC;h3Tdl=ptKlhJtJhe7)A<fYv2IV zGV&5CQtX_PQv4tfNh-_8$ja*JNePO9HfxB1HeA6BL8bZlcm%ciKpj6`L2*6MFs`7q zBIsZW8Ch{f6&YzMDFsOhc^OSXZaHxtaa~0z6%}bcRe5O{2@y#?At@nA1!)y|IVmYo za3Yoz7nR}_mE^TDHPBZQH`h^>73bzwkw*Ag5;VY}rLG_?CnX~e3L;5SZYxs*IVl5K zMJZWvEp-)XkP1+^g8~_(0u+QG10;kMq~sOkq@_e<#ib-cG9WB1CZHgstt~4nCLtvv zDkdxo+PW*wts*TeC@mo>2wK++_pp?dqL_>XH@BF+zLJchl)S8rteCu%x|E`#jJ&3T zih{I^q?Edhf{d=NqN=ovo{YG(q_C8vqL{R_xU@1Tt%!<>DT_->sH#dUiHoU#ni#^M z!{@lw)uqIx#iXSbrB$Srq!guPB&4N5LnJbi8cI@PvJ!IAG7^eX3eu`_dg{v3GNO{A z(qhu$GU6I?GN8IZN>xr(j$2((MnXXv1Z5;NmAOH|prtM)sUWE!sjes`CMl{2HXr0^ z8Bi%HCM6~#AttP<WTYo0B`7N^CL<-Kpu#O9tEnidq983PDXlJ}sv;vJCM_;3CCwu) zE+sB1EhQ}~BP^vXuB<LAqs-5(svxZ@BQC|m!vmT`RTmT#gpHPgR{JU_%0hdaGBT<v zDu|g<Wo>OO9SsfGyf+^oXaGb+1k{z1mR3>I)6>=gr9u#vSJzZihXNHfeLZbm4NcJC zp_CL4kF>Ou6k<L?Q&V40M^{q|qzi=g_07%AEKE$Gn$-=AtgXx~OiZCJ1Z8+laMXen zOUcWts_K9y0l<!eF3JPRfGm-gR#cV~6chv-rJ<&&W*{gC^Pn=+Gcd==DyT@Q`06YY zWiZ+x$-vFcE21c)rNAQ~AR)uU%P9o4T}DQRlSf8IMp$1_K~7mlN>Q0tLQYaoSzS(D z9yHUdFDEM_AS56oDGW;S(!#Pb+}x5p+A^}TvcjUmG7{>N;^G3l0x~i@!XifE;=(pk zd`6%(GD4C@!iu7N`m)@-pd8FAA*?AZF2k!S%r7G(BrGcr8ip0u(a{kGH&qq&z+<DJ zF?khbu;*mdm6Vi}WaYs9J4s1xaZsFqa*`~_YS4mikO#zN#T6w%P!2T4D=sIlEUyCU z-AjYk>c~igrb|IsSw=+$viToGa;r*6aLY+5NlNla@<{SZ@=Nh3%YdM|jIxZnEC|XA zgCM9xlM<B{20>6w4#WEDvf`lbi;OrANE)(aT0~e#UmXNx#n~Za#Ym*6zBUdZtEeQU zF2l|#Eh`N2khGe-yn=$Ek&KXpq_m{8IA{X`k~vT&XlzD6fLBOg0JJTIPe{^;k5^n= zSXM<|R#r|<K~hCsURG8H6q^b<!n}%-JQ9Y=GV1EGMjA@8@=~HQ{K7KA(#o>xN{Z4l z;-D%;N?KA(hF46M*WSv+SXI(SUqexnms?#H=3^xpX;3KZX)DVr%F0QCf=F7N+uq7V zQPxC3Sw>M(Pg`9UqyiM~pfCoh00kk)04Y&r8AT;USs8IfNf~L73<%4L3n~li=_$%f zNXd$dONb~aE6PYqa;wWK2+K-BM{Pm=1YsE&B?&nxUS0`f6BRjS8D&Lz1qme?Eg5BH zIVC-1btO4DX&Fs9WjO;wWlcFbBY8<#X%Sgz6$x2cNm*4;{t_1xSCf>LQrD1GmXJ^f zZ88A&kGZw9Km#DsvMRFbvZ^vFa`IAgvY<AcoV2#8jD)<DqO81>imZ~XhN6+Sx}2Q2 zG`JxrFR7^{2de*MG!-=!d9_sJq?KfqWtC;+q;yqzLBXJ>EhDWgtt_pjA|oL!4w3?S zSzT660+Mdzq$ET%mCcQ0WQ7$KBot+2l+}6V6!ny4)Rp99q-8bbG}PtfBxEH;W#o7y zC1fSUWo2c>WJP6EB~>*Q<<$guHI!vF<RoQycz8gQsG7pUBH$q|IZ*EiJhz|#X-&z? zYiOuzf>IU;tEuVh>ltWkgNqg(9v%S!NeKx_QBhD3$;zs08XM^wfYgDol9sL}3}}F5 zv~@wbQAUP`2XwxZrY6irU0ra0RTrcSgpG}DtgUS<EMU4#%pB}(Y%Q#y>OqTFbV0ES zPVh2HN}8Jb!k_~KL9XKE6%rB_0vApoOJrqL)D%TTguzDXXz6H~2n#DJLOclb49szg z%IY%eA%+{o8O-)cGw|>Th$_qJD)R~oO3LvH@CZX~my?s@;gyq<6E#*=QdE<ZQBmcS zQjj)O(^Ak<l#x?aHd2(A6BHJdlM<1Zmz0+ik(cM?mFCw6HNHRtB9dCtl9Gaaf^u>^ zBBEweQX&pAe5O1+qN2jmW+Lk1e8%!T0&;S43Vc!`x}p+t0=gnXa>BwQ@`^kn!V(ht z`ud`faV<mef(_8{l)9Q02*_!H+OYBp3ZQaMT3Qc$<^rfZ1lcSPT1Et7NXkosl9e(z zH-ZwHnxZ;llvPee9s*V6)#X7aPC_uRhLjYqg0!l%G`}>zG@rDf44;}j2x`l#$!me3 zq6i2=)267LsGO*(JOUbN$xDLzC~}f~GU9Tgu%$hsMp_^!F9}-D2#!!VA#S9P11Kn~ z%4o@P@W{zSQmm$;qLPxSnVg7}w5+tOq^vC53?zn#C}>huK-d`E3gQ!%HsceJkPwwu zQ<Rri1QnoKit_Sus&X<aN(Lf)%F=w&CTj9pS_)>`D)Ndl;&MWw@}hF83R<cvpdO*D zq=byDl!QE=go1#JorSrEw4<?(vNRvB7LuRkK;dktr>dYLuOJNyBw0yb7ds0Tc}pcV zd1YxsJuP{V3Q(#7g)&G5C<sA;CL^vYucD!>ASbCTEhh((0bzMbQ8jTRBV{Ei83l1k zDKRBAWqCPiUTt|L5qW7z$mkP{l#^4JQk3E2lQOqZS5T8vQ&v=xQkBz_Q&Uq=HB{A7 z1+B5rQ&d$nF;UZ1P&8MRmX{Tims6KgP>`0_0H+fPNlj^a8691DO<5@|S<nzZc%6fu zp1ibzq@ujKyq3I%th$1tjDkF9a!Wy0UqenxNmfNcQASN(RY6<XOixQeQBqD)UP@k4 zQCe43QASBdQC>$`N10DgT|pM)Pc;Qa8ADAzP%s$k$;pA*etPP1QnHe23Tg@<FKfvw zN<q?%qKuT7uBNq_oPvn5l9aNXoT?U|g0i8SoR+GBoSeL#qPCWTf~35(xV!?tw6r{E zshNU=f`q)5w3eQ-qNX69wwk=Qg0wt8KR?KOdLkksu=#&w@akS=Wk|kJRMgSd(gCRf zVNFeAa8DCDEW$4+2wI&dE)J5Dm)FuUH#0VYu;t}dbPaU$pg>y}B%^Nt%8l~!P&@Tt zHX0b1o0*sz7{YX#n>#u<INI7mHS1Ygxw<$y+1f$XgEG7UC<TjvHGpPwjYUMDr>%gN zqKFC$gJeL9nC0cwG?hg}M8HPr>l)}<h={;E2=ffgamuP%a$3=*`y?2w&d4(G@(PHn zDH^Ep2?<Fn@(J>ZLfTazl8;YOQBllXO;uS_K~7DBPexhRSW{O?S4B=iUDZrkNkK?d zNKr;iPDxrxK}=DRk5880NKr{iNla2qQCde<T1r?zSW%HzOx#LJO3YbKz>=3&TwFxf zN=!>qz+8z}0F;CIWyB1`q!a}W#Do<^Ma2|V_{2n|rHqY@#niySR2dW+pz=;jQx^mj zbwSNMB_(B04w92Il7`7Efvi?iQd9ytU0O+6O%4P>i424_RkV<neyAxzpoXHBBFJng z)|QsxQ<hbimF1V^mlco|lH&u_)0&EUikgbLN+75r27-{ZsUQY|ppiKkHq%j(R*(Wg zJ~>GRF+R{By_l$&xS0+JDoKOZ_dteEV5EeZ5e}fFsv)PV$jPUmC<gM7f{u!cs;Z@x zqNt24Xc$I89%ckG4YE*3NKn*VNK{BzSU^P93N)-CrlhH&q@)ZgKy_7=6cscS<kVD+ zMfucZ`D83K6?Ju$tn}2CROBQTgv1oZ6f~4{HPjUpK!t*ooV<*bqJWf=fTy#KwYIFQ zxxShlAD^xg%*X1ApkhYO)JQ`~O;K4E6hsPAe4frWYKpe1nu=<2rbfC-pm>1A5=aFo z2tfwONoXjlYpN+JNU6ywD1c-@SV>w`L)^?vRYh7(Nm5E$LRCXmQ9+hZPf1lwNfx}= z9OO#~R#4EAQI_N5ld-nZQr1+|P*YKr(NHi{(9~2`H`dVAP*PS<G*Z@3wy@ANP*%29 zkyBC-S5(lFQBsms(gvj!X(?$PStU6=eI;!<8C`h=1x0ZgNj^S4LqkPbC21ukEhSwg zZFwyvWjQ4!8F5*0WqBiQ1sN52H6;}}O+^hQeKl(%T_t5{1t}#NC218|19fFN6*(0} zeKmbGK0_^Kc?~5EB~2w|Ib&@;P%xMpDJW<tXebzJDaa^DX)0-gQ<bigvJ9wit0=83 zCnIj4?O>&-Af~1&qpF~wq06VNYOJZCtD&Tzpk$=1udA#qtt2a{sKhTTqbMz{q^KmN zB&nz)t81jDq9eqor=g^;EUU=R&kxE6Mq*;nc7w9AnwpNbrk1K2q|K(HqOYf?4@y}e ztfOOYW@c_=1S{`^gg__NNlJpcQc6m?2G&+)79e#XtZrzc4@<Lp2B03Pk%_XhtgNCU zKfjWaqN0%zsNe!&6BBDIb4wEwkS-9mwsv!MadUKp>9)1^@^o`|bOxza202hw)dU=` zAjOL6>iYWTVq&1<H$lg|@qx#!K{6mql$11e)I>$az(yGx7#rA#iNQPw^9;;!Y8twV zx=9viq!^rED>3l%3rXpyS!fH0iYllH2n$Mpiy1H>D4?dMCTXv&rKzu~tfMEOsG($I zV4`87sjQ->ZL6uSCMqGSrYNbTF0ZaCsiwxyuPA5<>e@@nN~y`4Dk{i{3W=(z@k>fM zD=0_?C<{69^GiudC^}0T$O_r3^9!k|sj3MnNSaH@sR^4)imFLSNUCcJN=nGeSzB96 zLi%~OnxN(!XcW`Hzyt);Oh9G2x`qZME-d9i@}PK92U)GIuBHxhyMnrcwh{<x%0r;O zrhz(0B?RlJL7<+Rfg0%K4hZHqR8ZvCP}EgY5>ygY5>gUX7Bo-;K~psYH4}9Z)RY84 zP(MUjRz(T~_0$m1))d^zkW-Tr1W7}tn585oZB0Q?T>*N04QS96ie>DqZ~!%JJ!KO$ zE<sf_aEvG$X=-X|IXSCIC@QHas>rK?XNI7rz$s8)MO0K+!d_GYv<Oi`(OF1HPEJzY zKvP{^Lql89z*JLRO%)WITGkQ*I*Nh{4hCu_ChE>+y6T$BvZ}&Ts!}R?>Lz+Rs%mno zs`7HmDvEMyLUL+Cp?+>IMv4LUX4*;u{3hxMKdXX5+164|T}Mqr5fntKa{QrwZaQji zS_W#`O173J>L3-Ma0i7lNChYeK?W$x=&9-G>!_>AX)CF!f@DBgT|q)$%Fa$(OF>y( zR!%`$OJ7?}Rf*qJT}x7389Isq@+SzZs_H9fDhmiGy0{u@7^v!MYicRzsamQU7-;C( z=$YtgXsW1MXzFP?IOv;cXgF#rsjEt>sTwM%t1GD+fzpb+jDoSEy0V#>y1tTviHfSK znzVu}KR>^vrJ9nuf||O4x{11>ih+ivvWB{Xw4$_zij|S7f|jz5x~8&$ny$K;j*F#< zhNirVjJkrlyrz=5j;6AfvX+{;j=7G2rJ;t3p1Pj8zPhHewUGcQ7;G(7RrOW%RV@uw z6;$O6z~+NItpT3FS5?qdR**K+_i|QKlhoEy&{kE`GZE0xwlPpO(bG^>QMb@EGttmc zP*;>u(-2frP?wiiS5ueMkXAENG&a-LG!_*w(^ogsP*ejAZK<iLnMq1YYCwUuwy}}E zp|&=t$WT<&)HE|SH3MZ85H>cpx3hDwvI38uflpFVQdCfqkpVR=)YVPRU7YP5LFzzQ z*TTl!3<@AJRyG<Mii&D#py4z%H8V4)g&H<CF3t{4Hnt#LAnf827~mi1?G4pz=H?k1 z5)kC=3sn!w@HU`Wl>{kP)73RIbC7_IQ$y!nKr-s;YHI5021YuPl9FJftSzi9TqPx8 z9)x)Y<~VIV6E%}U=ht!!3=9lR3=9m+3=9k+49pCSV48*b2m=Gd35Es+HU<v{1_n+B zUItl)Iz~fAb4CxwK*nIkc*aD=a>jbbiHwt&#F?a-%$UrX{Fx>(?O{63bdu>VvmkRD za~E?D^91IF%qy8UFrQ>T#}dzy%u>qoQ?6C+v%ILhth}qduY9<Cq<pk|tbDEf7WpIc zm*sEEznA|a|4%_wK}tbRK}kVP!9c-E!9}4!p;Dn!VU5B%g)Is@6@?YW6qOX!6!jG2 zl$ey*l=wj<laiE@oRYSZhf;!4s_Ll+|KI=p!1Uq&Cx)+rKV*Ni{QnQ~Dgy`DCkhM; z7>yV$7(E$-7(*Bn7?T()7#kQTF-~TZV3J`nXR-kM<QUUQrW?$H%q`3v%stF~%oCXx zGp}Oa$b5>0fhCEhh^0%eLGF`0gS>>ig1m=(0MsY(@(uFa<d4Z;lfNtfN&cIHu!4kw zjDiB#CzfEJlqfV{@kub$CxS|%N|JD&q(FVb0QL#n4~E|i|Nk>E{{Q*^>;EVJAOFAo z|IYur|L-v{{J+D%@c$MA14G)sOAM+19T^h;t1%=n$o*Ty!0>N61H=CX3=9me46Y0e z3|tJHkXZk>|KGlU8~)9C^z2d9lgCdUKDqzo?vvY3ZaumA<i?ZhPp&?>^5pW9i%%{* zIrro&IP@487#=u0uwr0%sP*9ggFg?x-QV`;^`j?G^d96sYPcWrpyEL=1H=6p_ov?P zWnj49eZS*=>;206vG?8XJKw&q{8Ho%1B38eq1QYeoEsS!z-bL;ER5d9z`y{*Ffkat ziGhIugm-}j5F{wAf`y187BDPfSi!IctQJConHa<rsK^p1jY>^|bNd(=7;+gZ8L}D5 z8LAkA7=sx@7(*Gu7{eJO7&5{6B#AMZF@>R&F_rlN^CRXn%$Jz2FyCUnz<incD)Tkw z>&!Qp?=jzGzQcT*`7ZN)hGK>i=IIQ14EYSn3<V5@45<w13>geX3^@!Hj7^Nqj2(=f zj9rY~j6ICKj4h0<jBSkVjB^?1v%F$l&A66v72_Jlb&Ts7H#pcwgoXqM1qS&0`TBT! zd3w0Jxw<$zIXc+e+1glJSz4Hznd<B5>S(B`swgYTONa;xfbKs7T^7O0!py|TpyR6S zmY}dfEnx$TnzDz7E{Lz3sIWmT5lJ9ngF>Rh1~*hS8x%nL5;rKIs&q(H*pP;%(gC8< z0j`o!K*5H=Mps9{Ras%fSr=u69gI<d5y}c1dR>$w6*jyDvwgvA7Bw)3*F`x}QBhY% zLBUlb!$n~OV}gR~2Dh?|F4qJXT^+`a++5De&Z%6wIt&}RxSf@qxpj3IHfSgpY-H51 zV+1ppG+eDVGBI%Qf~?)Ztm>MWvLPTa!qr7uQ85xM#^4M#YXh6}26nJn3Yi-m5;rh( zD{Ry`(ABd;fFUVCn<qs%B{3#q19M`eu1*)TYgbqM20`r&n#wL4G^?IS=<2wpZqQM7 zaowP;?4qmV9R#<4aRaNWfU-i@SB4FY3CeHZ=;|PgBtk{lR0X~=fFwa)ga^|G#zYvK zVFSDK1~!oY6cs^k>E7YMkff`lxS=610>W2dNRr;f;GnM^xq&GGBy#{JBN_^lX@JQ< zlshmaB`7O`BHlGY8G_0(BsMf8Dd_5KU{M1j)eS7FuF9?o8<^D+l9Dq(bYg0kvWp8O z+(IHYIJhXdZg5D12Bzyq1ASLz*TjSkj0u^bU<i!Zps!r8K}6XJ658H@5elF<$PA7E zYk`QmZV++az>ts()wMz2)dl1(1=lW6#JDPh<Jwy}Fk&Zz@&5-KO%$Xz8#9<NM1tJ0 zLCiT~1GAcIS42wM2KfZ(lnn|AX$le2iW?jvH!vnfDo3P7f?`QoV1woZX>d~@QdbA8 zi$&EHO*KsM1{T!~?5Z3J5lqs|k)Zh7z@n<)wt+=e*~w-DyQ;tjHV79KqfRyo5scCd zFl8GwA3#-rSSaSOsBU0Zb@tf62BJ4GCOCUYD@KA{1WMsJog@wMzy?;;4IFScv8W1c zV1+mW<R*wcP>aDXMR6KLHON7l3a+UxP<0?ailM9^hdFydeUHPPOrWp;TcPZ<fmM|Q z6rCO*U+C&EZD3Ya*ubL7v4Kg|86*aYMG1us3;_xe%Bjkc${7k990DRhkpl{AaQu1) zD|-h<MSx=)k{-N6P<Rk|3%DGVwSmbwBw~Y`HaJp2aSG<UgZXf-2bc@W=n&<eU_L0{ zcX23t2X}#NR)!kLpwQ*HfgvzrgM+d{mxYiC#6m_lWw!)nw?t(H0R^|N#2x<|lDalJ zICK@bCS+LY>L_$6d!}?L2S?aQgKY5*il~*Y0$DG_;LYeA;-ssyk%_@+qcUSh;6?|= zj^L<>odOID3LPO48<`lLA|rKmTqQD;y+MVYt3nDWaB3nmx)LH?bagf`h;3j}-N2;E zxPejGj$s3nvfV~TCN`c8T*|3V8@QF7K;rfwaeIh38%Ug8*=Yl#7^5!3E(T6UW=5t> zDe?-Ayo^i?28>J$4vb6;%nS*PObi8#Obkr_4=^%qGP88s$;imauxXjRfujT?0|O%? z2g3wLc7{+!Hiij|oD89itPB$vSr|eY>>1e^7#a8(Sr`}@&NH$wyk}%__`uZaq9Who zq9WhiWG3IlB`4qLk|N*Wk|OU=&sbk6An#DeSXUt+Ut1|4zo3S(rb0lTznZbSQb3-+ zim|FfK%SveppuD$znoF3%t=$e#3e<($R$O-z$ryO-zh~t&nZPd(<Mbd-6cgn)g?tf z*(F6j(IrJb(j`Sc+$BXm)Fnke*d;|i&?QCQ$0bGH%OyqL!zD%D%_T*i!G+OzgUbOI zrUNb)T$o&(q~)C*#pM~C7#}!&aAI<D6qgrbN|EQ);s|BcVhUwpN|EQ+;tS>0;tFNg zVhd%|VhG*OaG&8n12d-<M<}xvQ)oUDV@U80MuyE|jI4}17$<n|VE-TFy@4YjdIMv} z2Gw8?=@1yTfvsZ$LugcV#70KOzR1?zUIr&w?+voS5gQiBMtW~(2#(mmAiGhF!6{N( zo1s`+yBO3F0AWV$;t~dJM(vW)xMFPvZEbBvZ8QqZjVq2T)-KU50ciwLAg$WPB_LxM zOSBoZOG>pR;ux4%8F-ksG6;Y$0|NsO1H=Ch%p9PW&HoSo-Ts5xiVRF!L42BFM+Qd* z8wM8!25>(EM8hz|N=F6;u&f0G1A`3%0|ST;k%5pPzB>a01ExI4WM`-@h*}GVD26Bo zI|e(3FopmIa|Uye-AqrJJ~HSqXfWDB6pbh{mLY*5gdu>5gMs(|S7t^AcChdH7}kM$ zf-In30;rwK%)rRP$-u}U!7vFb&cwjSunNj%X3$~S0cEowsbOW{V0Z!*XJe3Hcmrj# zGc+)=K-nA&a*QodHYXCBo56%}5mcOq!HDr1lr6x(!^8n)3o>vqi7+@b<TDg7R5BDX zWP;ntB@7A-MhpfFh75)b3JlH+iQtxW9z!NWGD9Lm4ub-N4?_t<3WFYl0+<aFPi4qs zC}v1yNMTT5C}qfFNMT52C}L1xC}GH8NM%r92xUkDi<dBzGAJ;(G2}DkF@Wsx0h<ca zoeWlI$e_nyfTkLx2Go8pVF&`Z<VzWH7!nzZ7z`Nn7%Ui!8LSyx7#taV85|j`QFQ7u zAaugiBD(`w9$8!g>|>Bmu=yQiGss6pV7Fv~`Wj%rLi}FBP{fc3_I)ncjoA#v3<?bS z3~2;H(ghr{CE##TVDM!~WJqL42Zt^wq%#;w7)lrl7_1od8T1*-!BCGO1xXLcHjoZI zhGd3(hFk`H2GaE)hXEqwKqDIr3=IFLgUbhS;zlM|-hz9fpbC$Lft7)cft`Vafs=uY zft!JcftP`gfuBKuL6AX+L6|{=L6kv^L7YK?L6Sj=L7G8^L6$*|L7qW@L6Jd;L772? zL6t#`L7hQ^L6bp?L7PE`L6<>~K_8qFjTk^}QBwvp26F}r21^Dj25SZz23rO@273kv z21f=b24@Br23H0*26qMz22Tbr25$x*244n027iVChCqfOhG2#ehERquhH!=mhDe4e zhG>QuhFFF;hIobqhD1gth9eBi7>+WmW@utK#BhV*7{e2WeGD5IwlQpH*ut=tp`Bq9 z!)At?3_T1_8TK=5WZ2Ef%&?fDg`t&UFT*s37YuC-eGJ_Ua~W1LEMa6}=wi6V(9AHI z;S<AWhF*s03>^%I8SXKBWSGaWis3867lv;Pix}22oMbr8u#O>#A(>$ULkh!bhE#?V z45t{*Gn`>K%W#h2B|{p+Wrhn37a7(wq%(YAn8<LA;R?f5h75++3~w1a8L}8M8L}C2 z8FCo%7#1+(GZZouFcdKqGn6uvFqAQrGrVG`V5nlKWT<ASWvF4;!LX2_o}rGRfuWJ% z4Z}NzU5u=ZY>e!T9E_ZdTnv91{xNbh@-Xr;@-h5pWMJfH6krr&WMmX#6lN4*6lD}+ z6lauRlw_1*lxCD+lx6tA@RL!FQJztOQIS!JQJGPNQI%1RQJqnPQIk=NQJYbRQJ3Kt z!*xbIMtw#DMngs;Mq@@3MpH&JMsr3BMoUI3Mr%eJMq5TZhDQvK8SNPz7#$g%7@Zki z7+o3N7~L5?7(E%i7`+*N7=0Q282uRo7z5Ep1%5O9VT@#qVvJ^tVT@&rV~l4^U`%8L zj}kDZGNv)6GiESmGG;MmGv+YnGUhSnGZruwG8QpBV|dP3%vi!$%2>u&&RD@%$?%Y| zis3fH9frFM4;bz<Rx{Qx)-u*H)-yIRHZnFb>|tz%j}Rb_8uT&tGfn`F+%ZmOoWeMj zaT?=v#u<z=8D}xhW}L$~mvJ8Be8vTg3mF$NE@oW9xRh}j<8sCoj4K&eF|KA@!?>1V z2Ez)5Sqw87*D<^YjWjSUWthY;hhaHGKf@G;sSG<AH!>Vx+{AE@aWmr<#;uIo7`HR- zVBE>Li*YyO9>%?l`xy5#9$-Akc!=>Z;}OQAjK>&{GoD~P$#{zKG~*e@vyA5$&of?N zyvTTo@iOBT#;c6i7_T$lV7$qAi}5z&9mczi_ZaUpK45&v_=xc_;}gcGjL#UKGrnMa z$@q%#HRBt`w~X%?-!pz-{K)u;@iXHW#;=Uu7{4?AVEoDWi}5$(AI86o{}}%>F)%SQ zF)=YSu`sbRu`#hTaWHW*aWQc-@i6f+@iFl;2`~vV2{8#Xi7<&Wi7|;YNiaz=Nij(? z$uP+>$uY?@DKIHADKRNCsW7QBsWGWDX)tLrX)$Rt=`iUs=`rau888_#88I0%nJ}4x zM@B7}ESapBteI?>Y?<ts?3o;x9GRS$oS9shT$$XM+?hO>Jej<hyqSEMe3|^1{Fwrn z0-1uCf|){?LYcyt!kHqNBAKF?qM2ftVwvKY;+Ybd5}A^il9^JNQkl}2(wQ=tGMTcN zvYB$2a+&g&@|g;l3Ym(SikV87N}0--%9$#dDw(R7s+nq-YMJVo>X{mt8kw4ynweUd zTAA9I+L=0-I+?ndx|w>IdYSr|`k5v$O=OzHG?{4%(^RHuOw*ZWFwJC|#Wb5~4%1ww zc}(+}7BDSjTEw)NX$jL(re#danN~2ZWLm|vnrRKwTBdbO>zOt%ZDiWSw3%rO(^jT! zOxu}uFzsa8#k8Ag57S<zeN6kA4lo^LI>dCC=?K$NrejRUnNBdBWIDxkn&}MFS*CML z=b0`rU1Yk%beZW2(^aNxOxKxiFx_Oj#dMqL4%1zxdrbG49xy#*dc^dY=?T+Qre{pg znO-oxWO~K)n&}PGTc&qR@0mU@ePsH?^qJ`k(^sZ%Oy8M)F#Tlu#q^u$57S?!e@y?G z8JHQFnV6ZGS(sUw*_hdxIhZ+_xtO_`d6;>b`Iz~c1(*eyg_wnzMVLjI#hAsJC730d zrI@9eWte4|<(TD}6_^#7m6(;8RhU(o)tJ?pHJCM-wV1V;b(nRT^_caU4VVp?jhKy@ zO_)uY&6v%ZEtoBtt(dKuZJ2GD?U?PE9he=NotT}OU6@^&-I(2(J(xY2y_mh3eVBcj z{h0lk1DFGugP4PvLzqLE!<fUFBbXzZqnM+aW0+%^<Cx=_6POd3lbDm4Q<zhk)0oql zGng}(vzW7)bC`3P^O*CQ3z!R;i<pa<OPEWU%b3fVE0`;ptC*{qYnW@9>zM1A8<-oJ zo0yxKTbNs!+nC##JD59}yO_I~dzgEf`<VNgCooTBp2R$vc?$DX=4s5+nP)K1WS+%5 zn|TiNT;_Sq^O+YgFJxZCyqI|j^HSzz%*&ZqFt21@#k`t%4f9&&b<FFTH!yEx-o(6_ zc?<Ja=4}k~nYT0VVBX2Ri+MNm9_GEw`<VALA7DPne2Dol^AYBw46~V!F&}3>!F-bW z6!U53Gt6h1&oQ58zQBBu`4aPG<}2_~(woe;m~S)RVZO_JkNH0H1LlX!kC-1bKVg2# z{EYcI^9$ye%&(YVGrwVe%lwY{J@W_VkIbK#KQn(}{>uD~`8)Fu=AX>Jn13_>VgAef zkNH0f0}CSy6ALp73kxd?8w)!N2MZ?)7YjEF4+}2~9}7Q=0E-}t5Q{L22#Y9-7>hWI z1dAk#6pJ*A42vv_9E&`Q0*fMx5{oj63X3X>8jCuM28$+(7K=8E4vQ{}9*aJU0gEAv z5sNX435zL<8H+iK1&bw%6^k{C4T~*{9g97S1B)Yz6N@v83yUj@8;d)O2a6|*7mGKG z4~s90AB#Ur081cC5KAyi2umnS7)v-y1WP1K6iYNq3`;Ca97{Y)0!t!G5=$~m3QH<W z8cRA$21_PO7E3lu4ofae9!ow;0ZSoE5lb;k2}>zU8A~}!1xqDM6-zZs4NEOc9ZNk+ z14|=I6H7Bo3rj0Y8%sM&2TLbQ7fUxw4@)mgA4@;W1eS>`lUOFROktVIGL2<A%M6y8 zEVEc<v&><c%QBB;KFb1@g)EC$7PBm2S<14EWjV_VmX$24SXQ&FVOh(vj%7W|29}L1 zn^-opY+>2TvW;as%MO;EEW22Cv+QBn%d(GUKg$7@gDi(w4znC#Im&X3<v7a;mXj=} zSWdH?VL8ikj^#Yd1(u5}msl>dTw%G&a*gFW%MF&BEVo#0v)p01%W{w9KFb4^hb)g+ z9<w}QdCKyP<vGg>mX|EASYETdVR_5)j^#azYiUtFdud*#fq|m|ly+fv%uP%#%Fkm@ zgwSk`Nkyq;scea0irq0kJwGosn>`Uib2}$z7A2SFrsbqoa3>?!T+S)^C5g$&sd*(_ z$#5o{OLAgSejZy2gmOtP%1<m|cZFEQo(iGaT){T6rGhCgSGaDjR5+8}6=FSmDuiZp zg_xKMrnub^4&hEmu({mf25_arnLO^v`MJ4?5XbVQBXPJr5PG>Y5NsBYq{JeYjHJXO zHqVmGoRn0yOfbdn3Go4YCWL151bcxk6HKvtLfp!p38C3C^>Xr)bC^86m@>1ty%5H8 zXCc@;-bn7@$wuM`W~UdWrsm}&=A~pN>m?@^r}7|+u=yk=mzJcm<$x(3A0(YTIY=Bf zA8?Sd<$x&`pOpL(mYkIQ5;i}uMz%aK#p8#hg(nY*!{!Ip$d(7D1o9FK@{3D~@(VIj z!EWM45@2@COJ`2aOK10o1P^;Ygl6*xhYwpmnBw)%D9uYxEGo^-Nh~el%}3_31%X|| zRs^Paf{@(7Q-s7}3j(`_tq4K|gO#%tgDLJ{MCfuCBiP{dZ)j!&rH$D_Aeu|S6iWyw z%1b~|9tw_Ywo)*~9t!aTdntrw3k7?EtrSeLh8AV!rL&fTNZwFnpYWC<^VmZnNrJr; zLUV^B+{Imv#EwK_S0dPKQDCpJRe>q4D0tfDs)93FbMo`ji+O^Pf{CXXi39exk)b1$ zcH&6MFUd(QF3#dYL@7kh*^(_hy(qCPm8~2?g@dhSD+g1Y;VGG^MXAM^#hm3Z7F#4l zVI`R2sY)%%FD@-eEy~O<;z`RdEkbZI%Ti$+?&8b}7@M~^wJbFc&P&Zq&nSWMc=9sy z;F`c@XXd4W&F0R}ONFyb%JX4t-ja->RJaV-1u!X|w9GQN49EpA35W~eJg^I3Jg^I3 z9L~hF%uF*wBMVE;)Z&uN+{BX96psA7)RK(Mq7=@O@_aC(m@_{w6>LZb57;#jb}=`| zekhv<>}e<mWJFqKT1f`T2r#3V8>|t+<^lyHgb7kvoLNzl!38o4%qr%}&r1cHR+7P8 zQl1ZG7DEC8#sdcgf&+>QP^gz=6oUc+Cc%-PmztNE2XP3PQ4F>m#>^~(NEdU1bwGr< z!M=d7L5ax7(8vr-8yT8HX-np`_%!CU_)O-s_#D=>__X*;Fqy*vN-lbdIVJ4*d8uH! zq&y!)bAtU3VsU_c4`F1MLDUpyR)7RJ!Hxj4z)k?MI6w&o#NY&*TwGdE4Dys7m;o{y z#NdJ$4q|eGO^2{R#)DWKAoD>CE{FwSCd391lM`$Om<6#TGcOHd2Z#Z-BQq}zW=CdT z8q^Lj3v35S9oP<#I;b5WCe#j)LWmt87PyE4`G^}SL-Q4-mLYNZ;fnN%GxCc{I0F)k zQuA_B(@MC&tRhgJV)Ljh$Vkm&4bDj{&R}vZ;dUxYOwLX%0V(5jN=(i!21)QhMZsD) zL7Y^O0xmEMEXeC#nwe9anU|gel3;Tx&B;kEVROw(Ni5D_bIk=&d~hXT9bB#z$vKI+ zDf#7jV9hzHxuEhVtpuzN%mb_A2`mL=tNc8WN>I`<Ftjj$(uPnPT83K~L-{698l2P& z3@yM(&%n^a94c-Br7fW}qzp8)aDwukp|lH>c7@Vz5ZV%Iza`XuOQ^k;P<t(*_F6*i zwS?Mh3ANV}YOf{KUQ4LGmQZ^wq4rur?X`4c_0PyJ%45w3kq|dHLjCLrwc8PDw<FYU zN2uM7P`e$Wb~{4tc7)pP2({Z0YPTcQZbw(vP>^k`r63YwrxVn_PEfm@pmsY!?RJ9N z?F6;k32L_!)NUuJ-A+)uouGC*LG5;e+U*4Ow-YqHouT3F47J}GYQHnoerKrt&QSZE zq4qmN?RSRS?+mry8EU^X)P85E{mxMPouT$SL+y8g+V29j-vw&F3)FrWsQoTb`(2>+ zyFl%Cf!gl^wciD5zYElU7pVO%Q2Sk=_PapscZJ&T3bo%AYQHPgepjgduBL46;7re! z4yGWn<_fjl6>7UH)OJ^>?XFPUU7@zSLTz`2+U^Fm-3@BH8`O3;sO@f0+ufkPcZ2%g z4eEP0sQqr1T#lf25xCL7l?Z3DJGz2}*b`I1G`Byv%z?1;5p1w;j0_;IF*1O-#>fET z8Y2UUYm5vat}!xzxW>o;;u<3Zh--`tAg(bofP{vT0VFhx3?QLlWB>^bBLhfi7#To9 z!^i*<8b*dt`wgM?8$#_jgxYTiwI5Q=85kKt?KgzlZwNKt5Nf_5)O<sz`9@IljiBZm zLCrUUnr{R(-w5hINVRHUWCS(e2<ks0sQ-+h{xgF5&j@P25!8MosQpGz`;DRY8$<0k zhT3lowci+OzcJK)W2pa(q4pX>?KOtlYYes57;3LE)Lvt#y~a>`O`!IgK<zbw+G_%} z*92;>3DjN_sJ$jof15z<H-Xx30=3@+YQG88eiNwuCQ$oLp!QoD@qt_L@tJvLsYNBJ zDLg6prAaxd@!%FL7bvyHgOembtc@EF=>Tx0<d;C1d~hkSCIlC(5y1sp!~^b5Ky`A0 zRp=!aC-Wg1`5>+ks*!rhi3JEDh$a*vu+bn@;M`(jU;u8N8X6cF8N*nnhH#b<oQ04z zfs2{KS!QsSIh<tyXIa8nW^migAhBp--~yL}*=1+~H`fqunjzdYL%3;1aMO(7E;fRh zVqgS!rxDydBe*+_;3gZvO*Vp?Yz#Nq7;dsL++<^TSQx|YFoxS<47bA=Zig}44r90- zCU84U;C7h6?J$AcVFI@U5iTZhJ51nqn858Yf!kpQca<4jhZ$Ul8C-`MT!%T_RpxNF zn8V#-4tI+=++=gO$>wmA&EY1S!%en;n`{C1uLax=3%DH?a62sEc38meuz=fP0k^{f zZigk@4okQlmT)^P;dWTU?XZN~VF|Yb+8Q%4g_~xMFb!sxAw1_88o*=?4PbT|8o=x_ zG=SM<XaKXz&;Vwap#jW4h6XVI7(#0v6H{2;Ff@d@#L&{157e79G%zqT0}mEKvN3d! z!o&<x7@C+v3P%%j7#~tNnwUchM-vN3;b#JE(3)643O^GINa1H<0V(`UEZkt(6A>8D zMyiRW5yWn2<JJV)xHW+`ZcU(#TN7At!u3H4U=wKL)&$zPHGwv6O`wfi6KLbs#1c{f zn?MI7O&p;`2eg4}0&U=$KpVIw&<3svw1H~^ZQz<f8@MLW2CfOTfolS7;F>@ixF*mB zu8AWw3>=~MJ3%TS6KEsX1lq_ofi`kYpp9G;Xd~AI+Q>D5HgZj%ja(CGBi97l$TfjB za!sI(ToY&`*96+gHGwvAO`wfj6KEsX1lq_ofi`kYpp9G;Xd~AI+Q>D5HgZj%ja(CG zBi97l$TfjBa!sI(ToY&`*96+gHGwvAO`wfj6KEsX1lq_ofi`kYpp9G;Xd~AI+Q>D5 zHgZj%ja(CGBi97l$TfjBa!sI(ToY&`*96+gHGwvAO`wfj6KEsX1lq_ofi`kYpp9G; zS7;h{g){(6Tp<kr6KFHn1lr6sfi`nZpv_zpXfxLY+RQb9Hgip&&0G^`GuH&#%r${F zb4{SlToY(B*96+kHGwvBO`y$O6KFHn#0^q}m>C+ILW&Cm18Aeu$N-Xtj0_xI*|Lig zOR^JL9dimY5?P#5OA=Y#6LWJD!FkTu(uCc$pg1!pKaV9UwIq?*wIq=>BqOyXk;OB= zB$3rSv7jK4%_lK8DJ7A~x0K1Rlqn*U-9I-IG>p#_kjWgBk<S{OnVy@-9Fkbd9ttsv zIUpmG**PPVIiNU`H4$uY63E_kkiD!asW~Ny?5Pl?EL9*|L6Rk4TQfnnW`k|b0ow|3 zrjs*MUMW*WCVM`_YUZMhe70hcy@sq{in%1Ql)V&UCUZeXCUbH|CUZe?CTo6fYI-6U z*z+a%dHKaWQ15~{9IhpaU<N11w_p|zhy`m3@j!hFk%9US%;AS}U@E!%LH2;9xWGOJ zGkKuC1aml2VU~eB2^Qi3v0z3BB6A?pP~U<%{7~P)l=DFOU`Y`u2PO#?<O2I0%wz?V zoL~~vX5&Rs2nu6EH%NcS&<)bxF?56UcMRPi{T)L$NPoxB4btB+bc6JF4Ba689YZ%r zf5*@bQtKGHLHav}Zjk<tp&O*XW9SCy?-;s4`a6bhkp7OL8>GKu=mzQU7`j3FJBDtM z{*Iv=q`zb62I=n@x*3Ao;f8L8;A+6o4KluL=w=9Rha0*<`agzlkp7RM8>GKu=mzQU z7`j3FJBDtM{*Iv=q`zb62I=n@x<UFohHjAlj-eZ*zhmeI>F*f2LHav}Zjk<tp&O*X zW9SCy?-;s4`a6bhkp7OL8>GKu=mzQU7`j3FJBDtM{*Iv=q`zb62I=n@x<UFohHl2- zV#m-8((f^JgY<h0-5~uQLpMmj$IuPZ?=f_P^m`25ApIUgH%Pz7&<)b>F?56Udkozm z{T@R%NWaI>4bty1bc6JJ4Ba68978uqKgZAw($6t;gY<I@-5~uOLpMl2$I#6bT&)?p znS!fTLpM`!wQlHU3T`?Wx|xET4u)=~;9}L#%@ka$8oHT6{bve|M^kA0nSz@ThHj?d zYSYlo3~D~4#c${aX{s2yL7FOtZe~#P&7kI+LCrUVnr{Y8FJ@4C%)m_-LpL+1eP-aM zi=mqt)Lt`iwQcBT2DRTDYQH(ue~_k;p&O)WWawrNwI9+nGITSC+HVfE-yCYcIn;i0 zsQu<p`^};Dn?vn~j6E8<SwQWxfQBbz?9tH80&1TH)W49iM?*Kr*rTDF1=KzZsC|&8 znxPw{sb=T~X{s5zL7HlYZjh#$p_>KNzmTy<LpR9SqoEt5nP%t)X{H&vL7HiXZjff0 zp&O)`X6OcKrWv|HnrVh^kTFO@H%K$h&<)Z|GjxMA(+u4p%``(dNHfjQ4bn_Abb~b0 z4Ba5jG($H?GtJNq(o8dSgEZ3&-5||0LpMk>&Cm_fOfz(YG}8>-Ak8#GH%K$h&<)Z| zGjxMA(+u4p%``(dNHfjQ4bn_Abb~b04Ba5jG($H?GtJNq(o8dSgH*AGZjk1gp&O)m zX6OcKo*BA9nrDV?kmi}88>D$==mu$?8M;B5XNGQ&=9!@zq<LoO25Fudx<Q&}hHjAN znV}n`d1mMaX`UInL7HWTZjfe~p&O)GX6OcKmKnN1nq`J=kY<^o8>Crg=mu$)8M;B5 zWrl8$W|^TIq*-R@25FWVx<Q&{hHj8%nV}n`8D{7PX@(iPL7HKPZjfe}p&O(bX6OcK zh8em+nqP))kmi@68>IPV=mu$i8M;B5Uxsdw=9i%xr1@p&25Ej7x<Q&>hHjANm!TV^ z`DN$^X?_{HL7HENZjk1ep&O+6W#|TJei^z!nqP))kmi@68>IPV=mu$S8M;B5TZV3s z=9Zxwq?u*t<_68LkY<*ln;SI$xIy!)8#KSVLG!B{G{3sJa+l_1g7O`Bx&*?uL}EK4 zv7M0E&PZ$*B(^IO+YQ0Cv_N7bnQw_?z9o|RmPqDXBAIWAWWFVm`Ho2DJ0h9yh-AJa zlKGBE<~t&p?}%i+Ba-<}Na~%D*lu7ps4Ze-0LeS925#W8$kh!J#%=}%25hBCrFkW# zAZ2a_Zf@Yc(;N&;3{nh?|Nn#5rh|9bIx%!GFeg_Q<uHh(7o}!1DC8uT<T2<luz=Pd zgLd#TFd(rRk=RTO%-N|$c?=?8QUy#JF)%Q&LDwon)+sYEutNFFV7;JS8;lI7E18AZ zv@9>=9+>)O-jvhpehGm0c{4I_FfcOC1M4}(Y{qQH!pCgG9KsyJe2m$N*@<}(^9<%b z<{ajA%qy6in2VS_nEjZGnEjYTn4_4}z&L?<9Ww|o0;`<?Rl9<D1@jE%H0B&IT*q9* z!pGdiT*IQk+{E0$yoLD>^B?9us1A_Xi<m(=)-kVR;bY-rv0;&7KEr&5`52243kUNb z78@2m7AY15=3~rTSaevVm~XHcG4Epj08+!E1A@#4n9s1-fb@Y?9b*Q;8=$?lAl1yT zm|rn}VEzWOl|_n$iG_oOgN2Vpghd2oDvKJ(1TZ#Y0YMuUKh_;A3)r~WxLEeFak1QC z`Nqn?s>FJMHHWp0bq4Dy)*T=@)(fl`SWmI;KtPZvJ`C0Y(zOF>!YS5MF!eAw7#~J6 zFfyECV1=$h=LfGo2dy;MV=xD=9EYqM_hd+5NCvOePXq670PPb7t(cz1u!B*EQH0Ts z(SgyAF#x=~J&ZA$F^(~fF@rISF^6##cvUTIP3;}Vhm21ce=`1M5@Zr)l3>zg@?{ER zYGs<tv<|#V6SPJXv^MiJ(^;nT;5C_$6`41gZZq9QT7~%%vi6c$iCKkNgIR}JkJ*UX zgxQSQg4v4MhS`DHiP?qOjoE|Qi`j?Sk2#Dvf;oygmN}j|hdGbAh`EHhjJbljin)fl ziMfTjjkyDSazG#RB<8uykhO-Om4={IhO3|}3_<G)cQ7A-t`r2V61;`Hx({PDANuM& z*s46(YBtacHrVPl*g7?|b!QBW%qk4LAoBl9F!>Ekegcz+z~p`q$si0O|KA6Z3|!#Y z6k=dvIL;u)aEd_#EUFG7|Gxv1SHW`J45t_r7?>DNGKeyqVvq;x5(SeG6EwkUKZD69 zAd<lZOe%oL{};gIbp{q@F$OJW6$TCF>i^f7JO94{i8JVcNf{9N|1g+@h|7Z6`#>ZE zKZyMQ9Yiw7fyn<KK_r6!i2VPQfr(j+L6cbpRDL1ZaRY3J2=h$_6XshC8O&e*uVw!B z{~!zh|LZIQ{~xjlGMKOkF$A*+Gl;T?Fxaz*{(sFP_Ww1DFM|k+-~VeYiy7oumN3|{ zEM>@GS;mmTvYbJIWd(x?13U9g26^UN44y2445BPT3|cHA4Du{X8BAH0F_?m7MVW6g z*s};SaI*+8n6QX2D6lMLuwz-qV8_4)*Nf0+!m^CPgn@}=DT4^hG6oR_4(6K-TFkc? zf>{I^<XD6lG+9I#G+CA~@USdpuxDAuV9y`~SHb`P8q_cLP`_}oh%nf(i2i@YBKH3k zi!TEgi{Jl;EQ=XLSe7tou`FeXWm(1$$FiJ3gk=STCfGd+%(ocqko*C04-4}x1}zpL z22qx!4Du|?7~~mPpdlH@vXmhXWC{Z_^DPDwmZc0J9rg?$9iR{eg@qQ&G6pRMM&`W? zA`E<R*9wA73}z8wFkun>|C>eZ|8KAh7c+3PECGj7Fv~KAV3y?!qAV*w>5TangFL)m zU{V(V)e9Q$^z0Z+7!p9c0snvg|NZ~_|8M_)`~MkKa}6#0|JnZ!3=B~Bzxx06|EvGs z|9>A^egL@^<O>kZz`!8D!0`X||8M`_F>r(H2rj7jprrnP{QsVT;s4wJZ~lKBis^@e zfkB;tn?Zs>ok5*J<o`nk1_pixhW{@Z82<lY5C-iF`+xoa_y5-zI0t8{`v2_z=l|dS zfB66I|F{2dK{0`vOa`Z`QQb&1542mAXvOqZ`2Q2wK5hmE24Qfi`s4pgun1^x;s+4x z|2qbT{~y8d-T!z0KmPyz|0_s*VDbO=Na^Pj1H({DFaO{C|MdSWwEX|bz<?g6|9=i_ z_!6@H|LgxB{(t}f7F_>+`2Xeq*P&H^LER5&KYaNA73}^&9_EDN1gAXsC^ek211G`r z{{gt}<@<jZ)_!1M`2USTltGw5^#7IrKmK1IILG0)`Tu87%jf^M|F6(<E`EK3O%~(} zsG9^C82&%`{|(eG0*ip^1TbUJ5}f~^{Qn3#9pwLO1_lN`xI2YFV-x>h{Qv&{Ib3Wo zGC2RgfV%%Z7Wco#Y!wYYPoSnBQ0@h_7a;Bg=WS4aX88XN0T~zuPu&Kp1sE6@xWR7b zg}9o5fkBW#fI$$HuR$$Sxago{{D1lX8?wXxzyJRV#z%zn{~!Oq|NjB2;|4=b{r~a* z58ys8C}n`!|8HQf2KS0T!Al{8FcRzkPjr7HNz#+a&%n#T3rh9$w3Hflfx{m*e)9bP z*Z<G|e@CPjYS=(qGeAAw|KFh{%u5CaM6ZRm_6{o3H5fz~G#KO<G{A6BIgKdCh%hiP zh%j(5h=56=v`|aY|9uP$|IdPYybKKgPcR7nKgu8l9zg+>li&XTU|?X7Vqjp9B;LON zKcMi;|8xI;{@?rm2LmsIB7-c00D~k0KZDZ$pZ`CATD1Rv{D1xbErS4q27}=LtKfd@ z%l{9Ew+Eu~|Ih!Q{{Q^{{r^jFJ%v&SKva-Vf_x7$9cBV*4FeGc)iPioJ%jQg;|LHA z@gzT3DWvA*1GRVlzXzWf3>pa)V_;wq0?VTixO)1aS`CC51Q`TDb@=};3=H6Nk+>Lm z8Mqigra{g5{}kL_lxE;!kpBPd|LgyYKqdJ9@Bc4A6%axF{|F9&C;uOU+7AC8V7dw{ z4<AcG7)hv<1^27J|9=G1@&5;Cd<Im?fyz6u2<TiW1_p-zk3ntQ|1V%E2gC!ltp0!c z|MveU(5NOzHGcg6J_E!52cY!B!0`VI_~d1V|3ChJ`Tq$78Bp5)ApiaU0wQsl`Tsox z!sNlJ1`-z_At8{zA^QKn1dUTNNQ2tE{~v+Xfkv2q{J#T{2Pp@Qh=9@|gpHm2|Av7V z8cr7&82x254q|DR@H_`eilI$RJGa)f&MeBe|k0uJ@N|Mx)j{yzYrK_qD0l7Zp> z_5VK^r2jtwi7+ty|NQ^c|ECZ(g8Y925+?uOBj+cWJSc8KY)}lq*kJxoP)vedf#}nK zB&dl0e*vAT$iM(kS4izIQmpy^^8XJ8K3M8Q*NGYW=qiZg|No90J|I2+-+{uEf#E-_ z`~i<uf@Fxt|6eh1L(>eXoRMY__<!gBkN@Wx82;Y|jYs_d1ga50<{^47{~>h)NDvMG ze*u9Eg5cUr?Eg;&J_aRlDwkpqWl%;a|9}7g8%X)jAPX-4_e12++q|G01ChZb|MxR6 z{BHuyxPZ@)<M}_8L6m{(|MCBy!Dp{O`~RIm9F)IMb1|kl2$BEypm5gz760%3p9PkY zVUS_q1I^<x$b#DC3=IEw{QvR)<^N9%JPaxf{QnRBfAjzS{~oYfhW|4lG=#+GdQf=- z^6meZ(DL&kW={)TenLw-h=~vqzqz0q9aM@y%LPRF`53AjG+qEI1)hP*p#LvHWgtif zTqA(QKZDEP51>2=6331Ie+G}3Kl=Zff$RS{SV{rsT+k`@T>m#QFhD~Q(*Fg`jeR5J zdQ|)V-~7K8Y$C(|9cVJo|K9+K{C~m#o@WE6V*!u|GDa;y!R>{g5Ep=JN2FFWj_?Q7 z!{C`EgsBky2tGOs6n+d0xWXSvHK^Z&Q9pvl)&76~Pek}bOai461}<<}CJL&-z%2lX z6sYV3VF-^P`5zoIFyBE#P6pg=5N6;5rGGRdlt3X*NxuU*jX~lAVgrc%|1)Sl6_KhS zGSnlXcJsmOFHpV!wVQ~yg6IEp1~G8H;rqW0luG}<{eSEK=l|RPfBnDv|JVNq|L^^O z5>jvezk&q8=@!$P{~sWbL4rY=L6AZA|A+tg{+|KmB2Xy&N4iPj|7%eB^Z(%g9Skbq z@@FT=wEr*v9|Y0J`2W5CFaAFU!z2GsA<Mwn|DXN;%D@jzttc%6aJ!Cb?P~ON^8elc z*PuKL4}Y}!3si=|+=!Lt1E&uOP$|kF@&6>KECQ+f{~6vQJox{{|3i@U3@zCqB|Jte z1*8u&g9gew|GzWHFvv5AGAM#_*8g*$@&{!0f5@E{V5R@x{eQ>6|NrX$?+ohy-~Ipm zAKZ5Mk7X4GxHSrzBYOM)Bbq#<%`X71za{_Q0k?KQ<u7~{#Q$giKf+p6;Q5)a|389h zP~Qyf4jhCqgBSx3g9O+|Kf!H!F6jLuPz|8jDh7uCPtn?MSj+{_PJ>3Mu%-iW{6R`E zP#FxGKYjoI<NrJVe}a9*`~MxNgaBCzDqG<ABlfuc{}}=qc)|S_VQ`B_ngP^D7GmIK zkO7$k!T%qF!v!>}BlZ93|5yJnf?CU<nhzomBcWv&l#5|2sLdk@@&kk5|6QPV!vFXG zZ~gxY$*~L!|4;wl_kSNae>42Qg#<4_4Md~<KY~C8IR-@raR%l85C4DvzZYBsiZXD+ z+R-3Q{~!N<!@&Rl*#D={^m7I*3JFON6OR8s0_R;&4t@a3DWG_TD_{Vpc2K>?3oiXH z{D1NP=KnAMKmY#%@)44{@8C8Eq&`CkBeDKJ2lXJpEm=@(@_^f}Nc|Ut(*O5CZ3l46 zPW1ny|4;vK2hWiI`2P+yj8VfMp&!Kh{|e+EaQo-O|Ihy)fnysa0mq<}`yWSG!xdp+ z{J#%!Iiy|x|HJ>w;6C0R@clcW`~~V2gXnvpnEn6$|6>LrP#OdAK!*K){r?dtBtUGc zVPUG7G>8oX&4ts=c}xuc4BX%pt^!(X$Dqrg&%nW8#9+(7&EUe|#vl$ptyzk}o57nw z27F$#EJFZ8ID<SxBttBNIztLW3WE+qHbXXpE<-LuE`uKU>}!37N`@*11BTfQ%NUFp zRx_+-uwz)uu#v%@VKc)q1{a3Y3>O(97%nqhW{79F!SI?Pf#EH~cZL#%pA0`4>KT4B z{AOri_{;E@p^*`Efl?DA6C)EtGb10P7()xA1fv8)FQXKr3_~BI9HSh=L`FqMMTSX? z%8beklNnVRRT-u*sxw+MOl7oVbYfV==)&m2u%6M4(T!mPqX(l0!$w9gMlXg<j6RG$ z44WDK7{eL1Fh(-QGVEhaW6WhZ%(#ki6~k%9=Zw!8&M>}Ve8X@Sbk;7zImWMyUl}ei zerNp7aFOvR<4=Z5pmTN^E`v_kWw^@3$;8cYok@U6nBf+a1d{~AJtk=;X@>hunoPzF z517oDEErxhSut5LykoLqvSE17WXEL3@PWyJ$${Y`lM|CC!zU(hCU1scOukG(48NH| znIah(nc|po7}=N#nJO7YnW~wp8ReK7m>L-6nOd2qFe)(3V4A~dz%-v}KBFnqVy4B6 zW=u<&mNS|&tzlZjXazc<meHDlk%60ODT6V|sR{M$Z;*D#`QIQGt~0<vatI7srw_Vu z4YU^kw2B{e?l=R+$>X54ez3F05qhD!2H*mk3=9nNa2|pIYLhcF=pzKkWJxn1pge;t zgFF<%j04q=sthp5z@W;Y4Tbs)h76)GWnh{aygz}N0R+JU;ITu{dFxyZ5Xb<l%fTz9 z#2JLa<v(Z)3xs7Em>7_;9D^hS$bNYSW(IVu$iTpWj3N6aK%ot@8JSjMU}FGbWd?Qz z5LRJeV1QsLC=F5#T49F3Mhp@RstjCktiiy`0K=LLd<-xQ(y7J3&j7;O3=9m|ur7lj zgFb^Wg8_pG95aAT0O<nl`~aDPz##Q74AO^?p)U)Rg6V4`%r-<zO%*I82TloKCId)B zfq{WR2f_u%D3r;-36_QM)EKxKG$2gy%1%&NYr*-DyaQ?(!iCTnpx6=tryEG>VFRaD zkRnjJz!uM3U=^UW3`(!C^a)CFARQnK(#-}=10vw`3*v+BUxeuasR6M;7-R+rL)3us z9warwQX$L*5P3w}2e})jA7mafMx-W225tsvaDD^n2bCuv8Wir*;M@k{fiNfpLAn?j zSeVu_M1v|NkT7_6k`Xw^gJu|b7=#!=dz7G3NR%@J=$00c01Ja0g9!rz11t_e?q^^y z1xbN1C_Z%=7#PeL^caG`cPp4P7&2He_%J|nH%L(kgEI7v1{DTr@SP0e;9D6$w<pLj zgfqx8L@*dLXfk+#?{?r~5M<y6%`Jm>SApiq#2G|jy(F+rd<;4aoDA$>47wqQ2V@C@ zF@q?B41*z<jY+`53R4Cmf+!VD7!c4B?r(@H7^%YmgP;^0!~lU1yF(a|p#*~z6sj>m zpfE!i188&+f*BZO7`&hmGzuZeV8Rdr$65>$49FNVF9({N1I^K)Vr>R#24oCMdl0O~ zz`%ftAv{y?NU1v1Q=l}1j>8xj7?5!!gARiX95XOPFu<@GgE0dPgACMT&}0DNAO<f6 zY}kN7fWeSKkimk18;(J#$QZ0k9D3^w$Rr2`sfS^ZK8PqSNl2<fw?Tyg8KTRQ!w20q z;=$m=U=O}k33TI#Cj;pABo77;25$xi26r$C8%0-UP-X}KrwNc<aSZYdVhn){!3^=> zvK3N3f@C1EY6XrzP+En=8c2qLA%=m0!IuG4mx1`8eCq|i{R9-Qp!o1(h=tbFAbE5Q zN_`B_GCB;LQbDN;lrFHva}-zwC{0^3guv4$XbnF|2MGItd(9wQB*F3^7AV!h^nlcW z*dPp<n+BzOND2nYfiNr;!dzg>pu_-4`w-WHd<D}FQjd&5zE)reV^Cm_WRPVrfrhO= zgE6?50dfs!=MBVW5D$Vux)c$+bhIykcIoJJ=yfr;F$93;bKm~|$-vD3+ARpmRS;c+ zjQs!pKX?Wnv;yY;d(bMW!Q#OG5B@&@%{2di2U=(M|NZ}G|DXSV^#21`{Qdu@pfDat zw}Se-pwR|oH~jzk|L6aYp!o#QI5sqgAS<9V8@y8D6KM1iG<Wy^-T#-6QJViG&c)Eo z*`RTBu;KsDFfjc8^#AJr)gbQw$slrIF?byS$O+)l+W&hYeW3sCAo2g8``18n1BgNM zvJAZ7_2Qt}q=O8CpqZfmogi`W$m0OI4P^EIH{h{x(0(}3?yQ~QF?)vpjUe$s5q|GM zGtvJ~{y+2|GzTs9|0M$tgD8V2D1I4a@Qq)Bd`B+~avNwif?oEKX3zg`|F`|W4_;69 z{r|WBH~v3>%qc@gU`aEJHmd*MWMBYckm=y`2x2gRXO6(Efg}FE`u~c7@Bgv?`@!=A zlK&qwa4`ro2!m&sWWe>uz&Q>yN)DM5-v0mFf6)2?P|pP99wAU2#~=)<lR?~p!=PPx z3=9mi3<{u@-v65nVhkb-od5TOXHt(2oV!qM{=f78yZ?7UVxYDHWJVIqLz!;{iO?JW zfA;^`|2O~V{l5d69sYm!|C9g6|DX7O6fAxYl8@=_CUpD$|NMXd|MUM>!E;Ps{y+Ku z5<L6I0G_!+S3DT`0u21%7WND9JlBB7Il&-;^pyxIqniRzw6SVIq@7MQb_8@L5Y&GI zt<(k07=z|UMIhp!6%yDT04l#xF?My-5dHt{|EK@&K=s=HcmKcsfA;?c#9h!8F4VAs zN@jrPs;Oiab&UD{61*-&1$>GC!~d(`85N=b`@kz14^zhu%1r_9I)4UQtNQ=*|6Bj> zfabIztM@54n=%u?{qQIMUxU&*1H=D!|DS;S=O4l1??E&C*v$upJ1WMmj#5!jJTgf9 zKluOZ|6`z$uK!ONlo(_fwEw^O|B^vz;M>&yUxU|j!MzDu1p&sdLF*B4x356`6I2X0 zg6a(LO5@Mq@q^Ex^|s&@W1zMnbR`AVEhO2%|48fJK%oaQ08}DESOZJ`fBS#u|2N>V zjaLi||1bYP1zFYdf6KtS3vN4To)*FZjZi8u$S{Crt|7Cb5b<F^LdLuXuSal~m4n00 zgCP9CV{o`bV_@4mFxz1?raS+C8=xKwbOsA_mISy=lmxA3MR(u-bLb+2i;w6v)7xFd zrF`O4(ZcQE6`SCbuOO!XzX{nH0pSfTk~X0T8tDV4d{C=p5TBI<YGZ-o3%q)o0nw5H zi483N{~a`PHLz}=kL}PsLZH3O-$ChyGFw68XbcShFa5s+8oy#-fYtKgehCBa_B}pZ z!LzjAp?&=C|L^^O_W#HKd;dTEf5l)AUZaCg^T5j4gI6~Upc@F6FQE7*-huzWU>~E$ zB~HA_5S9Ob{(lbY34(b4;rlbdq8}mUK1c>PhK!H@zy1FnWGojlathuF0P4$8VJ!0h zL(pCT(3~=?^a8o&|God8|33upA_Z{=5MysQfYQMLwZ@1H3+(Pj*9;mzKo<k?LF*Dg zG^P0e_y4ycd&7`T`v2qqjsI^*k6l=K2T6sLm#)Zeg0R8!QV=FdBxsEUC|7{?5P{a4 zfHc#h#zT!&@c9uRKxvU-zyR`@2LE5f+y^Svkn=Mx=Yi(gu*v=Z2#afMvc!x1|Nj5s z|EG`;f{+ls|L=q5QXuSsBavMMc7+|7B#VHBJXx9vXhOJ?mbMUZ1QF7-C=rOTmINiB z6K+UQj;0h`Q@#PU2SE~`(|15LX&CqT0>~Wn_=S{*pna_%S$volr5(aPMB4=+Mhffy zkHJ}6gVtFwF#JFD|M>qyApd}37)<}9)W;xGK=}VN@Jd3^S>5pVANcf}AOD}BpNRlc zkBa}_hqU`3qVS!qTfqCFH$y~8A^$)Bf9e0O|9k%L{=e)0uKy?h-$m{jg36iyPyT=Y zzx)4pNO&O3{(t@d_5auYU;BUY|E>Rz{(t#@4OGV<<UuS*n1Rl=1M}!IzW{PG$TqB) zks%6x-v=*vR|06)7wFCpWd=KjIEDm<B!*;$RE9K$9Qa)y#SA436$~{Dbqoy*Z48qb zrZLQ7jANY7xR7xZ<7UP!j9VGEGwxvA$+!!A+s9Q#*nJ<j8SgOOWxUV$fbk*YW5y?p zPZ?h_erEi}_>1v369W?~6FU<R6CaZplQ@$MlPr@QlLC_>lL}KXQw&orQzO$<rfE#m zndUOhV_Lwpgn@}6l!282bQ=w5?-m;a4+9SaBlx5~RtA0s(3yP#3<3;1;FF?w8H5;w z82A}LzT#s5tt%H|uw$@eU}A`4h+|-8NMJ}{U|~pNNMaCWNM=Z8;ABW;NM+z;NMlH2 z;AO~R$YJ1O$YaQ3U}wl@$Y<bSC}1dHU}q>~C}iMZ0EGn)Lj^+x12;nrLk)ufLmfjM zgD^t_Lj!{dLmNXI0~f<2hDi)s4AU5<F>o`?VwlAs$QZ{M$H2ijpK(3|5930{g$!DZ zn;17SC^2qk+{~cNxP@^Gg9_tT#;pvhjN2KvGpI4{VBEo=&bX6tCxZs#F2-F9nv9nj zFEfZSUSYh#AkKJ|@hXD^<2A->43do38Lu-)G2URj!640elkp~l4C8Ia+YGXdcNp(5 z$T8k!yvraDiU9@*#)pg#859^FGd^aJXMDo=gh7GvDdSTHMaGwmFB!BMKQn%2Fk<}1 z_>IAs@fYJS1|7!VjK3L7m_Q4`O_^AkSQ&Jg*qPWF^q6><co@u>_?Y+@%$dZP#274? z#F@kyESY4OWEiZNWSL|cteNDP<QQz26qpnkY?&096dCN8RG3s4?3sd@f*GusVwhqW z9GGgEY8f1v8krgyoIr8SV8Aq;X*z=;(_E&x49-mRnC3CKFfCwOz@X2xglP$bD+40~ zE7Kb2+GBMF$U1#?tZ5v#AP0ji122OdgFJ%*10Mru7Z*Q+B50iz=pGUVWd<e&6$a1^ zM*<A244e#H3~bPq@~~B6puIxc3_1*;eL*%z*0?daGw3mRFz7RQGI%jCFc>lzF&Hyg zGuT4sorI9oBACqJT}uc)j0G}554uMPv>N~>PAbj8fPg#<ywJUr@?d|1{0_pPZM_i8 zz`)1=%0nQ`2Hv-(#efX88FZl#<_-g}uk{!pkbyyu!4L{<85kIB88{fU;Fy;|n*kZi zf%nVlFvv6LVqiW78wO+yn$5$7A#MS!WP$B(1MQDM$8rqr3@{AZcf!En&A`Z@2gfQ5 zo(wR|%;3cU!ywZ^yADAZw5taj=43EtU<2>c)@0yjfMC#mTQAVQ36KG37^EJCLHf|7 z2Z$@mAjTlWAj|;TktWF?%>dfnMLVZ}ViRIIC`~|E3=ELIhChP>D6c~WL9xof-~t{6 zbY}piR7k8rr5St}9AQib7Vyq1QwB45d^j<<GFUNKfcN}?_67OD)q>K#4ucXn?}JEp za7qQa3zROf#WQHnG$cLqFlfQkC&+yu9U$z)V8Oru&RsfSd5~qGR0qljAUz;8AT|iY z(mlvV5C+MCFf0|qT;RZ958Z1D+K<QpPGK<pAoa)?l$tCV+!-txbiii^z{1uGS_&~R zm@{yIa~sHIFbvXV0V?JhK)1CqDNX^UTcswIZw%%Pevth>4BX)JOJF(%75)D`=%mK~ z??F8R{5=W!Ivc!R=+*xZ|KI<A4n8pueEK7O-GIwB^szMbJ{B&0^b-I7349X>=pLDG z;MHvOvX}^aKx5SZ&;0-N|IGh0|F8c4Hds~wg4Rxfod16>1H=ElAQHqI92nfE6lLK3 ze~^La|3UcLCN9XV;J~`^KWIM>Hv|9woeT{BcY;Z9FAaQu#lSiZbeGus|0n<NXOLpx z0q?aI1??XHzYKJe<p4VkZ2kZJ1Hax7><}!(|84*G{r~>|2Dn}z`7CBE22xSz|4q=| z9oYOm6%D4)DA3-W|HuCCVUT3tVvuA2t*Qa-!J7xVPk}<CDb@}e_xr#7|6_1V1aXEJ z*j&gdYY-AFKxYCxCn?LI^#3M<2smy*=eE$<4fw44fB*m0{~tj&tNi~snCesT9vE&0 zeg+{1F7TKQ17v0xG*1Ise+|R<{6;G|@VSJbQ@7v!e+G8Pi~nE1?YIyBKY{8q7^ao| z#F#sH_9G%(`~T(t_Y5iw>Y$wm|F8bP3O?UNnnC3MI|ga!xl5oCJkXvKgeE$&{)6K4 z;s0CzAO3&%|IXl9k@Ww;|Lc%l2mhag)7zu}&;CF9f93z3|BsOO70@xL;8y*A@&CsE zga3~)Xfh}<XfkLr$T09Qi2OeVBH@Y#i1Gh3WZe*|_5VN6cJ>F=He9^_uVJgBAS(WU z0+%5#L3c!f+Uqb3k)=NgKB@fk|JUG}6ME`8;{0{`y9e3E{|EoSWDxlO33P80NE{l= z16FQ;+yKM>cm6-~|JDD?;P%of&@Lg63}g=zh&50cG?)AT%>TU%iVTtriVTY2Rnh#Q zwkEjM1b5c|v!L_18KnOI0G*$~Aoc&~{}T+7|MxKnGD!aa`u`{B9NqtKk#93$;Ai0a z|C&L7(i#U;-XiBr%(*JiSOlz%^8e@mcc3{n&`k@-x<F^+f>)YA(=1pGXeY|o|DQ1L z6@YsiCj-3ZLxMr}{}~2;P`@6R!vD|zgL2DH@OjuD!2MXh|0fw3z-MKHZk{>!f7Ab$ z|9AY~!Jq>^#d<RIL<rEiKA`iw#26U<-}`^#|AGIv;HPK5{Qvp?%>V2EZ}`6+3}^qJ ziEKZPIK78{lM0?Vl>*H|g2EInP6Z%wifkKcY*2d$tOGPZg4QMni$jV3_y7O+|A2uL zd=JSt25wM%L8TZ#=?#2W%M0*nb$p=PLm+$0K`sWxH0ZQE&>gi99pL`lchKEu5I%?m z`43DZodpcp&kCLq1l|4yav?|w77V^|g%f<n@Hg=JY@oA;!RjF9{{QwL;x_2Xz@U=o z|9!~0DWEeYVQ%|>fmpYJ4JVJlx?==<Ujfot%ODp*j7QBi5Hb8Dcpa}GgBXJngA{mt z9CUshBt%eVvHrgTuV9b{->vZzataFQRCfj;2I>DV{)29QdHw$ls0@MKi1GhD1K0oi zP?eyOY=-~fH3k3gAllNP+>hIRp!h&3-$3&|Sd`<*3*fs8L0YJt7f{cz2H60@$SEDf z2c>idq&pfIc)?@VDEIP!SM70wR3eX$fz&}(wqcqHF&ULa4oPS}6=o0wpW1~pFW||i z!Z7ooc>#Mq6@ldiP-sAN1M(UVC>y=h|Nr6tqyInte+HFxAYVXMO<|6YAZnlgufS_a z-v0mc{~;oc|NjbFt%7s|2xvtxXqPeQW|^0uz9q~TAX{)@Q125}?*GmIKf(QaRAB@U zbWZ#u2GA{-pmXUrfoscI3=IFz{eJ{6Cm6tcShzs9d;I^xAOLa+cq|#R5A`D`ZG#L1 z`3969Kx};Y|7+xRFt}Czzw`eMXm#rU`=FIAsOmuLVE^9*-B}IprOAU=Chmr=OM;a& zQlOLv()a%cWF<Ms<sd$8OvU<i&;L*Vk#2<m)w%y)gHD73-4P4A$pcG$`sx23(CJ72 z4}swx*cu^-dqF4KGsrP0|KH0X3LYm%%X<)2AoBlLP)LAu|9|-ZG-wVCd?FD1)`b5r z|Gz;#@gAJ^9)rt3&>5tXpc_;EU;6(V>74zS|G)mf3_kG<bV|vy|EDqcsG;TsSUm?j z>mB4S2u8^Z&;Eb;56KJQdmBJ0@&8v)di(z#M1pjHauaCB26*rDOHjWAH6B4c5C*A5 zV5FJ^lumiUXCw=O&lG^A4}@Yc3p5t?|0RPk_|Ak+pz&%3$T`bg3?iUA%>KXq|Kk5s z@SG;}rUTHr+W(K?Ct!eDpx}Ll|KEeh{2=G`Al2+3YaqD=qa3-#!0`XZ|9jvP6qFl5 zO3^U5H4NG{_5J@s@SSpt85lry7);s!cc61)7)1VGfrx-g4G0T#_Y$a0f?+G@9s<z4 zeV|kFe*XUkiZjrSIf&5w{|&h$hnt4M0L2PO5Tp~-Hiz8L1QLPZ{~ti>Q6Q}&hzRHe z8PE!EkdHxbgNfp!iK{=5O#u1$|654P2D|M8sEh=~8$=9QEjs)ExBs6Z<vF?xb@(9n zKuiLs7H~QNr4A4ViNoYUegN@NF|2Gu6@~C1Iw2$j1G;$-H3UdbaNK}$3g{;8ub^>s zaJ}{J|0i&d45dYdZWicPNpvyl@gY8kgbJt~_W%C>SO0(he+?1=VVF%Yf5Z4#Xq@c{ zkXj=2gOs7+|KC9OX@XK1!YyEv@P-(gA++SePOU}Qp@V!M96}h4r3^lq7)_7>mx)0g zy!H`Pj`A`vg2&5Q!K)iN!K)j&!K)j2z^fa1!K)kj!0Q_M!Rt5$z$+Ss81fhj8HB;> z7sbKr7bU@~6{W!I6s5r{6lK6G6lEDNGu~y8W4zD!kU<l)Zj(WWc{cM(20iB0%sUut zna?nvXYgje$b5~#pZPKKKZZ~ib{0N{LKblrIfhCWRTeFVMiyfhGlq5+dloN-ZkAA% z7>4OAtt|Zv^I2xI%wt%^vXW&D!#b87EPEI>u^eSN!LXg>49jDN-7L>oo-tfzU}DGs z`<4wnT1@InPXTaPpsw_!WQ`|eMJISICpe5?t2jaHF$KZvF@?bEF@?eFF-5@ZF-5`a zF~z{^F~u2gG2UX30I$lF1h2}J0<X%H2CvE#2CvGL0k6uG2CvGLVV=!An?aU&4)Yua zIp(>{a~b5B=P}P?P+*?VJfA_4c>(hR1|{Z&%nKQmnHMoHV$fh-%)FREnRyBG5(X9K zrOZniRGF7CFJn++Ue3InL7904^9lxa=9SDV88nzzGp}aQWM0F(hCz#YE%RCiZRT~% z>lk#H*E6qY&}H7hyn#WFc_Z^i27TsD%$pbtm^U+TW-w&l!n}pSh<PjXRt96{ZOq#k zOqjPbZ)Y%N-od<s!HoF~^BD$N=CjOa85Eh%F`s8pX1>ULkwJs`67yvS9p)>{R~QVK zuQFd{Fl4^Qe2u}B`7!e=21Dl8%%2!!nLjiCU@&F=$^4H&5uApU!D&btoQ5>OX-J2~ zp2dy9fW@7~gTau+lf{d{6r7$6Sz1}T84Ou^So#@cS!T1$VNhh5%QBBa8JxD1S$44O zX3$~T!?K4#ljSJOF$N8m<18l_3|P*vTxO7Exx#XrL7C+a%VP!uaGEn<U}9)zU}0ck z&<CeH1#rq!0N=(U4PI$%0$yn>3SMa}170O*1zu?^1D>@4-3TlP9vfF;Fa_sG3vep* zWN=_`U=RoAOEGZ1GzRBOdGLB;dGLB;8F22D1g|%i1+O=j1Ftuh1+O=j1Ls#OaDKG} z=T}K^ezgRrMLBSOwF0jVRtKj=LvUI&0;fd{a9T6~r$sMtTGRrkMQw0e)B&eOGjLke z1*b(La9Y#@r$t?GTGRulMOJWH<Oiok0dQL60H;Mka9ZR9r$r%fT4V&LML}>{6b7e7 zc5qtc0;feGa9ZRBr$t8QRm`gx1i>j%1e_xIz$ua$oFZAkDUu1CBH6$xk_Vh3dBG{t z9GoJ3!6{N1oFW~-Dbf*~B7K;5GVf$?V&28Pi$R5XH}h@=XXZW3dl+1p_cHHgP-5Q4 zypO?^c|Y@h1}WwP%m)~}nGZ4_WUyvF#C(XsjrlP1VFq{RBg{t_?3j--A7!v*KE`~E zL6!M9^Kk}y<`c{(7;KnNGM{9SU_Qlsib0L}H1lZ&59SNZ7Z?PYFEd|eU;(Ffb8uRB zWWLUPoxzFu2J<ZjXXe|?w;8ON?=atEuxGx{e4oJsoD<B!dBBnRKMNa!6AM3!7=ts5 zJc|m0HH$Wj5raL8Ig1^G2RQYcgHyjFi#Ll8gA<D{iywnCi$6;MgEdPaOAv!SOE60a zg9kWon6sp^<S;m~l(N(?c!2YXIZF>qF9R#fG?rNmPArRARxmiTtYlfmAjq<TWix|4 z%NCZc4DKx3Shh3Rv+Q8m$-n~6IU+0vSdK7wusmUT!obS%l;tUd5IDE+BGwAB_JP(4 zvi@KfV-N<f7zW(~0J{J53Ajb^1axLJXjdDo4?>`xLz4dg7Ss<0^<!Xypz#3^`~Qvq zAOHXS{{*x@9K<IEBYH8|RfEP={{Q^{2LEanP|FR6e(?Q!h_Mgsst}@}QE&(kG}8y4 zKllJ?!NXfTpxa&<7`Xrc0H2=v6?BR@C|n^rA>{uLp!VtiA0U!}0pdbP59j}Bkc$5o z!Lt%#a5Kd~qA&~>1@(uXfqJ_T8IWlRn?OA}sQ5cXI~XDYB0;J_Gz^2|=Q)EYcuWCw zUpGhsx*`JF!{GsqZZR<Ye*x}YL2s}?HwWY&@caZwKOF!64l);f-#TdQ_yeeo2p(<v z0wUqczzl?mpxxYHAq)b^ccA++G33z&Kq(XbobmtPK{GA?UxWHN|G$DqKfq&~pf)yg z7(>kZ4;uvorQ>%Dpq1Ak3DlT@-;xXR19VR`?zS)J#tmVRA;|bY=pJW;0PGA*(As!} zC>jeAnhZi9|A22r0?Yq@0`?iWZw1ej;1No2F9xgzo%sJ0l&jE1LHz$;ptdml2i+vV z@c%m^<$>fO802p#>n-R!NC+E7VoL@8e}eA_WIzvbP=6ev4`weY^?}M45C+MB$_Ipf zu(LV;e`62?_vXJN$$^xEa?92KPa(M#DuC!8eu3md@Y#l-(STQ|>LBX>|NQ?IGTQn7 zDW>lsDdhiMu)9F_1H1$20*$AFXdIY{L6CU|^L_>f=7Y?q8911)Gv8+r2G=Yy;F?7R zT(jtaYZfDL&0@vk%@V@k#1hNW!r;R)fn@?i3Am0ZWtq=1pP>p|Q&h8TVA;;l2(Ak{ zSx&N?WawjHV$cWIvW(zbmI++TGJ|Vb7H}=g3a(|@z_lzpxPIjT*RP!5`jrb@zw&_V zS8j0q$_uVv`M~unKe&Ds0N1aA;QCbvT#qt>>rrNKJ<0*DNBO|@s1VqPj9?!!gMG*W z_8}kGheF``k`Y{AGK1?&4sd<R2d*!LSYlaH7?{EJB_m5NO9=xrxYiV6sby(lU<QW> zGdM&T!6Cv74iRQ>t;q+jHMv+owI&}pbcDd61G;t05!{O81Gggiz^zD8a4S+6+=>LP zcj9Ld1Ggd}`_;t2tw?cjj}EjONr{0U+=_(sWt70JNJelgQUctHWCFJ$nZd0{W^gM~ z4BU$31-Bvv!L3Lka4S*}+=>(ew<3AK?MGg4%TWm2a^wNGr=-CxDHd=`N(S79k_E@E z3OIHZz%eQUj!RZ>i%AX~qio=~R0hW-J2)<-z;Vd|jzvyzEJ}i7kqaD)^5B+{G&n9* zz_G{%jz4*DJ4hNFi;xt`297y-aH~fe9Dgd{m}3LSnLN0SBMpu-6>x0HgWEXL;CNC2 z$BHsIKI9SY6csm6J4IDRy@x@UAst+=yk+42|CT|JLHhq)^wJzh{u<Q6;PM&1%Ju(u z@SP_={(l6u3?QTSgWCB>j)U%-c<}!NgB0k5@&7--J19VV%-@0epwqoSf$j}P(ljKP zpb{V1q3HD#vfSWg|9=artHAY}G^nkNTrYyyL(oT{v(rBR2e-&TBkrK}CXjorL8IjV zzkq#(^`_{-7(<}>L<WZchrumj9`G)ex9~O(*Z-gY4>3spp9^dC3`QTo+yq(~#{fPr z?*B2+N%NpREigF<{r@M}pR*vm!A*kab4CAuW8nRN?EmxsFB!l)VZgJPq6}Q1IUQ)M z4GrIbcg%qA``-2cF6b5w(5@M941niS!E-}97#RLf0_7pF_#h_!zyJT~|H=RF|L<pz z{D1xb3kCu3ZX0l)^#2^F!v+4YW{_dv9SZ(oU>MwY>p(-4XbQZxZXc+&0hI^f9X*%+ zKLyWZgU;M|44!p=PPB0YsgR$6A2c@e|K|U%41CZPAaB6=2eftsG>iNN)K>(J8VsZ} ziL?9vtN-u*AN&96{~iXR{}2AZW#9wv8iJGnGr(qWF$n%&4$3!R-k>JHbI05N|NIYL zzX6T`(5xodH&BOzZg1rSjf;R+OMixuP}xC6fzK+F{r{0c>HkGYZ4cU+1R6JE0Ifs? zj}-mC2wI;ssGN<*F`%_h|F6Q@m*BljcmKZw=N-^FLLWdSGdR}pm@zn{!0S@E|6c{& z(f$A3|JVP&fp<TFR#<{Y=s;@)K;uiGm22QTC_p2`5Ii{CkLn8OE~&5J)kR;y<7%M& zHShm_1n*_}2x-TGR`G&YD18I9gdiBzoWaT)EW5kO37`Kj|G!{R`Tw3l{r`DLF95VB z3w&d>HUsD;aRvrS@QQcPtuT;1Sum0u>*$~dG9C)E=>J2^RdJv@Wd47GsTjO8c$DMy z|LgzXF=+pP`TxfMC;#95fByf~|EK?7|Gxs>`32f__zHYK?8|}P|3xSqL2JhUAN+sg z|1ky&@aee{;Ms6#(75LR6AU6?DEt39gA%yUL&&%RmIu}B@Hsi~ExiZ_fyX`&T=;5o zB%wjZgv=d*=iRjbfBOFjmOH?`w3nb2hOl}QygTC)sKkcgLFN&-bHHPr&^3ks-+_1J zfNlT=%}u@n*E=6Ut0Cda2RQ@0qWL9!oh)d_8+dO7czrWy&k(4ThOX)z<X!=_TR^91 zoB`d-!NB$ZEqIUI>HjzWpZfpt|MmZG|6c>&88ldK3BntkpxL?qXa4{Azn4J@)VgHg z1MjK>&D(%h@{2GCFbFe9{C^H^QKOgy*?j`ikFv8)>i;(eDe%c!pxITv|4*U2FMs}j z0b1+Gpur&c|0-DQ<^R|8Xm>#7ML{!>uvvXr`x@Ch;FcZu?4tjmJJ6nj?&E{WBh-WY ztuH~nbZ{O07`iU`E=(l@Ke&hW{r|`RU;e*F?1_NOlf{6QWgyM4l>(p<TM!>R2FZfm zigRZny1xHk!M^(W{{!+GZqPnMP^%U+CIO!jN7qUuAG}^b;{ONG{zFJv3~H5uHH*M@ z`GHn*{GSgNLn5I25x4(G*^Bu9)c<q;Xa7I+f6o8?|L6Z-^8eib1E75d|JQ>?guy59 z>|js<?@!zbT7U8X76T7xCk4dd{|Eo?2JKK}kYSMdzxw~?|Fgln5|{j64`t2%fA0TY zuoy({(Eq9bS3~YM2blnhXGrWbh=S@E1_lO6P&)v$QvCl1@OlBz?lO=lNI4P)rzTJz z2((`hBreOqz@P=5jR)PA30fZk8I=Q%)-y0L$p8Pupv+(hQp%tLCLsi<j|(v$Srj6N zNrG&_6osBD2iZ9Vs&PPd3V5CXG%kRt9@KwB5ryof`v2uW>V8K~<TMM?07{V%8Z?r{ zAPwGS@Cejv2c=Vx3}`eTlrH{%1kHazM9@ia*n!I5PcRll1<Z6P@5TQY41)h(Fo-b- z|Gy0~6THihA0i7S1^+(<#}bqap&+~c{-6Jky3<ei|9#Ni&QSGy|L_052x@Qqzx4ku z=oWHNnFinecb|a)?0)!|3Mf>;AqqNi66E&J5WAqMhvEO{|Mx-Z6C?^rNuXW~NC&7) z0*OK}a)^S$4I+w<1ceHCPt;F{UeF9Mex3inAeRC#wU8BGU{_Jx=K!^#p>ZSg{{hIC z;J6V7<r@YD1`%)`lKlUa0g^+IyoV!hWd7d)<zetCKEe$A{~!N954ywS|Hc0|Nr)Rz z+Z_})usjD{=kou@|JxvcgPZ|&D}2=rB&I;(RKd{k5d3C=>m1OC3DO=|gbrA2Aw=lM z0@)4C$5?lIV!Gu2m;YZuGeW3)hCsRc|4&dm1cWhlktza;JJ3#Rh(6HTO7K3x7m$9> z|MwtUKo}yAOhWdZA@9Eg$%66`C_jVP$QaaG0F`TqJ(=M0?L8<=AZ>S$YH)s}_~sVy zeW;+c3fist9%L>I|3AXO_kRP72d9O>JM?}sF#Jc_u?gC(20Hr<`&}UapF`HNf_8Dj z%z>nM5c&Tje0MX*9X~*Ki^5fbXOaJZL$rNBX@h|e)^35cBEW0B1pj|$5M&SnmvUST zV$i+;%plO-LI!ZHh!6p@K;i%Y-v2M)x*T){Hb@jyV+w)WsvsW2|5yL-fJ?HQ(6yJK zGkK){Kl}gs{~~aS2wHattq=Zx`+osc_kc?uP)h`C0x1NdWdqX%Z`r^EaMIv@9js-; z06Ap=)K>j}9DJYqzW;~*?}N8&PQr{qJwG0`Wy8Sme;2%EbMOD5|5w2KLk~b%kN;o! z{}n0*GW*c~6QKQ`Fq0V=8FKNQobCWVJ01JU>1_<tz~`l3X1v0PI34{KBii}sO#7J* zFdbw%#B`YHDAO^f(@bZW&M}>5y1;ai=@QdrrYlTWnXWNiXS%_3lj#=IZKgX+cbV=n z-Di5h^pNQ}(<`R8%nHm(%sR|^%m&Ox%qGlc%ofa6%r?vp%x=sc%rVTd%!$m|%sI?? z%tg!<%vH>F%ni&<%q`4q%#)a>FfcKsfmf}ugIBGwgV(D;&R1sx?|R?^uUF#*?|R?^ z*F^&0^=gonYJ%YX4ou*cYRur3YAoQDYOLUuYFyxzYFyxzYJA|8YTV$JY8>E|YMkJe zY8>E|YMkJeYP{h64v_U~+~EBVJm6JpoZwY!yx{!~Lg4)l!r=W5BH;ZFqTu}wV&MG_ z;^6%b65#y~lHmOgQsDg#(%}6LGT{9VBH;ZFvf%v=GT{9VvY;4XP-Z&FbdW)w=@8Q) z21TaBOoth?LGi($&UBjTG=mz`8KyG~>P+XD&M~Mnoo71FAjfop=>mg1(?zC>3<^w_ zm@Y9WGF@i6%%H?{h3N`|GSgM2s|+ek*O;y`sDfgTL7nL)(@h37rdv$67}S|=Gu>v; zV7kL}he4g`F4J8GO{RNH_ZZZf?lawI&|-SP^ngK|=^@iY1|6p7OwSqAnO-ryVo+y# z%k-8(lUadTfkB>GiCKw3lUavZhe459k6DjFk=cORfI*(wh}noih1rDJgh7?rjM<Dq zjoE_Pf<cYhirI=mjoF6ThCz+lf!TpUhuMwUjX|5)gV}>YhdG8hhC!V<mN}L|g*lNq zkwKj~n>m|7ojHd&he3`xk2#M)j=6}rh(VdTg1Lf0g}I8kib0jRj=7FOow<RzfkB<Q ziMfeElevYtg+Y_Kjk%3MlX(*JBnCz1Da=zC)ESr<UW0eqKvJ7JIJK#R*GPiK26-5q zz-uJsz-dkyoaP+CX-*lO<}|=*&I)`kg)?}Kq!&2lxqwri0yyQ#gHxU>IOVB<Q=S?) z<tc+xo+3Eqsen_SDmdk-fK#3-IORElQ=T_?rKBP_<#~fso+>!yIf7H3IXLB6fK#3W zIOSP_Q=UCI<+*`Vo;5h-*??1?EjZ=5gHxUzIOREjQ=SJn<=KH#o(DMPNrO`!GdSf* zfm0q6(-Ed444mNfC&6@_={SQZ(+Q>%48lyOm`*V;gHxmoI7Lb@on<=9zz0s365w>n z1x}X|;B+YoPM1>Pbjb!zm(t*L$pcQ8yx?>x0Zx}P;B+YrPM2cfbSVK&myF<aDFIHA zOyCsB%=C!q5d$C7W2VOpEKE<Bo-lATJ!5*tzz<HT5=<|dUNSI))2js28>Tl50!;6i z-Z2O=y=QvQ;K%fV=>tP5(?_O{4E{`?m_9LtFnwnF%%IKmh3N}J0Ml2duM9d&-<ZBJ zcrtxw`oR#$^poi)LlDz1re6%YOuw0aGXyjJVfw=m!t|HvFGDEPKc;^SVNCy-{xgI# zGcYqSL@+ZlGcp)5GchwUL^3loGc!anvoNzTXfm@hvoeG*voW(V7%{Umvol09b1-u- z#4vL*b21n+b1`!>gfR0k^DyW!^D^@?#4__S^D*c%^D_%D1TYIS3o`gH3o#2ZgfI&; z3p2zqi!h5Y7%+=7i!-D!OE60?=rT((OEQEoOEF6^_%cf~OEUy9%P`9@1TxDq%Q8eV z%Q4F_#52n?%QK{cb1yTqBC{fc2{;ckGAlDHGlVj$Fsm@|F{?7GGDI?~GpjQMGHWtx zG9)l-Gix)1gY&r*vo5nPLkKvxOEK#+>obHf8!{U*gn;ur53@0|F@p{`=kqd~GMh4Z zg7d!&vpKUlLlC$WkYToDwqytbmjyD+*38xnLEsWWhS`?cmLZ7Qp4pxugxQhVks*ZH znc0~kgxQ7Jg@K*fmD!ac5nNs{F}pLnGem$(4Q6IfW={q~W-n$h23BToW^V>9W*=rB z23BTYW?u#^W<O>>25x45W`71v<^bjZ24?0!=0FBR<{;)E27cyX=3oXR<`Cu(27cyH z=1>MB<}l_k23h6^<_HE6=1As922<uJ<|qac=4j?<22*eeB>^s>c$nju;}~?9<C)_b z#F-PA6Bt6kWtK2=5_1xRE^`WV3PT8U8gm*$2y;4fIs-p*26G035px!E7DF(&T$2Eo zYh28^%()DH;L?qYIiES7!H>C+xsV|VT+&H17c&<#1TdE{moRWJmok?!Br}&WmoacK zmot|$B!kOA9_C8sN(LQpX~@f5&0Njk$y~!+!yv?5%UsJ~1}+~ZnCqGA83Mtjqy%## zb0b3_xU6JkZf0&~2nCmzjLfaftqh^ya+8s{ow=PMl(~bsgF%3~lev?@levqzi-C{1 zo4K1IlDUVuhk=i|m${cAlDUt$kAaW5pShnQl6eC21O|TQiOdrjjKC$Y6!T=}$qXUj z@>ha+D)Ur^5X8!8wLhSh(Habz%NR@<;uskI@A<#y{}~1m2EqSl{-6H;_5W!GzW<*X zc>X{Ce-J)qbP(1nhqsLe1p~5flR<((`2YF;=l*~Dznekg|2qbL2GDG~I=Ht1Sw(yf z+{%^#?b`YO6m&Yl|L^}F{Qvp?6@$S4gJAuK7(nNGDKIF2diei;{y+QwDQG<R|Jna< z{{MiopZ<Rg76a`YgUCK*0No}+R#<>~K%kTN{)1YfmlzoSUjdCAfmb;_0G+-FGZfm_ z{=fhl9Rc-R9>Qhb|9|!W<^N}pb3j0%pgtq$UL_ElfdS-02n#z2Is@bXyZ=A`-}`^* z|2s%854%cCQP8;oNTMPP4B)=RM+P|t_!%GnA2UdUXV!TCe`XK@?YaW@0q;OYP@#Ps z(CL1#lVCwTI1xy=fL7f=`bQuVD#id>Z2+<r+)o9qX#t&71rq)G|2>27|DXTg{=fhK z?f(zpQ74c>Tv#4-4hU!;*8k58pi^5uF)0844jxxTj2}wE$2B3lun?m#Qqa*a&^RLK zei)c7P}hKaV=yrU4eFOccp&p689;lBK_iPG8^JmuG0nihAj{we>a9Z*6CgpSHG}sG zp`0;=p$XJi#So-e0NkG?!4y<ifYy-ye+Cl!e+#s(2l<S1#A){bpTTSat#$tY>Hiu~ z?;VjMNU$E9Qef);e*~o%1_p2+`aOsQ$w1;9#KOd&b8bN^#-XzS;1gc{zd#=6fW#mu z?*4xUxe$@3{zLNWTSQ)km=8|LAa@biiTMBC|3~04uXq1HfcnYMF+zwTNF>NsBrbO5 z&`LAVJ%|XeK+X?j-~-Qa!)6W;(&#J!&^SJ>@IqINpAXs#0W$4B=)9p<;FJp=!N#u> zEDIX>0nJ&1)~`X&u>^~w5dU9-M%Exg5FdilKPc@(#7H9{DH5aylqx|qu^3dIBh>ys z2-5%mIRo$i>#%c@V6vbUt`IhO78sP8e}HBh{=fOZhk*h84n2rUVn~>u|9^so186?( z{|hkr<^PZWZ$b4Pc)t^TtQuzS|CiuU1E(FxnxX$c7$pAR1&_sn>Kv#-@GKcLE<iqn z2!mJOoPphO2@yk(AX^bUOcvC&@4&ma-!XvJ#G<<oJYNNJA*e<J``|k$rv5(!ozI3a z@&7CaLC~su28RE~7$EH#kgFd5-wQp{jDbP)|0U3=W&fZ4Kk)zc|A!1h|4%bWGH`>& zvHtI3P-Ia2{|IyfJox-k&<SJ*|KI-q?EkI*&;CFDf9wA-(B1C;U;e-H|MdTd|DS>8 z96@K=AW9KP*g?kx86fE&<UUv$h4=v^f{Fj{0EOuP_YA!M&w<)C$SVFL&Q!Yt69?J) z|KtBv3=IFtUM~P^r~ZEji3OP1FdABRfYK;v^)++^8Wi?0Kf+ah1C0rT>Id-nFH)L; zsQ{hq3S)z3WPber{Qo()r3o@0J<efDanj(?Uy#mE(Ap5UN~pUr6@bDJEXwu&C4)GF zGz0(t_y0dYN<WAk#IImA2m&(a0&0ygFz~_8Wkbl5!UB&a|9=Y#wf|2c`@KP9<dC`$ z;s#vCfl?J{{s4SVz)R2yV2DbHo587x*m@Oo@)js836{^0dKF~P|F8dF<MKOpccZTj z1DgVhcL7M+0hcx)L6A$JarXZq#4HdA!cb`nssG<VDgQrs_c;S-%pbHO>Kn*>aF~Km z)B{-o5&`!$F8}`uE*)<EzX8s}pw_Jr=oVP;O@|Esw}DQP_<sV_-(gVu|CT`#oS(%1 z-~PY(|NZ|z8ASh=FsL#J|8HPm_<x*1hd~EaEBwFvf64!?|8M-i@qg|AbN_Gr-vVZD z{=Xkg-~E5^{}!<LX7Fr=`2W`o!VE&7F#mt@Kd40i0Xjz(e4-yH6a^U=K(>N?hG)MA zs2)boy&%7$Vnkel)I-{PpcI9widY^v%!pM?P#q+F<5LEyb3m~NQw!=X;?sdo4pcis z<so?<%0Z_fwxWxY#)pJ7NDrv33kf$^%?=VJ4O8GElIOGj{{+={FdP5xg|%~_c^m9- zSQ!OslYj;A5TMwDN&mmhAjBXB?kR#+xj<MjNpTnhPJ?=ya1N0Sgeej*9sl2eZ$E@_ zVIczUeZmBhT?rFoU;y6|iQvL?LPX%^L%8@zP~IaVufkV9f>IA8=Yr0d!DljVIi%Fi z0IG@bsDzf$5EVE=0wRf(1g)<Fm$#r*w&1oWMA`qJp!t0W8x)qHbj1Lj(FOJDK<i&1 zH2CCmaNPyUEnpq+c>MnXGAaTVC5s^BBhWlC#9&Z7fiP%<3L-)}i4yLlYbQYma=4>u zgReazBHSUjz`<G!R0(%T4FfAf(A);+LV7YFF8CDLx1f;w{~DeGKoTV2|KE^w3SqY! zCW<dbkmh#q?F^uk8$f%4UL)GsBsd(@O8N%!J5&!2w?kxM^&o^t4*CBZ$cONJhqZ;z z04wQ0qm6_uftRG9n==?d=e{s7fZM|;`5)69m@SwxI7I&c1eY_QRuU-GZovC#i1oJ& zpj9{^?IhPtZ%L|~P(ubezkw{rib3HHlK{86!RMI4V*=zO=;}I9`43Zxi$+VspcM<C zcmQG4(IL>dD~chgIw7ut<qQ;sNSy!R+zAr+54sl&%md|BRI@<x#NhwmkbH<Ey+d*Y z$b3R>!*3&6ddGfRHt5U(kTKv`g|^ndL1>ULgasvkg4-`3VGzdKk_0J0!h^j%6N2Pc zG$yt<fQ@^hDTQ-EJs1cFoNK|YjQ_hKBBYb(F8KcwG~WT5TY;_WC(cY_(>u(x{~y3( zZjkaFTvx(mh^PM}m+zoeZ+NtVQamncV%-i4t3g$-f>zvtPddTnJCHc6Gz6{GWMKIJ z2Gk1ve+eW5!uY}lB#(|EITl?M!iR+sghx5~A9AJvXrvojKVdTy*#{6dgoLPtu%RR# zQy{8gBt$)oM-Kh}C!%i(YNbP38A$CZ5)Flv-6U!n8p@&PdlGgmDCI)ueDNrT?SK0J z9TeJ#^bUzr2#H5KniQhMMUw!vEs%O<#L2>X{+OyEA_&`%%J)MMInqh={0Kgyf&nr* z3`vhT%>>PBqf3Iu7tqBp`QWzE|9$W_<9&n@NLkCk0HYBySXuD4?Emi!vY;_W1|bGT z@SY$L3DW{{DZv&4v9XS<4l>RRvxZWdKzQRZ9(<<=9(in1|JPy@#Xn|&RR`!E@f-M8 z1Yp+(5ryPf2%9hosj0~EFD|n|Z5a^8B@Y*er!}w$511rSE`dsQusTpl@B%!({2Y1J zK4``VA`i<85FU0CIpu-&Ie>dkuoIBMc?zlz+@6NAu~3i@2bCUJB;i7^kw+?(kuTr| zV=&OmAPfZ<0%#$DDgOi}1S-iOB_?<#0VYg7jj<CC*(fA;F)}nT9br1obb{$5(<!F2 zOb?kJF+FB_!t|8s8Pf}<mrSpj-Y~smde8KM=_Au8rq4`Yn7%T7WBSSTm+3z<12ZEt z6Eib23o|P-2Qw!#H!}}2FKCr0vmmn&voNy=vpBN^vm~<=voy0TvplmRvof;^vnsP1 zvpTZ|vnI1Pvo5nfvmvuFvnjJVvn8`Nvn{h7vpur|vm>(;voo^`vn#VZvj?*$vlp{B zvk$W`vmdiRa{zN7a}aYda|m-7b2xJZb0l*Vb2M`tb3Ahrb24)(a~g9xb0%{Zb1ri} zb1`!Xa~X3bb2W1fb2D=*b31bfb0>2bb2oDjb1!orb3gM0=E)3<Oh*_lFhKGM7Xupu z8+2rX5jo88vKbke7?>GY7+4t?7+4tC7#JAX7}yyY7&sV2Kr5;k#2F+QB*FWhLB_~{ z2=JOF1_n6>c?Jar(0RAY(A}FV45|za45|!j4C>$-LXts)L7YL80kWz}n?aR9l|hFA zWCp120QKl~!Db0F=rOP`s4%dB^~-_A_MljwfdRZf8?>tuqz{2X>Ote!YzzhrJm9rn zpgKr_ftSGujF}mf!DfQgf&9kDV9dbJU;@S<Pe?(_VHNPK9!wR82FZd-A`k|#CBf^% zAhjE4rXO@F21p!+LA_rHW@nIQU}R8)W01WdH!?At0I!*k2Jg^7UNf%<-lw4iUNg^& zv}WE5W6iuAc+I>Dc+I>tc+Gqyc+I>lc+I>Fc+I>Ejy3c0;5GBA;5GAH;5GBA;5GAH z;5GAJ;5G9K;5GB|;5G9K;5GAH;5GAJ;5GBy;5G9+;MMYc;MMXX;MMXT;MMZ{;MMX1 z;MMYi;MMY;;MMX%;C1rC;C1ps;C1rCOrX6P65zG*;^4LMvEWtloZwaQ?BG@Lkd^Q( z;5G1~&=v2XJs3{lweD`<_3iB7HSJ>H)$HQn)$9`B)$A<b)$Ees)$CH>)$AeQb?lH8 z?5yAw>@naK?AqWJ>^k5T?7H9;>?Yt9?0Vo8?E2sp?5^My>|x*)>;~W!?1tbK>_*@f z?8e{~>?Yt9?55xq>}KE<?B?JV>=xh^?7rX?>_Ola?3Uma?B3uN>?Yt9?0(=C>{j3v z?AG8F?Ec^t>?Yt9>`~wq>^9&P?6#n?i9sK{f?X55g53nXg53_hg5923lv#`+9K4P_ z7`%?%2)vHn1iX%23%riq5WJ4v0KASp2)vHn0lbb~2fU750=$kr0KAS}47`rr6ugd| z1-y<u2)vHn0KASp0=$mh9K3?v1iW_L1iW_L1iW_L3A}dQ6})!c4ZL<e47_UH5WHU9 z5WG^|5WGg+5WGg+1iVJw1iVJw1iVI_1H4Av5xhoS9K1%|0=!0D0=!1u7raK@8N5c_ z2fRkz8N5c_2fRjI3cN<$8@xtc0=!1u7raJ21iVJw54=V_1iVJw54<`(6ude;5WG4) z6ude;5WFtk3A`@d6}%$d1iT`h9lRpl2)rWQ1iT{Mgc-CVJp{ZW-4DDX-59(UT@<_) zT^qa>T@<_)T^qa>oe{hWT_3#uoC&=0oC&=0+zGt$+!eg?+zq_)JPf?@+#S5~oEg04 z+yK1V+yK1VTnxP0+!VapTnxP0+!VapTnxP0+!VapoE5y<JPf?roCUnvJP5qnoCUnv zJP5qnoCUnvJP5qnJOsSj+z-6Y+yuPB+yt@0oKXd|!kp29F@b>%boUi##UiZeBaX(m z3KP2tARVAHY_QA1M3G(c{{d)E^#3>izk~IH@AH7EgV2!iQYicN|F;Z6;87&dtrKtm ze+Qp~2GWZ(I`JQ}?(HYO))RP7FCzm7b0l*#a{_Y;b0Kpnb2)P@0}}%qIQ=k!(+m?h z4KRaap9LKIEa3QMWnf~^0@n(#eS6#t+zia%T7e5(D}ZJl_!&T_l?X6!f@=lHPI4Y_ zeZT>(33$OZ0Ux*~fb8321n-y<0q>X+1DE~+;L@K7T>1+z6fzVtFo8>dPH?#|1}^cL zz$HE>xa<=ImwiIu(oGaxezAhfFMe>@Bn&PiM8UaT5S-72!1<O9oCEp6`9~0(vIW6u zS_qtKMZxJ+6r55;!D&>80n}3AV)S5<Vqi|LEXrXJNiRyxW|)+dSdz!Eg+Yvg*~vXf zfx*Dn+eLxFD>%qWfgy!~;s1Ydt<4F#tC&HZ!H~g*!JQ$1ArdOX$iT%Q%%I4i!C=H- z%izHf$PfjUVPfD0pPZ)2V9a30;K>lg5Dk?9g}Eq$GJ_U_34=X@7eg>Z3@F`$WqBA_ z7<d`P7-Sf<8B7_h7#tW}8N3-n7-B*0bBuCTU@&tI3Q=GP3JLO7V8{dO;bmZD;9~&Y z382GZz+lE;&EUx3#^A#c3f`shpMjeJv@cVFL5@L%L6^at!HL0_A&em&te2aCok4&> zl0lw9l|hfeg29==k0G2P0V>DAAjlxapunKUpwD2*;KJa~5W$ehkd&NQoXX@!Dw&d( zpUV_RDw$T4n9LMIDw&g+p2(B|CiB2#2^KOxIftn#2Shf2$u=<A112Yd$r*X2xkXI# zNG0=&Qu3IV6y%lWGOZ$&EKbZRVcG^IGxO4zHWZUe78@8cZ7YV5rAft12Z~813yPVJ z6_=7mmVo?whE$S)fq@BnhYTn$fqDjf4EziN;Fd5Wc%Fz6yylA$Jfq48+Oo&M2+l8z z;GP61S2Hkx`W7G(#A9UOU;wc>!6c}k0ot9+z`zXcVSwa#z;d8DM@9xvTiSvF1brAl zFoB_hvxi{<!zxBQP|K4khpCOThf9RZi)#bVJ`m((;x*z;;++I8Q9!maGO#fmWME`q zWbi=|(+7z$eLxmtoX5b#z{nsB?wi0|%gDg?F9{^hxRDXGz7;0U#K6VC#L&tx73@D3 zh9I~}Obl5JMvP^Q<%|`Km5f!4)r>WawTyL)^<aKEV;PDVHg!abF)%T(LS5U-(8s{V zFoj_X0}o?9V<`hK<3`4F3?gW5+ReC!aWCUO#{G;37!NWYVm!=v1kB&VxEn<bn>r%J zKs6p#H`RmPw2MKG@hIat1`RZq9c4Vmc%1PB<4ML-jHemTFrH;R2j(ARJc=TQO&yV9 zI718KvLg&~NN(a{U}DT;%mc@*5aR-9n&x0&WIM%Rz>vn|!ED8%!}5sr3Y!pH4~WN* zhTx;ilgu|@NMguhC}OB$XkzGMn8YxPVG+YBhD{8+7!EO<Vz|U`i{TN&D~3-DzZjVq zxfq2Qr5KeMwHS>Utr(pcy%>WSqZpGIvlxpQs~DRYyBH@i&SG4|xQcNT<1WTSjHehc zG2UXB!NAOz#~{ks1g6Cprh{p5#ttwo!Po<)B^mp`v=n0tn3iUo0;Xl4`g@`J`yl!m z3n2O#3nBU$iy-<Liy`_MOCb6gOCkChD<JwAE1~+Up!%z!`fH&2YoYq<p!(~f`Zq)M zZ-MIH3e~?2s((9F{|>1BolyO|p!)Yh_3wk~-w)M)0IL5WRR1BU{=-oHN1*zTL-n74 z>OTq9e+sJqG*tf?sQ$B1{pT1Y7>pRK7@Qcq7=jq0h>2~+W^l-hF?ND!3C4afEyXw$ zOv^y!`yld+g%EkhVu(CrDMX&J5-MK}m9K@$*F)vEK;^eV<##~kcR}U%LFErX<qtvS zk3i*5K;=(C<<CIn$?!45Ot8<y8N0x=1mgrSEydUhrlleBj0F&R#zKfZV=+XYu>>k# z1(mOc%GW~W>!9*mq4L|H@;jjNJE8LXq4Ecy@`s@EhoSN(q4KAo@@Js(XBmVT)EM*_ z%oyw#+!*{A!WiNh(irj>${6Yx+8Fv6rZLQ8SjMo9u^a3g3C4+FT8eQRn3jQvGZsR` z8H*v}jHOWVYN&WERJ<N4z6~nA11i1?Dt-Veeh4al1S)<CDt-nkPDVU2_JUn3$v6p2 zOEI>AX=z4CnIZ#`XDouqGZsVS8A~AYjHOWd8mN3NRK5->Uk{bv4wc^lmEQ@K-vyOF z2$eqsl|KxXKLV9M4V6Cwl|KuWKgYnvz{eoQAP0#p#>rsUOF?MHVklh;rFTH-Ls0q* z0}q1~gA#)lW`1UD2b(0#2q|l2AbiFW2%oVO%CCd+>!JLeQ2s6`|1gw)1j;`P<)gbB zl3QdL!8Btjl&*);yP)(DD1DBB2|Tt78c_wc9XP;k2QJ0}#u5f@#&X6w1_8!Rj5`@5 z822z9W>8=}#&{N^&dz6;!qCgm$5_Bv$XLW!%vi!$%1C7~@TfGnJ;qqZSkJ%(ZPh^9 zjT{Wj42%py42;a6dkR49GARZo1{Q|t3^N%Rz_m9sSQiUp6XQ$<KBU^+fI*ZI`zb#6 zKqq@JK4g3Zx-pyaDT5h<E(55I%gw;UAiyBRpv+*$V9x+KB`AlX2;6tBV5nheW0=M; zi!p*R7GVn`3qw5vXe=Bw4z2+n|JG*EVbEpJW6)<XU@&AbVlZYfVK8MdV=!m1V6bGc zVz6egVX$S$X2@lj%`k^yF2g*A`3wsf7BVbiSj@15VJX8hhUE+^7*;Z@Vpz?vhG8wk zI)?QO8yGe+Y-ZTPu$5sO!*+%p3_BUB7;ZD%Ww_7qkl``IQ-<dZFBx7lyk&UL@R8v& z!&ipy3_lruGyG-v&&bHg%*e{f&dABg&B(_nz$nBh!YIZl!6?Nj!zjn7z^KHi!l=fm z!KlTk!>Gq-z-Yv1!f3{5!Dz*3!)V9o!05#2!sy26!RW>4!|2Btz!=0B!WhOF$r#O; z#+bpF#hAmu$WYJV!r;o_#^BE2!Qjc@#o*20!{E!{$KcNpz!1m~#1PC7!Vt<3#t_aB z!4Sz1#SqO9!w}0*&QQs)n_&;bUWR=P`xy=}9Ar4eaG2o;!%>D~496KxFq~vK#c-P8 z48vK5a}4JhE-+kVxXf^c;VQ#5hU*MB7;ZA$Vz|R_kKqBsBZemo&lp}XykdC6@Q&dF z!zYF>4Br@jF#KZp!|;!hfsu)kg^`VsgOQ7omyw@QkWrXXlu?{fl2MvbmQkKjkx`jZ zl~J8hlTn*dmr<Y5kkOdYl+m2glF^#cmeHQkk<ppamC>EilhK>em(iawkTIAslrfw! ziZO;UoiUR!n=u#BZe%cH0QE*#8F&~(7-Sff7&I8yGcYrFGWalV0Mq`As~DIVJQ>$8 zFfsTru47<g@CWr!7(5Xvia~;bljRJ{S(bAw=UFbWTx7Y#a+&1`0}}%i_y$A<7SP>~ z9BAsVuv}%i#&VtI2Fp#BTP(L(o`ThbZb@VS--d{;{tnAsmU}GsSst)FWO>B$nB^H* iJ?JJy2Jp>@$m*pSxUltUz_}JwsxW{{6((?xgc$(ZhY%+K literal 0 HcmV?d00001 diff --git a/ring-android/app/src/main/res/font/ubuntu_medium.ttf b/ring-android/app/src/main/res/font/ubuntu_medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5296045e961bc51aad3115845134299063c35581 GIT binary patch literal 284424 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�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 literal 0 HcmV?d00001 diff --git a/ring-android/app/src/main/res/font/ubuntu_regular.ttf b/ring-android/app/src/main/res/font/ubuntu_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..dbb834a4dd5df2abb80d80ade8d2566bef49b90b GIT binary patch literal 298928 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 Q7F4P*fJ+r7aF2u;01&ljBme*a literal 0 HcmV?d00001 diff --git a/ring-android/app/src/main/res/layout-w720dp-land/tv_activity_about.xml b/ring-android/app/src/main/res/layout-w720dp-land/tv_activity_about.xml deleted file mode 100644 index 48f8e0595..000000000 --- a/ring-android/app/src/main/res/layout-w720dp-land/tv_activity_about.xml +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- -Copyright (C) 2004-2016 Savoir-faire Linux Inc. - -Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> - -This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software - Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. ---> - -<fragment xmlns:android="http://schemas.android.com/apk/res/android" - android:name="cx.ring.tv.about.AboutDetailsFragment" - android:id="@+id/details_fragment" - android:layout_width="match_parent" - android:layout_height="match_parent" - /> \ No newline at end of file diff --git a/ring-android/app/src/main/res/layout-w720dp-land/tv_frag_call.xml b/ring-android/app/src/main/res/layout-w720dp-land/tv_frag_call.xml index a4114ce97..2d45aecaa 100644 --- a/ring-android/app/src/main/res/layout-w720dp-land/tv_frag_call.xml +++ b/ring-android/app/src/main/res/layout-w720dp-land/tv_frag_call.xml @@ -211,6 +211,34 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. app:useCompatPadding="true" tools:visibility="visible" /> + <LinearLayout + android:id="@+id/record_layout" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_margin="16dp" + android:gravity="center_vertical" + android:layout_alignParentLeft="true" + android:visibility="invisible" + tools:visibility="visible"> + + <View + android:id="@+id/record_indicator" + android:layout_width="13dp" + android:layout_height="13dp" + android:backgroundTint="#BF0046" + android:background="@drawable/item_color_background" /> + + <TextView + android:id="@+id/record_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:textSize="12sp" + tools:text="Thomas"/> + + </LinearLayout> + <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/call_add_btn" android:layout_width="wrap_content" diff --git a/ring-android/app/src/main/res/layout/frag_conversation_tv.xml b/ring-android/app/src/main/res/layout/frag_conversation_tv.xml index 226588124..70647e861 100644 --- a/ring-android/app/src/main/res/layout/frag_conversation_tv.xml +++ b/ring-android/app/src/main/res/layout/frag_conversation_tv.xml @@ -11,14 +11,16 @@ android:id="@+id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textSize="22sp" - android:textStyle="bold" /> + android:textSize="20sp" + android:textStyle="bold" + android:fontFamily="@font/ubuntu_medium"/> <TextView android:id="@+id/subtitle" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textSize="18sp" /> + android:textSize="16sp" + android:fontFamily="@font/mulish_regular"/> <FrameLayout android:layout_width="match_parent" @@ -77,12 +79,11 @@ <ImageButton android:id="@+id/button_text" - android:layout_width="50dp" - android:layout_height="50dp" - android:alpha="0.85" + android:layout_width="40dp" + android:layout_height="40dp" android:background="@drawable/tv_button_shape" android:contentDescription="@string/tv_send_text" - android:src="@drawable/baseline_chat_24" + android:src="@drawable/baseline_androidtv_chat" android:tint="@color/white" /> <TextView @@ -106,12 +107,11 @@ <ImageButton android:id="@+id/button_audio" - android:layout_width="50dp" - android:layout_height="50dp" - android:alpha="0.85" + android:layout_width="40dp" + android:layout_height="40dp" android:background="@drawable/tv_button_shape" android:contentDescription="@string/tv_send_audio" - android:src="@drawable/baseline_mic_24" + android:src="@drawable/baseline_androidtv_message_audio" android:tint="@color/white" /> <TextView @@ -136,12 +136,11 @@ <ImageButton android:id="@+id/button_video" - android:layout_width="50dp" - android:layout_height="50dp" - android:alpha="0.85" + android:layout_width="40dp" + android:layout_height="40dp" android:background="@drawable/tv_button_shape" android:contentDescription="@string/tv_send_video" - android:src="@drawable/baseline_photo_camera_24" + android:src="@drawable/baseline_androidtv_message_video" android:tint="@color/white" /> <TextView diff --git a/ring-android/app/src/main/res/layout/tv_about_layout.xml b/ring-android/app/src/main/res/layout/tv_about_layout.xml new file mode 100644 index 000000000..add636cb3 --- /dev/null +++ b/ring-android/app/src/main/res/layout/tv_about_layout.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<TextView xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/title_text" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:layout_gravity="center" + android:gravity="center" + android:textAlignment="center"/> diff --git a/ring-android/app/src/main/res/layout/tv_activity_about.xml b/ring-android/app/src/main/res/layout/tv_activity_about.xml deleted file mode 100644 index 48f8e0595..000000000 --- a/ring-android/app/src/main/res/layout/tv_activity_about.xml +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- -Copyright (C) 2004-2016 Savoir-faire Linux Inc. - -Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> - -This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software - Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. ---> - -<fragment xmlns:android="http://schemas.android.com/apk/res/android" - android:name="cx.ring.tv.about.AboutDetailsFragment" - android:id="@+id/details_fragment" - android:layout_width="match_parent" - android:layout_height="match_parent" - /> \ No newline at end of file diff --git a/ring-android/app/src/main/res/layout/tv_activity_contact_more.xml b/ring-android/app/src/main/res/layout/tv_activity_contact_more.xml new file mode 100644 index 000000000..a7c8a82f0 --- /dev/null +++ b/ring-android/app/src/main/res/layout/tv_activity_contact_more.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<fragment xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/generalFragment" + android:name="cx.ring.tv.contact.more.TVContactMoreFragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context="cx.ring.tv.contact.more.TVContactMoreActivity" /> \ No newline at end of file diff --git a/ring-android/app/src/main/res/layout/tv_activity_home.xml b/ring-android/app/src/main/res/layout/tv_activity_home.xml index 7aa205ebc..98b8881ca 100644 --- a/ring-android/app/src/main/res/layout/tv_activity_home.xml +++ b/ring-android/app/src/main/res/layout/tv_activity_home.xml @@ -1,10 +1,39 @@ <?xml version="1.0" encoding="utf-8"?> -<fragment xmlns:android="http://schemas.android.com/apk/res/android" +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/main_browse_fragment" - android:name="cx.ring.tv.main.MainFragment" android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context="cx.ring.tv.main.HomeActivity" - tools:deviceIds="tv" - tools:ignore="MergeRootFrame" /> + android:layout_height="match_parent"> + + <FrameLayout + android:id="@+id/previewView" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:visibility="invisible"/> + + <ImageView + android:id="@+id/blur" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scaleX="-1"/> + + <View + android:id="@+id/fade" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/tv_transparent"/> + + <fragment + android:id="@+id/main_browse_fragment" + android:name="cx.ring.tv.main.MainFragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context="cx.ring.tv.main.HomeActivity" + tools:deviceIds="tv" + tools:ignore="MergeRootFrame" /> + + <androidx.fragment.app.FragmentContainerView + android:id="@+id/fragment_container" + android:layout_width="match_parent" + android:layout_height="match_parent"/> + +</FrameLayout> \ No newline at end of file diff --git a/ring-android/app/src/main/res/layout/tv_activity_settings.xml b/ring-android/app/src/main/res/layout/tv_activity_settings.xml index ccae34dd4..d42e1879f 100644 --- a/ring-android/app/src/main/res/layout/tv_activity_settings.xml +++ b/ring-android/app/src/main/res/layout/tv_activity_settings.xml @@ -2,7 +2,7 @@ <fragment xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/generalFragment" - android:name="cx.ring.tv.account.TVSettingsFragment" + android:name="cx.ring.tv.settings.TVSettingsFragment" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context="cx.ring.tv.account.TVSettingsActivity" /> \ No newline at end of file + tools:context="cx.ring.tv.settings.TVSettingsActivity" /> \ No newline at end of file diff --git a/ring-android/app/src/main/res/layout/tv_frag_call.xml b/ring-android/app/src/main/res/layout/tv_frag_call.xml index 7a206e113..a1e71e2f9 100644 --- a/ring-android/app/src/main/res/layout/tv_frag_call.xml +++ b/ring-android/app/src/main/res/layout/tv_frag_call.xml @@ -246,5 +246,33 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. app:useCompatPadding="true" tools:visibility="visible" /> + <LinearLayout + android:id="@+id/record_layout" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_margin="16dp" + android:gravity="center_vertical" + android:layout_alignParentLeft="true" + android:visibility="invisible" + tools:visibility="visible"> + + <View + android:id="@+id/record_indicator" + android:layout_width="13dp" + android:layout_height="13dp" + android:backgroundTint="#BF0046" + android:background="@drawable/item_color_background" /> + + <TextView + android:id="@+id/record_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:textSize="12sp" + tools:text="Thomas"/> + + </LinearLayout> + </RelativeLayout> </layout> \ No newline at end of file diff --git a/ring-android/app/src/main/res/layout/tv_frag_contact.xml b/ring-android/app/src/main/res/layout/tv_frag_contact.xml index ba4638167..41ec18ae2 100644 --- a/ring-android/app/src/main/res/layout/tv_frag_contact.xml +++ b/ring-android/app/src/main/res/layout/tv_frag_contact.xml @@ -1,6 +1,24 @@ <?xml version="1.0" encoding="utf-8"?> -<fragment xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/details_fragment" - android:name="cx.ring.tv.contact.TVContactFragment" - android:layout_width="wrap_content" - android:layout_height="wrap_content" /> \ No newline at end of file +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <FrameLayout + android:id="@+id/previewView" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:visibility="invisible"/> + + <ImageView + android:id="@+id/background" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scaleX="-1"/> + + <fragment + android:id="@+id/details_fragment" + android:name="cx.ring.tv.contact.TVContactFragment" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + +</FrameLayout> \ No newline at end of file diff --git a/ring-android/app/src/main/res/layout/tv_frag_share.xml b/ring-android/app/src/main/res/layout/tv_frag_share.xml index 17fb2e066..d6b9cc4b5 100644 --- a/ring-android/app/src/main/res/layout/tv_frag_share.xml +++ b/ring-android/app/src/main/res/layout/tv_frag_share.xml @@ -34,6 +34,7 @@ android:text="@string/share_message" android:textColor="@color/text_color_primary_dark" android:textSize="16sp" + android:fontFamily="@font/ubuntu_light" app:layout_constraintBottom_toTopOf="@+id/qr_image" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/ring-android/app/src/main/res/layout/tv_titleview.xml b/ring-android/app/src/main/res/layout/tv_titleview.xml index 3dc85a4a0..7c245313d 100644 --- a/ring-android/app/src/main/res/layout/tv_titleview.xml +++ b/ring-android/app/src/main/res/layout/tv_titleview.xml @@ -3,20 +3,11 @@ xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res-auto"> - <androidx.leanback.widget.SearchOrbView - android:id="@+id/title_orb" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical|start" - android:layout_marginStart="48dp" - android:layout_marginTop="8dp" - android:transitionGroup="true" /> - <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerVertical="true" - android:layout_marginEnd="24dp" + android:layout_marginEnd="18dp" android:layout_marginStart="260dp" android:layout_toStartOf="@id/title_photo_contact" android:gravity="end" @@ -28,7 +19,9 @@ android:layout_height="wrap_content" android:ellipsize="end" android:maxLines="1" - android:textAppearance="@android:style/TextAppearance.Large" + android:fontFamily="@font/ubuntu_medium" + android:textSize="16sp" + android:textColor="@color/white" android:visibility="gone" tools:text="account alias" tools:visibility="visible" /> @@ -39,7 +32,8 @@ android:layout_height="wrap_content" android:ellipsize="end" android:maxLines="1" - android:textAppearance="@android:style/TextAppearance.DeviceDefault" + android:textSize="12sp" + android:fontFamily="@font/ubuntu_regular" android:visibility="gone" tools:text="account name" tools:visibility="visible" /> @@ -48,11 +42,37 @@ <ImageView android:id="@+id/title_photo_contact" - android:layout_width="80dp" - android:layout_height="80dp" + android:layout_width="52dp" + android:layout_height="52dp" + android:layout_toStartOf="@+id/title_settings" + android:layout_gravity="center_vertical" + android:layout_marginEnd="16dp" + android:layout_marginTop="8dp" + app:srcCompat="@drawable/ic_contact_picture_fallback" /> + + <ImageButton + android:id="@+id/title_settings" + android:layout_width="52dp" + android:layout_height="52dp" android:layout_alignParentEnd="true" android:layout_gravity="center_vertical|end" android:layout_marginEnd="24dp" - android:padding="6dp" - app:srcCompat="@drawable/ic_contact_picture_fallback" /> + android:layout_marginTop="18dp" + android:padding="26dp" + android:tint="@color/grey_300" + android:layout_centerVertical="true" + android:background="@drawable/tv_button_shape" + android:src="@drawable/baseline_androidtv_settings" + android:visibility="invisible"/> + + <androidx.leanback.widget.SearchOrbView + android:id="@+id/title_orb" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical|start" + android:layout_marginStart="48dp" + android:layout_marginTop="8dp" + android:transitionGroup="true" + android:visibility="invisible"/> + </merge> \ No newline at end of file diff --git a/ring-android/app/src/main/res/values/colors.xml b/ring-android/app/src/main/res/values/colors.xml index 7feeafe29..7dbb71aca 100644 --- a/ring-android/app/src/main/res/values/colors.xml +++ b/ring-android/app/src/main/res/values/colors.xml @@ -50,4 +50,11 @@ <color name="button_border">@color/color_primary_dark</color> <color name="chip_background">#330f426b</color> + <!-- TV --> + <color name="tv_transparent">#a0000000</color> + <color name="tv_contact_background">#00335C</color> + <color name="tv_contact_row_background">#002B4E</color> + <color name="tv_search_background">#0095EB</color> + + </resources> diff --git a/ring-android/app/src/main/res/values/strings.xml b/ring-android/app/src/main/res/values/strings.xml index cb1017984..888b0faa2 100644 --- a/ring-android/app/src/main/res/values/strings.xml +++ b/ring-android/app/src/main/res/values/strings.xml @@ -73,7 +73,7 @@ along with this program; if not, write to the Free Software <string name="menu_item_account">Manage account</string> <string name="menu_item_settings">Settings</string> <string name="menu_item_plugin_list">Plugins Settings</string> - <string name="menu_item_share">Share my contact</string> + <string name="menu_item_share">Share my account</string> <string name="menu_item_about">About Jami</string> <!-- Dialing Fragment --> @@ -158,7 +158,10 @@ along with this program; if not, write to the Free Software <string name="ab_action_speakerphone">Enable speaker</string> <string name="ab_action_contact_add">Add to contacts</string> <string name="ab_action_contact_add_question">Add to contacts?</string> + <string name="hist_contact_invited">Contact invited</string> <string name="hist_contact_added">Contact added</string> + <string name="hist_contact_left">Contact left</string> + <string name="hist_contact_banned">Contact banned</string> <string name="hist_invitation_received">Invitation received</string> <string name="ab_action_audio_call">Audio call</string> <string name="ab_action_video_call">Video call</string> @@ -214,6 +217,7 @@ along with this program; if not, write to the Free Software <string name="clear_history_dialog_title">Clear history?</string> <string name="clear_history_dialog_message">This action cannot be undone.</string> <string name="clear_history_completed">History has been cleared.</string> + <string name="clear_history">Clear</string> <!-- Conversation --> <string name="conversation_details">Contact details</string> @@ -370,6 +374,7 @@ along with this program; if not, write to the Free Software <string name="tv_send_text">Send Text</string> <string name="tv_send_audio">Send Audio</string> <string name="tv_send_video">Send Media</string> + <string name="tv_action_more">More</string> <string name="conversation_input_speech_hint">Say something…</string> <!-- Wizard --> diff --git a/ring-android/app/src/main/res/values/strings_account.xml b/ring-android/app/src/main/res/values/strings_account.xml index 0ffde5a80..4f968ed1e 100644 --- a/ring-android/app/src/main/res/values/strings_account.xml +++ b/ring-android/app/src/main/res/values/strings_account.xml @@ -213,6 +213,17 @@ along with this program; if not, write to the Free Software <string name="account_sip_success_message">You have successfully registered your Sip account.</string> <string name="account_sip_register_anyway">Register anyway</string> + <!-- TV --> + <string name="account_tv_settings_header">Account settings</string> + <string name="account_tv_advance_settings_header">Advanced settings</string> + <string name="account_tv_add_contact">Add contact</string> + <string name="account_tv_advanced_settings">Change the general settings</string> + <string name="account_tv_about">About Jami</string> + <string name="tv_about_version">Version</string> + <string name="tv_about_license">License</string> + <string name="tv_about_author">Author rights</string> + <string name="tv_about_credits">Credits</string> + <string name="account_link_device">Connect from another device</string> <string name="account_link_button">Connect from network</string> <string name="account_link_archive_button">Connect from backup</string> @@ -226,7 +237,7 @@ along with this program; if not, write to the Free Software <string name="account_end_export_button">close</string> <string name="account_end_export_infos">Your PIN is:\n\n%%\n\nTo complete the process, you need to open Jami on the new device. Create a new account with \"Link this device to an account\". Your PIN is valid for 10 minutes.</string> <string name="account_link_export_info_light">To use this account on other devices, you must first expose it on Jami. This will generate a PIN code that you must enter on the new device to set up the account. The PIN is valid for 10 minutes.</string> - <string name="account_export_title">Add devices</string> + <string name="account_export_title">Link account to other devices</string> <string name="account_connect_server_button">Connect to management server</string> <string name="account_connect_button">Connect</string> <string name="prompt_server">Jami management server URL</string> @@ -262,7 +273,7 @@ along with this program; if not, write to the Free Software <string name="account_sip_cannot_be_registered">Can\'t register account</string> <!-- Edit profile--> - <string name="account_edit_profile">Edit your profile</string> + <string name="account_edit_profile">Edit my account</string> <!-- Devices --> <string name="account_revoke_device_hint">Enter password to confirm</string> diff --git a/ring-android/app/src/main/res/values/styles.xml b/ring-android/app/src/main/res/values/styles.xml index afdcbc128..5fccdf6de 100644 --- a/ring-android/app/src/main/res/values/styles.xml +++ b/ring-android/app/src/main/res/values/styles.xml @@ -181,7 +181,7 @@ <style name="Theme.Ring.Leanback.GuidedStep.First" /> <style name="Theme.Ring.LeanbackBrowse" parent="Theme.Leanback.Browse"> - <item name="defaultSearchColor">@color/color_primary_light</item> + <item name="defaultSearchColor">@color/tv_search_background</item> <item name="searchOrbViewStyle">@style/CustomSearchOrbView</item> </style> @@ -195,7 +195,7 @@ </style> <style name="CustomSearchOrbView" parent="Widget.Leanback.SearchOrbViewStyle"> - <item name="searchOrbIcon">@drawable/baseline_person_add_24</item> + <item name="searchOrbIcon">@drawable/baseline_search_24</item> </style> <!-- A default card style. Used in cards example. --> @@ -236,16 +236,8 @@ <item name="android:padding">16dp</item> </style> - <style name="IconCardTitleStyle" parent="Widget.Leanback.ImageCardView.TitleStyle"> - <item name="android:maxLines">2</item> - <item name="android:minLines">2</item> - <item name="android:textAlignment">center</item> - <item name="android:gravity">center</item> - </style> - <style name="IconCardInfoAreaStyle" parent="Widget.Leanback.ImageCardView.InfoAreaStyle"> <item name="android:layout_width">96dp</item> - <item name="android:background">@null</item> <item name="layout_viewType">main</item> </style> @@ -255,13 +247,6 @@ <item name="android:layout_height">@dimen/search_image_card_height</item> </style> - <style name="SearchCardStyle" parent="Widget.Leanback.ImageCardViewStyle"> - <item name="cardBackground">@null</item> - <item name="android:layout_width">96dp</item> - <item name="android:layout_height">96dp</item> - <item name="lbImageCardViewType">Title</item> - </style> - <style name="ContactCardTheme" parent="DefaultCardTheme"> <item name="imageCardViewStyle">@style/ContactTitleViewStyle</item> </style> @@ -270,14 +255,9 @@ <item name="imageCardViewStyle">@style/ContactCompleteCardViewStyle</item> </style> - <style name="SearchCardTheme" parent="Theme.Leanback"> - <item name="imageCardViewStyle">@style/SearchCardStyle</item> - <item name="imageCardViewImageStyle">@style/SearchCardImageStyle</item> - </style> <!-- Theme corresponding to the IconCardStyle --> <style name="IconCardTheme" parent="Theme.Leanback"> <item name="imageCardViewStyle">@style/IconCardViewStyle</item> - <item name="imageCardViewTitleStyle">@style/IconCardTitleStyle</item> <item name="imageCardViewImageStyle">@style/IconCardImageStyle</item> <item name="imageCardViewInfoAreaStyle">@style/IconCardInfoAreaStyle</item> </style> @@ -360,4 +340,59 @@ <item name="android:textSize">20sp</item> </style> + <!-- Style for the title view in a GuidanceStylist's default layout. --> + <style name="Widget.Leanback.GuidanceTitleStyle"> + <item name="android:layout_toEndOf">@id/guidance_icon</item> + <item name="android:layout_width">wrap_content</item> + <item name="android:layout_height">wrap_content</item> + <item name="android:layout_alignWithParentIfMissing">true</item> + <item name="android:ellipsize">end</item> + <item name="android:fontFamily">@font/ubuntu_light</item> + <item name="android:gravity">start</item> + <item name="android:maxLines">2</item> + <item name="android:paddingBottom">4dp</item> + <item name="android:paddingTop">2dp</item> + <item name="android:textColor">#FFFFFF</item> + <item name="android:textSize">36sp</item> + </style> + + <!-- Style for the description view in a GuidanceStylist's default layout. --> + <style name="Widget.Leanback.GuidanceDescriptionStyle"> + <item name="android:layout_below">@id/guidance_title</item> + <item name="android:layout_toEndOf">@id/guidance_icon</item> + <item name="android:layout_width">wrap_content</item> + <item name="android:layout_height">wrap_content</item> + <item name="android:layout_alignWithParentIfMissing">true</item> + <item name="android:ellipsize">end</item> + <item name="android:fontFamily">@font/mulish_regular</item> + <item name="android:gravity">start</item> + <item name="android:maxLines">6</item> + <item name="android:textColor">#88F1F1F1</item> + <item name="android:textSize">14sp</item> + <item name="android:lineSpacingExtra">3dp</item> + </style> + + <!-- Style for an action's title in a GuidedActionsStylist's default item layout. --> + <style name="Widget.Leanback.GuidedActionItemTitleStyle"> + <item name="android:layout_width">match_parent</item> + <item name="android:layout_height">wrap_content</item> + <item name="android:alpha">1.00</item> + <item name="android:fontFamily">@font/ubuntu_medium</item> + <item name="android:maxLines">@integer/lb_guidedactions_item_title_min_lines</item> + <item name="android:textColor">@color/lb_guidedactions_item_unselected_text_color</item> + <item name="android:textSize">@dimen/lb_guidedactions_item_title_font_size</item> + </style> + + <!-- Style for an action's description in a GuidedActionsStylist's default item layout. --> + <style name="Widget.Leanback.GuidedActionItemDescriptionStyle"> + <item name="android:layout_width">match_parent</item> + <item name="android:layout_height">wrap_content</item> + <item name="android:alpha">0.50</item> + <item name="android:fontFamily">@font/mulish_regular</item> + <item name="android:maxLines">@integer/lb_guidedactions_item_description_min_lines</item> + <item name="android:textColor">@color/lb_guidedactions_item_unselected_text_color</item> + <item name="android:textSize">@dimen/lb_guidedactions_item_description_font_size</item> + <item name="android:visibility">gone</item> + </style> + </resources> \ No newline at end of file diff --git a/ring-android/app/src/main/res/xml/tv_about_pref.xml b/ring-android/app/src/main/res/xml/tv_about_pref.xml new file mode 100644 index 000000000..f7688fbfd --- /dev/null +++ b/ring-android/app/src/main/res/xml/tv_about_pref.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" + android:title="@string/about"> + + <PreferenceCategory android:title="@string/tv_about_version"> + <Preference + android:key="About.version" + android:selectable="true" + android:enabled="true"/> + </PreferenceCategory> + + <PreferenceCategory android:title="@string/tv_about_license"> + <Preference + android:key="About.license" + android:selectable="true" + android:enabled="true"/> + </PreferenceCategory> + + <PreferenceCategory android:title="@string/tv_about_author"> + <Preference + android:key="About.rights" + android:selectable="true" + android:enabled="true"/> + </PreferenceCategory> + + <PreferenceCategory android:title="@string/tv_about_credits"> + <Preference + android:key="About.credits" + android:selectable="true" + android:enabled="true"/> + </PreferenceCategory> + +</PreferenceScreen> \ No newline at end of file diff --git a/ring-android/app/src/main/res/xml/tv_account_general_pref.xml b/ring-android/app/src/main/res/xml/tv_account_general_pref.xml index 4b084a2d6..62ca37cba 100644 --- a/ring-android/app/src/main/res/xml/tv_account_general_pref.xml +++ b/ring-android/app/src/main/res/xml/tv_account_general_pref.xml @@ -1,5 +1,16 @@ <?xml version="1.0" encoding="utf-8"?> -<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> +<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" + android:title="@string/action_settings"> + + <PreferenceCategory android:title="@string/about"> + <Preference + android:key="Account.about" + android:title="@string/account_tv_about" + android:selectable="true" + android:fragment="cx.ring.tv.settings.TVAboutFragment" + android:icon="@drawable/ic_jami"/> + </PreferenceCategory> + <PreferenceCategory android:title="@string/account_basic_category"> <SwitchPreference android:defaultValue="false" diff --git a/ring-android/app/src/main/res/xml/tv_contact_more_pref.xml b/ring-android/app/src/main/res/xml/tv_contact_more_pref.xml new file mode 100644 index 000000000..22d9d7e77 --- /dev/null +++ b/ring-android/app/src/main/res/xml/tv_contact_more_pref.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" + android:title="@string/tv_action_more"> + <PreferenceCategory> + <Preference + android:key="Contact.clear" + android:title="@string/conversation_action_history_clear" + android:icon="@drawable/baseline_androidtv_clearconversation" + /> + <Preference + android:key="Contact.delete" + android:title="@string/conversation_action_remove_this" + android:icon="@drawable/baseline_androidtv_deletecontact" + /> + </PreferenceCategory> +</PreferenceScreen> \ No newline at end of file diff --git a/ring-android/app/src/withFirebase/java/cx/ring/application/JamiApplicationFirebase.java b/ring-android/app/src/withFirebase/java/cx/ring/application/JamiApplicationFirebase.java deleted file mode 100644 index 4a1493348..000000000 --- a/ring-android/app/src/withFirebase/java/cx/ring/application/JamiApplicationFirebase.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ -package cx.ring.application; - -import android.util.Log; - -import com.google.firebase.FirebaseApp; -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.RemoteMessage; - -public class JamiApplicationFirebase extends JamiApplication { - static private final String TAG = JamiApplicationFirebase.class.getSimpleName(); - private String pushToken = null; - - @Override - public void onCreate() { - super.onCreate(); - try { - FirebaseApp.initializeApp(this); - FirebaseMessaging.getInstance().getToken().addOnSuccessListener(token -> { - Log.w(TAG, "Found push token"); - try { - setPushToken(token); - } catch (Exception e) { - Log.e(TAG, "Can't set push token", e); - } - }); - } catch (Exception e) { - Log.e(TAG, "Can't start service", e); - } - } - - @Override - public String getPushToken() { - return pushToken; - } - - public void setPushToken(String token) { - // Log.d(TAG, "setPushToken: " + token); - pushToken = token; - if (mAccountService != null && mPreferencesService != null) { - if (mPreferencesService.getSettings().isAllowPushNotifications()) { - mAccountService.setPushNotificationToken(token); - } - } - } - - public void onMessageReceived(RemoteMessage remoteMessage) { - // Log.d(TAG, "onMessageReceived: " + remoteMessage.getFrom()); - if (mAccountService != null) - mAccountService.pushNotificationReceived(remoteMessage.getFrom(), remoteMessage.getData()); - } -} diff --git a/ring-android/app/src/withFirebase/java/cx/ring/application/JamiApplicationFirebase.kt b/ring-android/app/src/withFirebase/java/cx/ring/application/JamiApplicationFirebase.kt new file mode 100644 index 000000000..5fff7795f --- /dev/null +++ b/ring-android/app/src/withFirebase/java/cx/ring/application/JamiApplicationFirebase.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ +package cx.ring.application + +import android.util.Log +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class JamiApplicationFirebase : JamiApplication() { + + override var pushToken: String? = null + set(token) { + //Log.d(TAG, "setPushToken: $token"); + field = token + if (mPreferencesService.settings.isAllowPushNotifications) { + mAccountService.setPushNotificationToken(token) + } + } + + override fun onCreate() { + super.onCreate() + try { + Log.w(TAG, "onCreate()") + FirebaseApp.initializeApp(this) + FirebaseMessaging.getInstance().token.addOnSuccessListener { token: String? -> + Log.w(TAG, "Found push token") + try { + pushToken = token + } catch (e: Exception) { + Log.e(TAG, "Can't set push token", e) + } + } + } catch (e: Exception) { + Log.e(TAG, "Can't start service", e) + } + } + + fun onMessageReceived(remoteMessage: RemoteMessage) { + // Log.d(TAG, "onMessageReceived: " + remoteMessage.getFrom()); + mAccountService.pushNotificationReceived(remoteMessage.from, remoteMessage.data) + } + + companion object { + private val TAG = JamiApplicationFirebase::class.simpleName + } +} \ No newline at end of file diff --git a/ring-android/app/src/withFirebase/java/cx/ring/services/JamiFirebaseMessagingService.java b/ring-android/app/src/withFirebase/java/cx/ring/services/JamiFirebaseMessagingService.java deleted file mode 100644 index d56569d7e..000000000 --- a/ring-android/app/src/withFirebase/java/cx/ring/services/JamiFirebaseMessagingService.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ -package cx.ring.services; - -import android.content.Context; -import android.os.PowerManager; -import android.util.Log; - -import androidx.annotation.NonNull; - -import com.google.firebase.messaging.FirebaseMessagingService; -import com.google.firebase.messaging.RemoteMessage; - -import cx.ring.application.JamiApplication; -import cx.ring.application.JamiApplicationFirebase; - -public class JamiFirebaseMessagingService extends FirebaseMessagingService { - @Override - public void onMessageReceived(@NonNull RemoteMessage remoteMessage) { - try { - // Even if wakeLock is deprecated, without this part, some devices are blocking - // during the call negotiation. So, re-add this code to avoid to block here. - PowerManager pm = (PowerManager)getSystemService(Context.POWER_SERVICE); - PowerManager.WakeLock wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "wake:push"); - wl.setReferenceCounted(false); - wl.acquire(10 * 1000); - } catch (Exception e) { - Log.w("JamiFirebaseMessaging", "Can't acquire wake lock", e); - } - - JamiApplicationFirebase app = (JamiApplicationFirebase)JamiApplication.getInstance(); - if (app != null) - app.onMessageReceived(remoteMessage); - } - - @Override - public void onNewToken(@NonNull String refreshedToken) { - JamiApplicationFirebase app = (JamiApplicationFirebase)JamiApplication.getInstance(); - if (app != null) - app.setPushToken(refreshedToken); - } -} diff --git a/ring-android/app/src/withFirebase/java/cx/ring/services/JamiFirebaseMessagingService.kt b/ring-android/app/src/withFirebase/java/cx/ring/services/JamiFirebaseMessagingService.kt new file mode 100644 index 000000000..b85f196d5 --- /dev/null +++ b/ring-android/app/src/withFirebase/java/cx/ring/services/JamiFirebaseMessagingService.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ +package cx.ring.services + +import android.os.PowerManager +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import cx.ring.application.JamiApplication +import cx.ring.application.JamiApplicationFirebase + +class JamiFirebaseMessagingService : FirebaseMessagingService() { + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + try { + // Even if wakeLock is deprecated, without this part, some devices are blocking + // during the call negotiation. So, re-add this code to avoid to block here. + val pm = getSystemService(POWER_SERVICE) as PowerManager + val wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "wake:push") + wl.setReferenceCounted(false) + wl.acquire((10 * 1000).toLong()) + } catch (e: Exception) { + Log.w("JamiFirebaseMessaging", "Can't acquire wake lock", e) + } + val app = JamiApplication.instance as JamiApplicationFirebase? + app?.onMessageReceived(remoteMessage) + } + + override fun onNewToken(refreshedToken: String) { + Log.w("JamiFirebaseMessaging", "onNewToken $refreshedToken") + val app = JamiApplication.instance as JamiApplicationFirebase? + app?.pushToken = refreshedToken + } +} \ No newline at end of file diff --git a/ring-android/build.gradle b/ring-android/build.gradle index 11800d4fe..7c4c426f8 100644 --- a/ring-android/build.gradle +++ b/ring-android/build.gradle @@ -4,9 +4,13 @@ buildscript { maven { url "https://maven.google.com" } mavenCentral() } + ext.kotlin_version = '1.5.21' + ext.hilt_version = '2.38.1' dependencies { - classpath 'com.android.tools.build:gradle:4.2.1' - classpath 'com.google.gms:google-services:4.3.8' + classpath 'com.android.tools.build:gradle:7.0.0' + classpath 'com.google.gms:google-services:4.3.10' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" } } allprojects { diff --git a/ring-android/gradle/wrapper/gradle-wrapper.properties b/ring-android/gradle/wrapper/gradle-wrapper.properties index 1de822da9..d7f3c26ed 100644 --- a/ring-android/gradle/wrapper/gradle-wrapper.properties +++ b/ring-android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip diff --git a/ring-android/libringclient/build.gradle b/ring-android/libringclient/build.gradle index fd647e2bb..9b10c1954 100644 --- a/ring-android/libringclient/build.gradle +++ b/ring-android/libringclient/build.gradle @@ -1,8 +1,11 @@ +apply plugin: 'kotlin' apply plugin: 'java' +apply plugin: 'kotlin-kapt' dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" // VCard parsing implementation 'com.googlecode.ez-vcard:ez-vcard:0.11.2' @@ -25,6 +28,8 @@ dependencies { // gson implementation 'com.google.code.gson:gson:2.8.7' + api "com.google.dagger:dagger:$hilt_version" + kapt "com.google.dagger:dagger-compiler:$hilt_version" } sourceCompatibility = JavaVersion.VERSION_1_8 diff --git a/ring-android/libringclient/src/main/java/net/jami/account/AccountWizardPresenter.java b/ring-android/libringclient/src/main/java/net/jami/account/AccountWizardPresenter.java index 0a8722aad..dddac96d3 100644 --- a/ring-android/libringclient/src/main/java/net/jami/account/AccountWizardPresenter.java +++ b/ring-android/libringclient/src/main/java/net/jami/account/AccountWizardPresenter.java @@ -227,7 +227,7 @@ public class AccountWizardPresenter extends RootPresenter<net.jami.account.Accou .firstElement() .subscribe(a -> { if (!model.isLink() && a.isJami() && !StringUtils.isEmpty(model.getUsername())) - mAccountService.registerName(a, model.getPassword().toString(), model.getUsername()); + mAccountService.registerName(a, model.getPassword(), model.getUsername()); mAccountService.setCurrentAccount(a); if (model.isPush()) { Settings settings = mPreferences.getSettings(); diff --git a/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationPresenter.java b/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationPresenter.java deleted file mode 100644 index f09359c8d..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationPresenter.java +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.account; - -import net.jami.services.AccountService; - -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; -import javax.inject.Named; - -import net.jami.mvp.AccountCreationModel; -import net.jami.mvp.RootPresenter; -import net.jami.utils.StringUtils; - -import io.reactivex.rxjava3.core.Scheduler; -import io.reactivex.rxjava3.subjects.PublishSubject; - -public class JamiAccountCreationPresenter extends RootPresenter<net.jami.account.JamiAccountCreationView> { - - public static final String TAG = JamiAccountCreationPresenter.class.getSimpleName(); - private static final int PASSWORD_MIN_LENGTH = 6; - private static final long TYPING_DELAY = 350L; - private final PublishSubject<String> contactQuery = PublishSubject.create(); - protected net.jami.services.AccountService mAccountService; - @Inject - @Named("UiScheduler") - protected Scheduler mUiScheduler; - private AccountCreationModel mAccountCreationModel; - private boolean isUsernameCorrect = false; - private boolean isPasswordCorrect = true; - private boolean isConfirmCorrect = true; - private boolean showLoadingAnimation = true; - private CharSequence mPasswordConfirm = ""; - - @Inject - public JamiAccountCreationPresenter(AccountService accountService) { - this.mAccountService = accountService; - } - - @Override - public void bindView(net.jami.account.JamiAccountCreationView view) { - super.bindView(view); - mCompositeDisposable.add(contactQuery - .debounce(TYPING_DELAY, TimeUnit.MILLISECONDS) - .switchMapSingle(q -> mAccountService. - findRegistrationByName("", "", q)) - .observeOn(mUiScheduler) - .subscribe(q -> onLookupResult(q.name, q.address, q.state))); - } - - public void init(AccountCreationModel accountCreationModel) { - if (accountCreationModel == null) { - getView().cancel(); - } - mAccountCreationModel = accountCreationModel; - } - - /** - * Called everytime the provided username for the new account changes - * Sends the new value of the username to the ContactQuery subjet and shows the loading - * animation if it has not been started before - */ - public void userNameChanged(String userName) { - if (mAccountCreationModel != null) - mAccountCreationModel.setUsername(userName); - contactQuery.onNext(userName); - isUsernameCorrect = false; - - if (showLoadingAnimation) { - net.jami.account.JamiAccountCreationView view = getView(); - if (view != null) - view.updateUsernameAvailability(net.jami.account.JamiAccountCreationView.UsernameAvailabilityStatus.LOADING); - showLoadingAnimation = false; - } - } - - public void registerUsernameChanged(boolean isChecked) { - if (mAccountCreationModel != null) { - if (!isChecked) { - mAccountCreationModel.setUsername(""); - } - checkForms(); - } - } - - public void passwordUnset() { - if (mAccountCreationModel != null) - mAccountCreationModel.setPassword(null); - isPasswordCorrect = true; - isConfirmCorrect = true; - getView().showInvalidPasswordError(false); - getView().enableNextButton(true); - } - - public void passwordChanged(String password, CharSequence repeat) { - mPasswordConfirm = repeat; - passwordChanged(password); - } - - public void passwordChanged(String password) { - if (mAccountCreationModel != null) - mAccountCreationModel.setPassword(password); - if (!StringUtils.isEmpty(password) && password.length() < PASSWORD_MIN_LENGTH) { - getView().showInvalidPasswordError(true); - isPasswordCorrect = false; - } else { - getView().showInvalidPasswordError(false); - isPasswordCorrect = password.length() != 0; - if (!password.contentEquals(mPasswordConfirm)) { - if (mPasswordConfirm.length() > 0) - getView().showNonMatchingPasswordError(true); - isConfirmCorrect = false; - } else { - getView().showNonMatchingPasswordError(false); - isConfirmCorrect = true; - } - } - getView().enableNextButton(isPasswordCorrect && isConfirmCorrect); - } - - public void passwordConfirmChanged(String passwordConfirm) { - if (!passwordConfirm.equals(mAccountCreationModel.getPassword())) { - getView().showNonMatchingPasswordError(true); - isConfirmCorrect = false; - } else { - getView().showNonMatchingPasswordError(false); - isConfirmCorrect = true; - } - mPasswordConfirm = passwordConfirm; - getView().enableNextButton(isPasswordCorrect && isConfirmCorrect); - } - - public void createAccount() { - if (isInputValid()) { - net.jami.account.JamiAccountCreationView view = getView(); - view.goToAccountCreation(mAccountCreationModel); - } - } - - private boolean isInputValid() { - boolean passwordOk = isPasswordCorrect && isConfirmCorrect; - boolean usernameOk = mAccountCreationModel != null && mAccountCreationModel.getUsername() != null || isUsernameCorrect; - return passwordOk && usernameOk; - } - - private void checkForms() { - boolean valid = isInputValid(); - if(valid && isUsernameCorrect) - getView().updateUsernameAvailability(net.jami.account.JamiAccountCreationView. - UsernameAvailabilityStatus.AVAILABLE); - } - - private void onLookupResult(String name, String address, int state) { - net.jami.account.JamiAccountCreationView view = getView(); - //Once we get the result, we can show the loading animation again when the user types - showLoadingAnimation = true; - if (view == null) { - return; - } - if (name == null || name.isEmpty()) { - - view.updateUsernameAvailability(net.jami.account.JamiAccountCreationView. - UsernameAvailabilityStatus.RESET); - isUsernameCorrect = false; - } else { - switch (state) { - case 0: - // on found - view.updateUsernameAvailability(net.jami.account.JamiAccountCreationView. - UsernameAvailabilityStatus.ERROR_USERNAME_TAKEN); - isUsernameCorrect = false; - break; - case 1: - // invalid name - view.updateUsernameAvailability(net.jami.account.JamiAccountCreationView. - UsernameAvailabilityStatus.ERROR_USERNAME_INVALID); - isUsernameCorrect = false; - break; - case 2: - // available - view.updateUsernameAvailability(net.jami.account.JamiAccountCreationView. - UsernameAvailabilityStatus.AVAILABLE); - mAccountCreationModel.setUsername(name); - isUsernameCorrect = true; - break; - default: - // on error - view.updateUsernameAvailability(JamiAccountCreationView. - UsernameAvailabilityStatus.ERROR); - isUsernameCorrect = false; - break; - } - } - checkForms(); - } - - public void setPush(boolean push) { - if (mAccountCreationModel != null) { - mAccountCreationModel.setPush(push); - } - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationPresenter.kt b/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationPresenter.kt new file mode 100644 index 000000000..315cfaa90 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationPresenter.kt @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.account + +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.subjects.PublishSubject +import net.jami.account.JamiAccountCreationPresenter +import net.jami.mvp.AccountCreationModel +import net.jami.mvp.RootPresenter +import net.jami.services.AccountService +import net.jami.services.AccountService.RegisteredName +import net.jami.utils.StringUtils.isEmpty +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Named + +class JamiAccountCreationPresenter @Inject constructor( + var mAccountService: AccountService, + @param:Named("UiScheduler") var mUiScheduler: Scheduler +): RootPresenter<JamiAccountCreationView>() { + private val contactQuery = PublishSubject.create<String>() + + private var mAccountCreationModel: AccountCreationModel? = null + private var isUsernameCorrect = false + private var isPasswordCorrect = true + private var isConfirmCorrect = true + private var showLoadingAnimation = true + private var mPasswordConfirm: CharSequence = "" + + override fun bindView(view: JamiAccountCreationView) { + super.bindView(view) + mCompositeDisposable.add(contactQuery + .debounce(TYPING_DELAY, TimeUnit.MILLISECONDS) + .switchMapSingle { q: String? -> mAccountService.findRegistrationByName("", "", q!!) } + .observeOn(mUiScheduler) + .subscribe { q: RegisteredName -> onLookupResult(q.name, q.address, q.state) }) + } + + fun init(accountCreationModel: AccountCreationModel?) { + if (accountCreationModel == null) { + view?.cancel() + } + mAccountCreationModel = accountCreationModel + } + + /** + * Called everytime the provided username for the new account changes + * Sends the new value of the username to the ContactQuery subjet and shows the loading + * animation if it has not been started before + */ + fun userNameChanged(userName: String) { + if (mAccountCreationModel != null) mAccountCreationModel!!.username = userName + contactQuery.onNext(userName) + isUsernameCorrect = false + if (showLoadingAnimation) { + val view = view + view?.updateUsernameAvailability(JamiAccountCreationView.UsernameAvailabilityStatus.LOADING) + showLoadingAnimation = false + } + } + + fun registerUsernameChanged(isChecked: Boolean) { + if (mAccountCreationModel != null) { + if (!isChecked) { + mAccountCreationModel!!.username = "" + } + checkForms() + } + } + + fun passwordUnset() { + if (mAccountCreationModel != null) mAccountCreationModel!!.password = null + isPasswordCorrect = true + isConfirmCorrect = true + view!!.showInvalidPasswordError(false) + view!!.enableNextButton(true) + } + + fun passwordChanged(password: String, repeat: CharSequence) { + mPasswordConfirm = repeat + passwordChanged(password) + } + + fun passwordChanged(password: String) { + if (mAccountCreationModel != null) mAccountCreationModel!!.password = password + if (!isEmpty(password) && password.length < PASSWORD_MIN_LENGTH) { + view!!.showInvalidPasswordError(true) + isPasswordCorrect = false + } else { + view!!.showInvalidPasswordError(false) + isPasswordCorrect = password.length != 0 + isConfirmCorrect = if (!password.contentEquals(mPasswordConfirm)) { + if (mPasswordConfirm.length > 0) view!!.showNonMatchingPasswordError(true) + false + } else { + view!!.showNonMatchingPasswordError(false) + true + } + } + view!!.enableNextButton(isPasswordCorrect && isConfirmCorrect) + } + + fun passwordConfirmChanged(passwordConfirm: String) { + isConfirmCorrect = if (passwordConfirm != mAccountCreationModel!!.password) { + view!!.showNonMatchingPasswordError(true) + false + } else { + view!!.showNonMatchingPasswordError(false) + true + } + mPasswordConfirm = passwordConfirm + view!!.enableNextButton(isPasswordCorrect && isConfirmCorrect) + } + + fun createAccount() { + if (isInputValid) { + val view = view + view!!.goToAccountCreation(mAccountCreationModel) + } + } + + private val isInputValid: Boolean + private get() { + val passwordOk = isPasswordCorrect && isConfirmCorrect + val usernameOk = + mAccountCreationModel != null && mAccountCreationModel!!.username != null || isUsernameCorrect + return passwordOk && usernameOk + } + + private fun checkForms() { + val valid = isInputValid + if (valid && isUsernameCorrect) view!!.updateUsernameAvailability(JamiAccountCreationView.UsernameAvailabilityStatus.AVAILABLE) + } + + private fun onLookupResult(name: String?, address: String?, state: Int) { + val view = view + //Once we get the result, we can show the loading animation again when the user types + showLoadingAnimation = true + if (view == null) { + return + } + if (name == null || name.isEmpty()) { + view.updateUsernameAvailability(JamiAccountCreationView.UsernameAvailabilityStatus.RESET) + isUsernameCorrect = false + } else { + when (state) { + 0 -> { + // on found + view.updateUsernameAvailability(JamiAccountCreationView.UsernameAvailabilityStatus.ERROR_USERNAME_TAKEN) + isUsernameCorrect = false + } + 1 -> { + // invalid name + view.updateUsernameAvailability(JamiAccountCreationView.UsernameAvailabilityStatus.ERROR_USERNAME_INVALID) + isUsernameCorrect = false + } + 2 -> { + // available + view.updateUsernameAvailability(JamiAccountCreationView.UsernameAvailabilityStatus.AVAILABLE) + mAccountCreationModel!!.username = name + isUsernameCorrect = true + } + else -> { + // on error + view.updateUsernameAvailability(JamiAccountCreationView.UsernameAvailabilityStatus.ERROR) + isUsernameCorrect = false + } + } + } + checkForms() + } + + fun setPush(push: Boolean) { + mAccountCreationModel!!.isPush = push + } + + companion object { + val TAG = JamiAccountCreationPresenter::class.java.simpleName + private const val PASSWORD_MIN_LENGTH = 6 + private const val TYPING_DELAY = 350L + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationView.java b/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationView.kt similarity index 58% rename from ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationView.java rename to ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationView.kt index 1f7556cd9..1bb996865 100644 --- a/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationView.java +++ b/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountCreationView.kt @@ -17,30 +17,19 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ -package net.jami.account; +package net.jami.account -import net.jami.mvp.AccountCreationModel; +import net.jami.mvp.AccountCreationModel -public interface JamiAccountCreationView { - - enum UsernameAvailabilityStatus { - ERROR_USERNAME_TAKEN, - ERROR_USERNAME_INVALID, - ERROR, - LOADING, - AVAILABLE, - RESET +interface JamiAccountCreationView { + enum class UsernameAvailabilityStatus { + ERROR_USERNAME_TAKEN, ERROR_USERNAME_INVALID, ERROR, LOADING, AVAILABLE, RESET } - void updateUsernameAvailability(UsernameAvailabilityStatus status); - - void showInvalidPasswordError(boolean display); - - void showNonMatchingPasswordError(boolean display); - - void enableNextButton(boolean enabled); - - void goToAccountCreation(AccountCreationModel accountCreationModel); - - void cancel(); -} + fun updateUsernameAvailability(status: UsernameAvailabilityStatus?) + fun showInvalidPasswordError(display: Boolean) + fun showNonMatchingPasswordError(display: Boolean) + fun enableNextButton(enabled: Boolean) + fun goToAccountCreation(accountCreationModel: AccountCreationModel?) + fun cancel() +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountSummaryPresenter.java b/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountSummaryPresenter.java index 88dd843b0..7d76a0fde 100644 --- a/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountSummaryPresenter.java +++ b/ring-android/libringclient/src/main/java/net/jami/account/JamiAccountSummaryPresenter.java @@ -31,6 +31,7 @@ import javax.inject.Inject; import javax.inject.Named; import net.jami.mvp.RootPresenter; +import net.jami.services.VCardService; import net.jami.utils.Log; import net.jami.utils.StringUtils; import net.jami.utils.VCardUtils; @@ -42,12 +43,14 @@ import io.reactivex.rxjava3.core.Scheduler; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; -public class JamiAccountSummaryPresenter extends RootPresenter<net.jami.account.JamiAccountSummaryView> { +public class JamiAccountSummaryPresenter extends RootPresenter<JamiAccountSummaryView> { private static final String TAG = JamiAccountSummaryPresenter.class.getSimpleName(); - private final net.jami.services.DeviceRuntimeService mDeviceRuntimeService; - private final net.jami.services.AccountService mAccountService; + private final DeviceRuntimeService mDeviceRuntimeService; + private final AccountService mAccountService; + private final VCardService mVcardService; + private String mAccountID; @Inject @@ -56,9 +59,11 @@ public class JamiAccountSummaryPresenter extends RootPresenter<net.jami.account. @Inject public JamiAccountSummaryPresenter(AccountService accountService, - DeviceRuntimeService deviceRuntimeService) { + DeviceRuntimeService deviceRuntimeService, + VCardService vcardService) { mAccountService = accountService; mDeviceRuntimeService = deviceRuntimeService; + mVcardService = vcardService; } public void registerName(String name, String password) { @@ -93,14 +98,14 @@ public class JamiAccountSummaryPresenter extends RootPresenter<net.jami.account. public void setAccountId(String accountID) { mCompositeDisposable.clear(); mAccountID = accountID; - net.jami.account.JamiAccountSummaryView v = getView(); + JamiAccountSummaryView v = getView(); Account account = mAccountService.getAccount(mAccountID); if (v != null && account != null) v.accountChanged(account); mCompositeDisposable.add(mAccountService.getObservableAccountUpdates(mAccountID) .observeOn(mUiScheduler) .subscribe(a -> { - net.jami.account.JamiAccountSummaryView view = getView(); + JamiAccountSummaryView view = getView(); if (view != null) view.accountChanged(a); })); @@ -118,7 +123,7 @@ public class JamiAccountSummaryPresenter extends RootPresenter<net.jami.account. } public void changePassword(String oldPassword, String newPassword) { - net.jami.account.JamiAccountSummaryView view = getView(); + JamiAccountSummaryView view = getView(); if (view != null) view.showPasswordProgressDialog(); mCompositeDisposable.add(mAccountService.setAccountPassword(mAccountID, oldPassword, newPassword) @@ -158,9 +163,7 @@ public class JamiAccountSummaryPresenter extends RootPresenter<net.jami.account. .flatMap(vcard -> VCardUtils.saveLocalProfileToDisk(vcard, mAccountID, filesDir)) .subscribeOn(Schedulers.io()) .subscribe(vcard -> { - account.resetProfile(); - mAccountService.refreshAccounts(); - getView().updateUserView(account); + account.setLoadedProfile(mVcardService.loadVCardProfile(vcard).cache()); }, e -> Log.e(TAG, "Error saving vCard !", e))); } @@ -183,9 +186,7 @@ public class JamiAccountSummaryPresenter extends RootPresenter<net.jami.account. .flatMap(vcard -> VCardUtils.saveLocalProfileToDisk(vcard, mAccountID, filesDir)) .subscribeOn(Schedulers.io()) .subscribe(vcard -> { - account.resetProfile(); - mAccountService.refreshAccounts(); - getView().updateUserView(account); + account.setLoadedProfile(mVcardService.loadVCardProfile(vcard).cache()); }, e -> Log.e(TAG, "Error saving vCard !", e))); } diff --git a/ring-android/libringclient/src/main/java/net/jami/account/ProfileCreationPresenter.java b/ring-android/libringclient/src/main/java/net/jami/account/ProfileCreationPresenter.java index ab2fb1e7d..ef03a12ce 100644 --- a/ring-android/libringclient/src/main/java/net/jami/account/ProfileCreationPresenter.java +++ b/ring-android/libringclient/src/main/java/net/jami/account/ProfileCreationPresenter.java @@ -20,12 +20,13 @@ package net.jami.account; import net.jami.mvp.AccountCreationModel; -import net.jami.services.DeviceRuntimeService; -import net.jami.services.HardwareService; import javax.inject.Inject; +import javax.inject.Named; import net.jami.mvp.RootPresenter; +import net.jami.services.DeviceRuntimeService; +import net.jami.services.HardwareService; import net.jami.utils.Log; import io.reactivex.rxjava3.core.Scheduler; @@ -35,14 +36,16 @@ public class ProfileCreationPresenter extends RootPresenter<net.jami.account.Pro public static final String TAG = ProfileCreationPresenter.class.getSimpleName(); - private final net.jami.services.DeviceRuntimeService mDeviceRuntimeService; - private final net.jami.services.HardwareService mHardwareService; + private final DeviceRuntimeService mDeviceRuntimeService; + private final HardwareService mHardwareService; private final Scheduler mUiScheduler; private net.jami.mvp.AccountCreationModel mAccountCreationModel; @Inject - public ProfileCreationPresenter(DeviceRuntimeService deviceRuntimeService, HardwareService hardwareService, Scheduler uiScheduler) { + public ProfileCreationPresenter(DeviceRuntimeService deviceRuntimeService, + HardwareService hardwareService, + @Named("UiScheduler") Scheduler uiScheduler) { mDeviceRuntimeService = deviceRuntimeService; mHardwareService = hardwareService; mUiScheduler = uiScheduler; diff --git a/ring-android/libringclient/src/main/java/net/jami/call/CallPresenter.java b/ring-android/libringclient/src/main/java/net/jami/call/CallPresenter.java deleted file mode 100644 index 42490c998..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/call/CallPresenter.java +++ /dev/null @@ -1,765 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.call; - -import net.jami.daemon.JamiService; -import net.jami.facades.ConversationFacade; -import net.jami.model.Call; -import net.jami.model.Conference; -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.model.ConversationHistory; -import net.jami.model.Uri; -import net.jami.mvp.RootPresenter; -import net.jami.services.AccountService; -import net.jami.services.CallService; -import net.jami.services.ContactService; -import net.jami.services.DeviceRuntimeService; -import net.jami.services.HardwareService; -import net.jami.utils.Log; -import net.jami.utils.StringUtils; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; -import javax.inject.Named; - -import io.reactivex.rxjava3.annotations.NonNull; -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Observer; -import io.reactivex.rxjava3.core.Scheduler; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public class CallPresenter extends RootPresenter<CallView> { - - public final static String TAG = CallPresenter.class.getSimpleName(); - - private final AccountService mAccountService; - private final ContactService mContactService; - private final HardwareService mHardwareService; - private final CallService mCallService; - private final DeviceRuntimeService mDeviceRuntimeService; - private final ConversationFacade mConversationFacade; - - private Conference mConference; - private final List<Call> mPendingCalls = new ArrayList<>(); - private final Subject<List<Call>> mPendingSubject = BehaviorSubject.createDefault(mPendingCalls); - - private boolean mOnGoingCall = false; - private boolean mAudioOnly = true; - private boolean permissionChanged = false; - private boolean pipIsActive = false; - private boolean incomingIsFullIntent = true; - private boolean callInitialized = false; - - private int videoWidth = -1; - private int videoHeight = -1; - private int previewWidth = -1; - private int previewHeight = -1; - private String currentSurfaceId = null; - private String currentPluginSurfaceId = null; - - private Disposable timeUpdateTask = null; - - @Inject - @Named("UiScheduler") - protected Scheduler mUiScheduler; - - @Inject - public CallPresenter(AccountService accountService, - ContactService contactService, - HardwareService hardwareService, - CallService callService, - DeviceRuntimeService deviceRuntimeService, - ConversationFacade conversationFacade) { - mAccountService = accountService; - mContactService = contactService; - mHardwareService = hardwareService; - mCallService = callService; - mDeviceRuntimeService = deviceRuntimeService; - mConversationFacade = conversationFacade; - } - - public void cameraPermissionChanged(boolean isGranted) { - if (isGranted && mHardwareService.isVideoAvailable()) { - mHardwareService.initVideo() - .onErrorComplete() - .blockingAwait(); - permissionChanged = true; - } - } - - public void audioPermissionChanged(boolean isGranted) { - if (isGranted && mHardwareService.hasMicrophone()) { - mCallService.restartAudioLayer(); - } - } - - - @Override - public void unbindView() { - if (!mAudioOnly) { - mHardwareService.endCapture(); - } - super.unbindView(); - } - - @Override - public void bindView(CallView view) { - super.bindView(view); - /*mCompositeDisposable.add(mAccountService.getRegisteredNames() - .observeOn(mUiScheduler) - .subscribe(r -> { - if (mSipCall != null && mSipCall.getContact() != null) { - getView().updateContactBubble(mSipCall.getContact()); - } - }));*/ - mCompositeDisposable.add(mHardwareService.getVideoEvents() - .observeOn(mUiScheduler) - .subscribe(this::onVideoEvent)); - mCompositeDisposable.add(mHardwareService.getAudioState() - .observeOn(mUiScheduler) - .subscribe(state -> getView().updateAudioState(state))); - - /*mCompositeDisposable.add(mHardwareService - .getBluetoothEvents() - .subscribe(event -> { - if (!event.connected && mSipCall == null) { - hangupCall(); - } - }));*/ - } - - public void initOutGoing(String accountId, Uri conversationUri, String contactUri, boolean audioOnly) { - if (accountId == null || contactUri == null) { - Log.e(TAG, "initOutGoing: null account or contact"); - hangupCall(); - return; - } - if (!mHardwareService.hasCamera()) { - audioOnly = true; - } - //getView().blockScreenRotation(); - - Observable<Conference> callObservable = mCallService - .placeCall(accountId, conversationUri, Uri.fromString(StringUtils.toNumber(contactUri)), audioOnly) - //.map(mCallService::getConference) - .flatMapObservable(mCallService::getConfUpdates) - .share(); - - mCompositeDisposable.add(callObservable - .observeOn(mUiScheduler) - .subscribe(conference -> { - contactUpdate(conference); - confUpdate(conference); - }, e -> { - hangupCall(); - Log.e(TAG, "Error with initOutgoing: " + e.getMessage()); - })); - - showConference(callObservable); - } - - /** - * Returns to or starts an incoming call - * - * @param confId the call id - * @param actionViewOnly true if only returning to call or if using full screen intent - */ - public void initIncomingCall(String confId, boolean actionViewOnly) { - //getView().blockScreenRotation(); - - // if the call is incoming through a full intent, this allows the incoming call to display - incomingIsFullIntent = actionViewOnly; - - Observable<Conference> callObservable = mCallService.getConfUpdates(confId) - .observeOn(mUiScheduler) - .share(); - - // Handles the case where the call has been accepted, emits a single so as to only check for permissions and start the call once - mCompositeDisposable.add(callObservable - .firstOrError() - .subscribe(call -> { - if (!actionViewOnly) { - contactUpdate(call); - confUpdate(call); - callInitialized = true; - getView().prepareCall(true); - } - }, e -> { - hangupCall(); - Log.e(TAG, "Error with initIncoming, preparing call flow :" , e); - })); - - // Handles retrieving call updates. Items emitted are only used if call is already in process or if user is returning to a call. - mCompositeDisposable.add(callObservable - .subscribe(call -> { - if (callInitialized || actionViewOnly) { - contactUpdate(call); - confUpdate(call); - } - }, e -> { - hangupCall(); - Log.e(TAG, "Error with initIncoming, action view flow: ", e); - })); - - showConference(callObservable); - } - - private void showConference(Observable<Conference> conference) { - conference = conference - .distinctUntilChanged(); - mCompositeDisposable.add(conference - .switchMap(Conference::getParticipantInfo) - .observeOn(mUiScheduler) - .subscribe(info -> getView().updateConfInfo(info), - e -> Log.e(TAG, "Error with initIncoming, action view flow: ", e))); - - mCompositeDisposable.add(conference - .switchMap(Conference::getParticipantRecording) - .observeOn(mUiScheduler) - .subscribe(contacts -> getView().updateParticipantRecording(contacts), - e -> Log.e(TAG, "Error with initIncoming, action view flow: ", e))); - } - - public void prepareOptionMenu() { - boolean isSpeakerOn = mHardwareService.isSpeakerPhoneOn(); - //boolean hasContact = mSipCall != null && null != mSipCall.getContact() && mSipCall.getContact().isUnknown(); - boolean canDial = mOnGoingCall && mConference != null; - // get the preferences - boolean displayPluginsButton = getView().displayPluginsButton(); - boolean showPluginBtn = displayPluginsButton && mOnGoingCall && mConference != null; - boolean hasMultipleCamera = mHardwareService.getCameraCount() > 1 && mOnGoingCall && !mAudioOnly; - getView().initMenu(isSpeakerOn, hasMultipleCamera, canDial, showPluginBtn, mOnGoingCall); - } - - public void chatClick() { - if (mConference == null || mConference.getParticipants().isEmpty()) { - return; - } - Call firstCall = mConference.getParticipants().get(0); - if (firstCall == null) { - return; - } - ConversationHistory c = firstCall.getConversation(); - if (c instanceof Conversation) { - Conversation conversation = ((Conversation) c); - getView().goToConversation(conversation.getAccountId(), conversation.getUri()); - } else if (firstCall.getContact() != null) { - getView().goToConversation(firstCall.getAccount(), firstCall.getContact().getConversationUri().blockingFirst()); - } - } - - public void speakerClick(boolean checked) { - mHardwareService.toggleSpeakerphone(checked); - } - - public void muteMicrophoneToggled(boolean checked) { - mCallService.setLocalMediaMuted(mConference.getId(), CallService.MEDIA_TYPE_AUDIO, checked); - } - - - public boolean isMicrophoneMuted() { - return mCallService.isCaptureMuted(); - } - - public void switchVideoInputClick() { - if(mConference == null) - return; - mHardwareService.switchInput(mConference.getId(), false); - getView().switchCameraIcon(mHardwareService.isPreviewFromFrontCamera()); - } - - public void configurationChanged(int rotation) { - mHardwareService.setDeviceOrientation(rotation); - } - - public void dialpadClick() { - getView().displayDialPadKeyboard(); - } - - public void acceptCall() { - if (mConference == null) { - return; - } - mCallService.accept(mConference.getId()); - } - - public void hangupCall() { - if (mConference != null) { - if (mConference.isConference()) - mCallService.hangUpConference(mConference.getId()); - else - mCallService.hangUp(mConference.getId()); - } - for (Call call : mPendingCalls) { - mCallService.hangUp(call.getDaemonIdString()); - } - finish(); - } - - public void refuseCall() { - final Conference call = mConference; - if (call != null) { - mCallService.refuse(call.getId()); - } - finish(); - } - - public void videoSurfaceCreated(Object holder) { - if (mConference == null) { - return; - } - String newId = mConference.getId(); - if (!newId.equals(currentSurfaceId)) { - mHardwareService.removeVideoSurface(currentSurfaceId); - currentSurfaceId = newId; - } - mHardwareService.addVideoSurface(mConference.getId(), holder); - getView().displayContactBubble(false); - } - - public void videoSurfaceUpdateId(String newId) { - if (!Objects.equals(newId, currentSurfaceId)) { - mHardwareService.updateVideoSurfaceId(currentSurfaceId, newId); - currentSurfaceId = newId; - } - } - - public void pluginSurfaceCreated(Object holder) { - if (mConference == null) { - return; - } - String newId = mConference.getPluginId(); - if (!newId.equals(currentPluginSurfaceId)) { - mHardwareService.removeVideoSurface(currentPluginSurfaceId); - currentPluginSurfaceId = newId; - } - mHardwareService.addVideoSurface(mConference.getPluginId(), holder); - getView().displayContactBubble(false); - } - - public void pluginSurfaceUpdateId(String newId) { - if (!Objects.equals(newId, currentPluginSurfaceId)) { - mHardwareService.updateVideoSurfaceId(currentPluginSurfaceId, newId); - currentPluginSurfaceId = newId; - } - } - - public void previewVideoSurfaceCreated(Object holder) { - mHardwareService.addPreviewVideoSurface(holder, mConference); - //mHardwareService.startCapture(null); - } - - public void videoSurfaceDestroyed() { - if (currentSurfaceId != null) { - mHardwareService.removeVideoSurface(currentSurfaceId); - currentSurfaceId = null; - } - } - public void pluginSurfaceDestroyed() { - if (currentPluginSurfaceId != null) { - mHardwareService.removeVideoSurface(currentPluginSurfaceId); - currentPluginSurfaceId = null; - } - } - public void previewVideoSurfaceDestroyed() { - mHardwareService.removePreviewVideoSurface(); - mHardwareService.endCapture(); - } - - public void displayChanged() { - mHardwareService.switchInput(mConference.getId(), false); - } - - public void layoutChanged() { - //getView().resetVideoSize(videoWidth, videoHeight, previewWidth, previewHeight); - } - - - public void uiVisibilityChanged(boolean displayed) { - Log.w(TAG, "uiVisibilityChanged " + mOnGoingCall + " " + displayed); - CallView view = getView(); - if (view != null) - view.displayHangupButton(mOnGoingCall && displayed); - } - - private void finish() { - if (timeUpdateTask != null && !timeUpdateTask.isDisposed()) { - timeUpdateTask.dispose(); - timeUpdateTask = null; - } - mConference = null; - CallView view = getView(); - if (view != null) - view.finish(); - } - - private Disposable contactDisposable = null; - - private void contactUpdate(final Conference conference) { - if (mConference != conference) { - mConference = conference; - if (contactDisposable != null && !contactDisposable.isDisposed()) { - contactDisposable.dispose(); - } - if (conference.getParticipants().isEmpty()) - return; - - // Updates of participant (and pending participant) list - Observable<List<Call>> callsObservable = mPendingSubject - .map(pendingList -> { - Log.w(TAG, "mPendingSubject onNext " + pendingList.size() + " " + conference.getParticipants().size()); - if (pendingList.isEmpty()) - return conference.getParticipants(); - List<Call> newList = new ArrayList<>(conference.getParticipants().size() + pendingList.size()); - newList.addAll(conference.getParticipants()); - newList.addAll(pendingList); - return newList; - }); - - // Updates of individual contacts - Observable<List<Observable<Call>>> contactsObservable = callsObservable - .flatMapSingle(calls -> Observable - .fromIterable(calls) - .map(call -> mContactService.observeContact(call.getAccount(), call.getContact(), false) - .map(contact -> call)) - .toList(calls.size())); - - // Combined updates of contacts as participant list updates - Observable<List<Call>> contactUpdates = contactsObservable - .switchMap(list -> Observable - .combineLatest(list, objects -> { - Log.w(TAG, "flatMapObservable " + objects.length); - ArrayList<Call> calls = new ArrayList<>(objects.length); - for (Object call : objects) - calls.add((Call)call); - return (List<Call>)calls; - })) - .filter(list -> !list.isEmpty()); - - contactDisposable = contactUpdates - .observeOn(mUiScheduler) - .subscribe(cs -> getView().updateContactBubble(cs), e -> Log.e(TAG, "Error updating contact data", e)); - mCompositeDisposable.add(contactDisposable); - } - mPendingSubject.onNext(mPendingCalls); - } - - private void confUpdate(Conference call) { - Log.w(TAG, "confUpdate " + call.getId() + " " + call.getState()); - - Call.CallStatus status = call.getState(); - if (status == Call.CallStatus.HOLD) { - if (call.isSimpleCall()) - mCallService.unhold(call.getId()); - else - JamiService.addMainParticipant(call.getConfId()); - } - mAudioOnly = !call.hasVideo(); - CallView view = getView(); - if (view == null) - return; - view.updateMenu(); - if (call.isOnGoing()) { - mOnGoingCall = true; - view.initNormalStateDisplay(mAudioOnly, isMicrophoneMuted()); - view.updateMenu(); - if (!mAudioOnly) { - mHardwareService.setPreviewSettings(); - mHardwareService.updatePreviewVideoSurface(call); - videoSurfaceUpdateId(call.getId()); - pluginSurfaceUpdateId(call.getPluginId()); - view.displayVideoSurface(true, mDeviceRuntimeService.hasVideoPermission()); - if (permissionChanged) { - mHardwareService.switchInput(mConference.getId(), permissionChanged); - permissionChanged = false; - } - } - if (timeUpdateTask != null) - timeUpdateTask.dispose(); - timeUpdateTask = mUiScheduler.schedulePeriodicallyDirect(this::updateTime, 0, 1, TimeUnit.SECONDS); - } else if (call.isRinging()) { - Call scall = call.getCall(); - - view.handleCallWakelock(mAudioOnly); - if (scall.isIncoming()) { - if (mAccountService.getAccount(scall.getAccount()).isAutoanswerEnabled()) { - mCallService.accept(scall.getDaemonIdString()); - // only display the incoming call screen if the notification is a full screen intent - } else if (incomingIsFullIntent) { - view.initIncomingCallDisplay(); - } - } else { - mOnGoingCall = false; - view.updateCallStatus(scall.getCallStatus()); - view.initOutGoingCallDisplay(); - } - } else { - finish(); - } - } - - public void maximizeParticipant(Conference.ParticipantInfo info) { - Contact contact = info == null ? null : info.contact; - if (mConference.getMaximizedParticipant() == contact) - info = null; - mConference.setMaximizedParticipant(contact); - if (info != null) { - mCallService.setConfMaximizedParticipant(mConference.getConfId(), info.contact.getUri()); - } else { - mCallService.setConfGridLayout(mConference.getConfId()); - } - } - - private void updateTime() { - CallView view = getView(); - if (view != null && mConference != null) { - if (mConference.isOnGoing()) { - long start = mConference.getTimestampStart(); - if (start != Long.MAX_VALUE) { - view.updateTime((System.currentTimeMillis() - start) / 1000); - } else { - view.updateTime(-1); - } - } - } - } - - private void onVideoEvent(HardwareService.VideoEvent event) { - Log.d(TAG, "VIDEO_EVENT: " + event.start + " " + event.callId + " " + event.w + "x" + event.h); - - if (event.start) { - getView().displayVideoSurface(true, !isPipMode() && mDeviceRuntimeService.hasVideoPermission()); - } else if (mConference != null && mConference.getId().equals(event.callId)) { - getView().displayVideoSurface(event.started, event.started && !isPipMode() && mDeviceRuntimeService.hasVideoPermission()); - if (event.started) { - videoWidth = event.w; - videoHeight = event.h; - getView().resetVideoSize(videoWidth, videoHeight); - } - } else if (event.callId == null) { - if (event.started) { - previewWidth = event.w; - previewHeight = event.h; - getView().resetPreviewVideoSize(previewWidth, previewHeight, event.rot); - } - } - if (mConference != null && mConference.getPluginId().equals(event.callId)) { - if (event.started) { - previewWidth = event.w; - previewHeight = event.h; - getView().resetPluginPreviewVideoSize(previewWidth, previewHeight, event.rot); - } - } - /*if (event.started || event.start) { - getView().resetVideoSize(videoWidth, videoHeight, previewWidth, previewHeight); - }*/ - } - - public void positiveButtonClicked() { - if (mConference.isRinging() && mConference.isIncoming()) { - acceptCall(); - } else { - hangupCall(); - } - } - - public void negativeButtonClicked() { - if (mConference.isRinging() && mConference.isIncoming()) { - refuseCall(); - } else { - hangupCall(); - } - } - - public void toggleButtonClicked() { - if (mConference != null && !(mConference.isRinging() && mConference.isIncoming())) { - hangupCall(); - } - } - - public boolean isAudioOnly() { - return mAudioOnly; - } - - public void requestPipMode() { - if (mConference != null && mConference.isOnGoing() && mConference.hasVideo()) { - getView().enterPipMode(mConference.getId()); - } - } - - public boolean isPipMode() { - return pipIsActive; - } - - public void pipModeChanged(boolean pip) { - pipIsActive = pip; - if (pip) { - getView().displayHangupButton(false); - getView().displayPreviewSurface(false); - getView().displayVideoSurface(true, false); - } else { - getView().displayPreviewSurface(true); - getView().displayVideoSurface(true, mDeviceRuntimeService.hasVideoPermission()); - } - } - - public void toggleCallMediaHandler(String id, boolean toggle) - { - if (mConference != null && mConference.isOnGoing() && mConference.hasVideo()) { - getView().toggleCallMediaHandler(id, mConference.getId(), toggle); - } - } - - public boolean isSpeakerphoneOn() { - return mHardwareService.isSpeakerPhoneOn(); - } - - public void sendDtmf(CharSequence s) { - mCallService.playDtmf(s.toString()); - } - - public void addConferenceParticipant(String accountId, Uri contactUri) { - mCompositeDisposable.add(mConversationFacade.startConversation(accountId, contactUri) - .map(Conversation::getCurrentCalls) - .subscribe(confs -> { - if (confs.isEmpty()) { - final Observer<Call> pendingObserver = new Observer<Call>() { - private Call call = null; - - @Override - public void onSubscribe(@NonNull Disposable d) {} - - @Override - public void onNext(@NonNull Call sipCall) { - if (call == null) { - call = sipCall; - mPendingCalls.add(sipCall); - } - mPendingSubject.onNext(mPendingCalls); - } - - @Override - public void onError(Throwable e) {} - - @Override - public void onComplete() { - if (call != null) { - mPendingCalls.remove(call); - mPendingSubject.onNext(mPendingCalls); - call = null; - } - } - }; - - // Place new call, join to conference when answered - Maybe<Call> newCall = mCallService.placeCallObservable(accountId, null, contactUri, mAudioOnly) - .doOnEach(pendingObserver) - .filter(Call::isOnGoing) - .firstElement() - .delay(1, TimeUnit.SECONDS) - .doOnEvent((v, e) -> pendingObserver.onComplete()); - mCompositeDisposable.add(newCall.subscribe(call -> { - String id = mConference.getId(); - if (mConference.isConference()) { - mCallService.addParticipant(call.getDaemonIdString(), id); - } else { - mCallService.joinParticipant(id, call.getDaemonIdString()).subscribe(); - } - })); - } else { - // Selected contact already in call or conference, join it to current conference - Conference selectedConf = confs.get(0); - if (selectedConf != mConference) { - if (mConference.isConference()) { - if (selectedConf.isConference()) - mCallService.joinConference(mConference.getId(), selectedConf.getId()); - else - mCallService.addParticipant(selectedConf.getId(), mConference.getId()); - } else { - if (selectedConf.isConference()) - mCallService.addParticipant(mConference.getId(), selectedConf.getId()); - else - mCallService.joinParticipant(mConference.getId(), selectedConf.getId()).subscribe(); - } - } - } - })); - } - - public void startAddParticipant() { - getView().startAddParticipant(mConference.getId()); - } - - public void hangupParticipant(Conference.ParticipantInfo info) { - if (info.call != null) - mCallService.hangUp(info.call.getDaemonIdString()); - else - mCallService.hangupParticipant(mConference.getId(), info.contact.getPrimaryNumber()); - } - - public void muteParticipant(Conference.ParticipantInfo info, boolean mute) { - mCallService.muteParticipant(mConference.getId(), info.contact.getPrimaryNumber(), mute); - } - - public void openParticipantContact(Conference.ParticipantInfo info) { - Call call = info.call == null ? mConference.getFirstCall() : info.call; - getView().goToContact(call.getAccount(), info.contact); - } - - public void stopCapture() { - mHardwareService.stopCapture(); - } - - public boolean startScreenShare(Object mediaProjection) { - return mHardwareService.startScreenShare(mediaProjection); - } - - public void stopScreenShare() { - mHardwareService.stopScreenShare(); - } - - public boolean isMaximized(Conference.ParticipantInfo info) { - return mConference.getMaximizedParticipant() == info.contact; - } - - public void startPlugin(String mediaHandlerId) { - mHardwareService.startMediaHandler(mediaHandlerId); - if(mConference == null) - return; - mHardwareService.switchInput(mConference.getId(), mHardwareService.isPreviewFromFrontCamera()); - } - - public void stopPlugin() { - mHardwareService.stopMediaHandler(); - if(mConference == null) - return; - mHardwareService.switchInput(mConference.getId(), mHardwareService.isPreviewFromFrontCamera()); - } - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/call/CallPresenter.kt b/ring-android/libringclient/src/main/java/net/jami/call/CallPresenter.kt new file mode 100644 index 000000000..da0acdd51 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/call/CallPresenter.kt @@ -0,0 +1,698 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.call + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Observer +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.daemon.JamiService +import net.jami.services.ConversationFacade +import net.jami.model.* +import net.jami.model.Call.CallStatus +import net.jami.model.Conference.ParticipantInfo +import net.jami.model.Uri.Companion.fromString +import net.jami.mvp.RootPresenter +import net.jami.services.* +import net.jami.services.HardwareService.AudioState +import net.jami.services.HardwareService.VideoEvent +import net.jami.utils.Log +import net.jami.utils.StringUtils.toNumber +import java.util.* +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Named + +class CallPresenter @Inject constructor( + private val mAccountService: AccountService, + private val mContactService: ContactService, + private val mHardwareService: HardwareService, + private val mCallService: CallService, + private val mDeviceRuntimeService: DeviceRuntimeService, + private val mConversationFacade: ConversationFacade +) : RootPresenter<CallView>() { + private var mConference: Conference? = null + private val mPendingCalls: MutableList<Call> = ArrayList() + private val mPendingSubject: Subject<List<Call>> = BehaviorSubject.createDefault(mPendingCalls) + private var mOnGoingCall = false + var isAudioOnly = true + private set + private var permissionChanged = false + var isPipMode = false + private set + private var incomingIsFullIntent = true + private var callInitialized = false + private var videoWidth = -1 + private var videoHeight = -1 + private var previewWidth = -1 + private var previewHeight = -1 + private var currentSurfaceId: String? = null + private var currentPluginSurfaceId: String? = null + private var timeUpdateTask: Disposable? = null + + @Inject + @Named("UiScheduler") + lateinit var mUiScheduler: Scheduler + + fun cameraPermissionChanged(isGranted: Boolean) { + if (isGranted && mHardwareService.isVideoAvailable) { + mHardwareService.initVideo() + .onErrorComplete() + .blockingAwait() + permissionChanged = true + } + } + + fun audioPermissionChanged(isGranted: Boolean) { + if (isGranted && mHardwareService.hasMicrophone()) { + mCallService.restartAudioLayer() + } + } + + override fun unbindView() { + if (!isAudioOnly) { + mHardwareService.endCapture() + } + super.unbindView() + } + + override fun bindView(view: CallView) { + super.bindView(view) + /*mCompositeDisposable.add(mAccountService.getRegisteredNames() + .observeOn(mUiScheduler) + .subscribe(r -> { + if (mSipCall != null && mSipCall.getContact() != null) { + getView().updateContactBubble(mSipCall.getContact()); + } + }));*/mCompositeDisposable.add(mHardwareService.getVideoEvents() + .observeOn(mUiScheduler) + .subscribe { event: VideoEvent -> onVideoEvent(event) }) + mCompositeDisposable.add(mHardwareService.audioState + .observeOn(mUiScheduler) + .subscribe { state: AudioState? -> getView()!!.updateAudioState(state) }) + + /*mCompositeDisposable.add(mHardwareService + .getBluetoothEvents() + .subscribe(event -> { + if (!event.connected && mSipCall == null) { + hangupCall(); + } + }));*/ + } + + fun initOutGoing(accountId: String?, conversationUri: Uri?, contactUri: String?, audioOnly: Boolean) { + Log.e(TAG, "initOutGoing") + var audioOnly = audioOnly + if (accountId == null || contactUri == null) { + Log.e(TAG, "initOutGoing: null account or contact") + hangupCall() + return + } + if (!mHardwareService.hasCamera()) { + audioOnly = true + } + //getView().blockScreenRotation(); + val callObservable = mCallService + .placeCall(accountId, conversationUri, fromString(toNumber(contactUri)!!), audioOnly) + //.map(mCallService::getConference) + .flatMapObservable { call: Call -> mCallService.getConfUpdates(call) } + .share() + mCompositeDisposable.add(callObservable + .observeOn(mUiScheduler) + .subscribe({ conference: Conference -> + contactUpdate(conference) + confUpdate(conference) + }) { e: Throwable -> + hangupCall() + Log.e(TAG, "Error with initOutgoing: " + e.message) + }) + showConference(callObservable) + } + + /** + * Returns to or starts an incoming call + * + * @param confId the call id + * @param actionViewOnly true if only returning to call or if using full screen intent + */ + fun initIncomingCall(confId: String, actionViewOnly: Boolean) { + //getView().blockScreenRotation(); + + // if the call is incoming through a full intent, this allows the incoming call to display + incomingIsFullIntent = actionViewOnly + val callObservable = mCallService.getConfUpdates(confId) + .observeOn(mUiScheduler) + .share() + + // Handles the case where the call has been accepted, emits a single so as to only check for permissions and start the call once + mCompositeDisposable.add(callObservable + .firstOrError() + .subscribe({ call: Conference -> + if (!actionViewOnly) { + contactUpdate(call) + confUpdate(call) + callInitialized = true + view!!.prepareCall(true) + } + }) { e: Throwable? -> + hangupCall() + Log.e(TAG, "Error with initIncoming, preparing call flow :", e) + }) + + // Handles retrieving call updates. Items emitted are only used if call is already in process or if user is returning to a call. + mCompositeDisposable.add(callObservable + .subscribe({ call: Conference -> + if (callInitialized || actionViewOnly) { + contactUpdate(call) + confUpdate(call) + } + }) { e: Throwable? -> + hangupCall() + Log.e(TAG, "Error with initIncoming, action view flow: ", e) + }) + showConference(callObservable) + } + + private fun showConference(conference: Observable<Conference>) { + var conference = conference + conference = conference + .distinctUntilChanged() + mCompositeDisposable.add(conference + .switchMap { obj: Conference -> obj.participantInfo } + .observeOn(mUiScheduler) + .subscribe( + { info: List<ParticipantInfo>? -> view!!.updateConfInfo(info) } + ) { e: Throwable? -> Log.e(TAG, "Error with initIncoming, action view flow: ", e) }) + mCompositeDisposable.add(conference + .switchMap { obj: Conference -> obj.participantRecording } + .observeOn(mUiScheduler) + .subscribe( + { contacts: Set<Contact>? -> view!!.updateParticipantRecording(contacts) } + ) { e: Throwable? -> Log.e(TAG, "Error with initIncoming, action view flow: ", e) }) + } + + fun prepareOptionMenu() { + val isSpeakerOn: Boolean = mHardwareService.isSpeakerphoneOn + //boolean hasContact = mSipCall != null && null != mSipCall.getContact() && mSipCall.getContact().isUnknown(); + val canDial = mOnGoingCall && mConference != null + // get the preferences + val displayPluginsButton = view!!.displayPluginsButton() + val showPluginBtn = displayPluginsButton && mOnGoingCall && mConference != null + val hasMultipleCamera = mHardwareService.cameraCount > 1 && mOnGoingCall && !isAudioOnly + view!!.initMenu(isSpeakerOn, hasMultipleCamera, canDial, showPluginBtn, mOnGoingCall) + } + + fun chatClick() { + if (mConference == null || mConference!!.participants.isEmpty()) { + return + } + val firstCall = mConference!!.participants[0] ?: return + val c = firstCall.conversation + if (c is Conversation) { + val conversation = c + view!!.goToConversation(conversation.accountId, conversation.uri) + } else if (firstCall.contact != null) { + view!!.goToConversation(firstCall.account, firstCall.contact!!.conversationUri.blockingFirst()) + } + } + + val isSpeakerphoneOn: Boolean + get() = mHardwareService.isSpeakerphoneOn + + fun speakerClick(checked: Boolean) { + mHardwareService.toggleSpeakerphone(checked) + } + + fun muteMicrophoneToggled(checked: Boolean) { + mCallService.setLocalMediaMuted(mConference!!.id, CallService.MEDIA_TYPE_AUDIO, checked) + } + + val isMicrophoneMuted: Boolean + get() = mCallService.isCaptureMuted + + fun switchVideoInputClick() { + if (mConference == null) return + mHardwareService.switchInput(mConference!!.id, false) + view!!.switchCameraIcon(mHardwareService.isPreviewFromFrontCamera) + } + + fun configurationChanged(rotation: Int) { + mHardwareService.setDeviceOrientation(rotation) + } + + fun dialpadClick() { + view?.displayDialPadKeyboard() + } + + fun acceptCall() { + mConference?.let { mCallService.accept(it.id) } + } + + fun hangupCall() { + mConference?.let { conference -> + if (conference.isConference) + mCallService.hangUpConference(conference.id) + else + mCallService.hangUp(conference.id) + } + for (call in mPendingCalls) { + mCallService.hangUp(call.daemonIdString!!) + } + finish() + } + + fun refuseCall() { + mConference?.let { mCallService.refuse(it.id) } + finish() + } + + fun videoSurfaceCreated(holder: Any?) { + if (mConference == null) { + return + } + val newId = mConference!!.id + if (newId != currentSurfaceId) { + mHardwareService.removeVideoSurface(currentSurfaceId) + currentSurfaceId = newId + } + mHardwareService.addVideoSurface(mConference!!.id, holder) + view!!.displayContactBubble(false) + } + + fun videoSurfaceUpdateId(newId: String?) { + if (newId != currentSurfaceId) { + mHardwareService.updateVideoSurfaceId(currentSurfaceId, newId) + currentSurfaceId = newId + } + } + + fun pluginSurfaceCreated(holder: Any?) { + if (mConference == null) { + return + } + val newId = mConference!!.pluginId + if (newId != currentPluginSurfaceId) { + mHardwareService.removeVideoSurface(currentPluginSurfaceId) + currentPluginSurfaceId = newId + } + mHardwareService.addVideoSurface(mConference!!.pluginId, holder) + view!!.displayContactBubble(false) + } + + fun pluginSurfaceUpdateId(newId: String?) { + if (newId != currentPluginSurfaceId) { + mHardwareService.updateVideoSurfaceId(currentPluginSurfaceId, newId) + currentPluginSurfaceId = newId + } + } + + fun previewVideoSurfaceCreated(holder: Any?) { + mHardwareService.addPreviewVideoSurface(holder, mConference) + //mHardwareService.startCapture(null); + } + + fun videoSurfaceDestroyed() { + if (currentSurfaceId != null) { + mHardwareService.removeVideoSurface(currentSurfaceId) + currentSurfaceId = null + } + } + + fun pluginSurfaceDestroyed() { + if (currentPluginSurfaceId != null) { + mHardwareService.removeVideoSurface(currentPluginSurfaceId) + currentPluginSurfaceId = null + } + } + + fun previewVideoSurfaceDestroyed() { + mHardwareService.removePreviewVideoSurface() + mHardwareService.endCapture() + } + + fun displayChanged() { + mHardwareService.switchInput(mConference!!.id, false) + } + + fun layoutChanged() { + //getView().resetVideoSize(videoWidth, videoHeight, previewWidth, previewHeight); + } + + fun uiVisibilityChanged(displayed: Boolean) { + Log.w(TAG, "uiVisibilityChanged $mOnGoingCall $displayed") + val view = view + view?.displayHangupButton(mOnGoingCall && displayed) + } + + private fun finish() { + if (timeUpdateTask != null && !timeUpdateTask!!.isDisposed) { + timeUpdateTask!!.dispose() + timeUpdateTask = null + } + mConference = null + val view = view + view?.finish() + } + + private var contactDisposable: Disposable? = null + private fun contactUpdate(conference: Conference) { + if (mConference !== conference) { + mConference = conference + if (contactDisposable != null && !contactDisposable!!.isDisposed) { + contactDisposable!!.dispose() + } + if (conference.participants.isEmpty()) return + + // Updates of participant (and pending participant) list + val callsObservable = mPendingSubject + .map<List<Call>> { pendingList: List<Call> -> + Log.w(TAG, "mPendingSubject onNext " + pendingList.size + " " + conference.participants.size) + if (pendingList.isEmpty()) return@map conference.participants + val newList: MutableList<Call> = ArrayList(conference.participants.size + pendingList.size) + newList.addAll(conference.participants) + newList.addAll(pendingList) + newList + } + + // Updates of individual contacts + val contactsObservable = callsObservable + .flatMapSingle { calls: List<Call> -> + Observable.fromIterable(calls) + .map { call: Call -> mContactService.observeContact(call.account!!, call.contact!!, false) + .map { call } } + .toList(calls.size) + } + + // Combined updates of contacts as participant list updates + val contactUpdates = contactsObservable + .switchMap { list: List<Observable<Call>>? -> + Observable + .combineLatest(list) { objects: Array<Any> -> + Log.w(TAG, "flatMapObservable " + objects.size) + val calls = ArrayList<Call>(objects.size) + for (call in objects) calls.add(call as Call) + calls + } + } + .filter { list: List<Call> -> !list.isEmpty() } + contactDisposable = contactUpdates + .observeOn(mUiScheduler) + .subscribe({ cs: List<Call>? -> view!!.updateContactBubble(cs) }) { e: Throwable? -> + Log.e( + TAG, + "Error updating contact data", + e + ) + } + mCompositeDisposable.add(contactDisposable) + } + mPendingSubject.onNext(mPendingCalls) + } + + private fun confUpdate(call: Conference) { + Log.w(TAG, "confUpdate " + call.id + " " + call.state) + val status = call.state + if (status === CallStatus.HOLD) { + if (call.isSimpleCall) mCallService.unhold(call.id) else JamiService.addMainParticipant(call.id) + } + isAudioOnly = !call.hasVideo() + val view = view ?: return + view.updateMenu() + if (call.isOnGoing) { + mOnGoingCall = true + view.initNormalStateDisplay(isAudioOnly, isMicrophoneMuted) + view.updateMenu() + if (!isAudioOnly) { + mHardwareService.setPreviewSettings() + mHardwareService.updatePreviewVideoSurface(call) + videoSurfaceUpdateId(call.id) + pluginSurfaceUpdateId(call.pluginId) + view.displayVideoSurface(true, mDeviceRuntimeService.hasVideoPermission()) + if (permissionChanged) { + mHardwareService.switchInput(mConference!!.id, permissionChanged) + permissionChanged = false + } + } + if (timeUpdateTask != null) timeUpdateTask!!.dispose() + timeUpdateTask = mUiScheduler.schedulePeriodicallyDirect({ updateTime() }, 0, 1, TimeUnit.SECONDS) + } else if (call.isRinging) { + val scall = call.call!! + view.handleCallWakelock(isAudioOnly) + if (scall.isIncoming) { + if (mAccountService.getAccount(scall.account!!)!!.isAutoanswerEnabled) { + mCallService.accept(scall.daemonIdString!!) + // only display the incoming call screen if the notification is a full screen intent + } else if (incomingIsFullIntent) { + view.initIncomingCallDisplay() + } + } else { + mOnGoingCall = false + view.updateCallStatus(scall.callStatus) + view.initOutGoingCallDisplay() + } + } else { + finish() + } + } + + fun maximizeParticipant(info: ParticipantInfo?) { + var info = info + val contact = info?.contact + if (mConference!!.maximizedParticipant == contact) info = null + mConference!!.maximizedParticipant = contact + if (info != null) { + mCallService.setConfMaximizedParticipant(mConference!!.id, info.contact.uri) + } else { + mCallService.setConfGridLayout(mConference!!.id) + } + } + + private fun updateTime() { + val view = view + if (view != null && mConference != null) { + if (mConference!!.isOnGoing) { + val start = mConference!!.timestampStart + if (start != Long.MAX_VALUE) { + view.updateTime((System.currentTimeMillis() - start) / 1000) + } else { + view.updateTime(-1) + } + } + } + } + + private fun onVideoEvent(event: VideoEvent) { + Log.d(TAG, "VIDEO_EVENT: " + event.start + " " + event.callId + " " + event.w + "x" + event.h) + if (event.start) { + view!!.displayVideoSurface(true, !isPipMode && mDeviceRuntimeService.hasVideoPermission()) + } else if (mConference != null && mConference!!.id == event.callId) { + view!!.displayVideoSurface( + event.started, + event.started && !isPipMode && mDeviceRuntimeService.hasVideoPermission() + ) + if (event.started) { + videoWidth = event.w + videoHeight = event.h + view!!.resetVideoSize(videoWidth, videoHeight) + } + } else if (event.callId == null) { + if (event.started) { + previewWidth = event.w + previewHeight = event.h + view!!.resetPreviewVideoSize(previewWidth, previewHeight, event.rot) + } + } + if (mConference != null && mConference!!.pluginId == event.callId) { + if (event.started) { + previewWidth = event.w + previewHeight = event.h + view!!.resetPluginPreviewVideoSize(previewWidth, previewHeight, event.rot) + } + } + /*if (event.started || event.start) { + getView().resetVideoSize(videoWidth, videoHeight, previewWidth, previewHeight); + }*/ + } + + fun positiveButtonClicked() { + if (mConference!!.isRinging && mConference!!.isIncoming) { + acceptCall() + } else { + hangupCall() + } + } + + fun negativeButtonClicked() { + if (mConference!!.isRinging && mConference!!.isIncoming) { + refuseCall() + } else { + hangupCall() + } + } + + fun toggleButtonClicked() { + if (mConference != null && !(mConference!!.isRinging && mConference!!.isIncoming)) { + hangupCall() + } + } + + fun requestPipMode() { + if (mConference != null && mConference!!.isOnGoing && mConference!!.hasVideo()) { + view!!.enterPipMode(mConference!!.id) + } + } + + fun pipModeChanged(pip: Boolean) { + isPipMode = pip + if (pip) { + view!!.displayHangupButton(false) + view!!.displayPreviewSurface(false) + view!!.displayVideoSurface(true, false) + } else { + view!!.displayPreviewSurface(true) + view!!.displayVideoSurface(true, mDeviceRuntimeService.hasVideoPermission()) + } + } + + fun toggleCallMediaHandler(id: String?, toggle: Boolean) { + if (mConference != null && mConference!!.isOnGoing && mConference!!.hasVideo()) { + view!!.toggleCallMediaHandler(id, mConference!!.id, toggle) + } + } + + fun sendDtmf(s: CharSequence) { + mCallService.playDtmf(s.toString()) + } + + fun addConferenceParticipant(accountId: String, uri: Uri) { + mCompositeDisposable.add(mConversationFacade.startConversation(accountId, uri) + .subscribe { conversation: Conversation -> + val confs: List<Conference> = conversation.currentCalls + if (confs.isEmpty()) { + val pendingObserver: Observer<Call> = object : Observer<Call> { + private var call: Call? = null + override fun onSubscribe(d: Disposable) {} + override fun onNext(sipCall: Call) { + if (call == null) { + call = sipCall + mPendingCalls.add(sipCall) + } + mPendingSubject.onNext(mPendingCalls) + } + + override fun onError(e: Throwable) {} + override fun onComplete() { + if (call != null) { + mPendingCalls.remove(call) + mPendingSubject.onNext(mPendingCalls) + call = null + } + } + } + val contactUri = if (uri.isSwarm) conversation.contact!!.uri else uri + + // Place new call, join to conference when answered + val newCall = mCallService.placeCallObservable(accountId, null, contactUri, isAudioOnly) + .doOnEach(pendingObserver) + .filter(Call::isOnGoing) + .firstElement() + .delay(1, TimeUnit.SECONDS) + .doOnEvent { v: Call, e: Throwable -> pendingObserver.onComplete() } + mCompositeDisposable.add(newCall.subscribe { call: Call -> + val id = mConference!!.id + if (mConference!!.isConference) { + mCallService.addParticipant(call.daemonIdString!!, id) + } else { + mCallService.joinParticipant(id, call.daemonIdString!!).subscribe() + } + }) + } else { + // Selected contact already in call or conference, join it to current conference + val selectedConf = confs[0] + if (selectedConf !== mConference) { + if (mConference!!.isConference) { + if (selectedConf.isConference) + mCallService.joinConference(mConference!!.id, selectedConf.id) + else + mCallService.addParticipant(selectedConf.id, mConference!!.id) + } else { + if (selectedConf.isConference) + mCallService.addParticipant(mConference!!.id, selectedConf.id) + else + mCallService.joinParticipant(mConference!!.id, selectedConf.id).subscribe() + } + } + } + }) + } + + fun startAddParticipant() { + view!!.startAddParticipant(mConference!!.id) + } + + fun hangupParticipant(info: ParticipantInfo) { + if (info.call != null) + mCallService.hangUp(info.call.daemonIdString!!) + else + mCallService.hangupParticipant(mConference!!.id, info.contact.primaryNumber) + } + + fun muteParticipant(info: ParticipantInfo, mute: Boolean) { + mCallService.muteParticipant(mConference!!.id, info.contact.primaryNumber, mute) + } + + fun openParticipantContact(info: ParticipantInfo) { + val call = info.call ?: mConference!!.firstCall!! + view!!.goToContact(call.account, info.contact) + } + + fun stopCapture() { + mHardwareService.stopCapture() + } + + fun startScreenShare(mediaProjection: Any?): Boolean { + return mHardwareService.startScreenShare(mediaProjection) + } + + fun stopScreenShare() { + mHardwareService.stopScreenShare() + } + + fun isMaximized(info: ParticipantInfo): Boolean { + return mConference?.maximizedParticipant == info.contact + } + + fun startPlugin(mediaHandlerId: String?) { + mHardwareService.startMediaHandler(mediaHandlerId) + mConference?.let { conference -> mHardwareService.switchInput(conference.id, mHardwareService.isPreviewFromFrontCamera) } + } + + fun stopPlugin() { + mHardwareService.stopMediaHandler() + mConference?.let { conference -> mHardwareService.switchInput(conference.id, mHardwareService.isPreviewFromFrontCamera) } + } + + companion object { + val TAG = CallPresenter::class.simpleName!! + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/contactrequests/ContactRequestsPresenter.java b/ring-android/libringclient/src/main/java/net/jami/contactrequests/ContactRequestsPresenter.java index 03ff087a2..1a526d962 100644 --- a/ring-android/libringclient/src/main/java/net/jami/contactrequests/ContactRequestsPresenter.java +++ b/ring-android/libringclient/src/main/java/net/jami/contactrequests/ContactRequestsPresenter.java @@ -25,7 +25,7 @@ import java.util.List; import javax.inject.Inject; import javax.inject.Named; -import net.jami.facades.ConversationFacade; +import net.jami.services.ConversationFacade; import net.jami.model.Account; import net.jami.model.Uri; import net.jami.mvp.RootPresenter; @@ -70,9 +70,9 @@ public class ContactRequestsPresenter extends RootPresenter<net.jami.contactrequ } @Override - public void unbindView() { + public void onDestroy() { mAccount.onComplete(); - super.unbindView(); + super.onDestroy(); } public void updateAccount(String accountId) { diff --git a/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationPresenter.java b/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationPresenter.java deleted file mode 100644 index 5463e3ca5..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationPresenter.java +++ /dev/null @@ -1,474 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.conversation; - -import net.jami.daemon.Blob; -import net.jami.facades.ConversationFacade; -import net.jami.model.Account; -import net.jami.model.Call; -import net.jami.model.Conference; -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.model.DataTransfer; -import net.jami.model.Error; -import net.jami.model.Interaction; -import net.jami.model.TrustRequest; -import net.jami.model.Uri; -import net.jami.mvp.RootPresenter; -import net.jami.services.AccountService; -import net.jami.services.ContactService; -import net.jami.services.DeviceRuntimeService; -import net.jami.services.HardwareService; -import net.jami.services.PreferencesService; -import net.jami.services.VCardService; -import net.jami.utils.FileUtils; -import net.jami.utils.Log; -import net.jami.utils.StringUtils; -import net.jami.utils.Tuple; -import net.jami.utils.VCardUtils; - -import java.io.File; - -import javax.inject.Inject; -import javax.inject.Named; - -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Scheduler; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public class ConversationPresenter extends RootPresenter<ConversationView> { - - private static final String TAG = ConversationPresenter.class.getSimpleName(); - private final ContactService mContactService; - private final AccountService mAccountService; - private final HardwareService mHardwareService; - private final ConversationFacade mConversationFacade; - private final VCardService mVCardService; - private final DeviceRuntimeService mDeviceRuntimeService; - private final PreferencesService mPreferencesService; - - private Conversation mConversation; - private Uri mConversationUri; - - private CompositeDisposable mConversationDisposable; - private final CompositeDisposable mVisibilityDisposable = new CompositeDisposable(); - - @Inject - @Named("UiScheduler") - protected Scheduler mUiScheduler; - - private final Subject<Conversation> mConversationSubject = BehaviorSubject.create(); - - @Inject - public ConversationPresenter(ContactService contactService, - AccountService accountService, - HardwareService hardwareService, - ConversationFacade conversationFacade, - VCardService vCardService, - DeviceRuntimeService deviceRuntimeService, - PreferencesService preferencesService) { - mContactService = contactService; - mAccountService = accountService; - mHardwareService = hardwareService; - mConversationFacade = conversationFacade; - mVCardService = vCardService; - mDeviceRuntimeService = deviceRuntimeService; - mPreferencesService = preferencesService; - mCompositeDisposable.add(mVisibilityDisposable); - } - - public void init(Uri conversationUri, String accountId) { - Log.w(TAG, "init " + conversationUri + " " + accountId); - if (conversationUri.equals(mConversationUri)) - return; - mConversationUri = conversationUri; - mCompositeDisposable.add(mConversationFacade.getAccountSubject(accountId) - .observeOn(mUiScheduler) - .flatMap(account -> mConversationFacade.loadConversationHistory(account, conversationUri) - .map(c -> { - setConversation(account, c); - return c; - })) - .subscribe(c -> {}, e -> { - Log.e(TAG, "Error loading conversation", e); - getView().goToHome(); - })); - getView().setReadIndicatorStatus(showReadIndicator()); - } - - private void setConversation(Account account, final Conversation conversation) { - Log.w(TAG, "setConversation " + conversation.getAggregateHistory().size()); - if (mConversation == conversation) - return; - //if (mConversation != null) - // mConversation.setVisible(false); - mConversation = conversation; - mConversationSubject.onNext(conversation); - ConversationView view = getView(); - if (view != null) - initView(account, conversation, view); - } - - public void pause() { - mVisibilityDisposable.clear(); - if (mConversation != null) { - mConversation.setVisible(false); - } - } - - public void resume(boolean isBubble) { - Log.w(TAG, "resume " + mConversationUri); - mVisibilityDisposable.clear(); - mVisibilityDisposable.add(mConversationSubject - .subscribe(conversation -> { - conversation.setVisible(true); - updateOngoingCallView(conversation); - mConversationFacade.readMessages(mAccountService.getAccount(conversation.getAccountId()), conversation, !isBubble); - }, e -> Log.e(TAG, "Error loading conversation", e))); - } - - private void initContact(final Account account, final Conversation conversation, final ConversationView view) { - if (account.isJami()) { - Log.w(TAG, "initContact " + conversation.getUri()); - if (conversation.isSwarm() || account.isContact(conversation)) { - view.switchToConversationView(); - } else { - Uri uri = conversation.getUri(); - TrustRequest req = account.getRequest(uri); - if (req == null) { - view.switchToUnknownView(uri.getRawUriString()); - } else { - view.switchToIncomingTrustRequestView(req.getDisplayname()); - } - } - } else { - view.switchToConversationView(); - } - view.displayContact(conversation); - } - - private void initView(Account account, final Conversation c, final ConversationView view) { - Log.w(TAG, "initView " + c.getUri()); - if (mConversationDisposable == null) { - mConversationDisposable = new CompositeDisposable(); - mCompositeDisposable.add(mConversationDisposable); - } - mConversationDisposable.clear(); - view.hideNumberSpinner(); - - if (account.isJami()) { - mConversationDisposable.add(c.getContact() - .getConversationUri() - .observeOn(mUiScheduler) - .subscribe(uri -> init(uri, account.getAccountID()))); - } - - mConversationDisposable.add(Observable.combineLatest( - mHardwareService.getConnectivityState(), - mAccountService.getObservableAccount(account), - (isConnected, a) -> isConnected || a.isRegistered()) - .observeOn(mUiScheduler) - .subscribe(isOk -> { - ConversationView v = getView(); - if (v != null) { - if (!isOk) - v.displayNetworkErrorPanel(); - else if(!account.isEnabled()) { - v.displayAccountOfflineErrorPanel(); - } - else { - v.hideErrorPanel(); - } - } - })); - - mConversationDisposable.add(c.getSortedHistory() - .observeOn(mUiScheduler) - .subscribe(view::refreshView, e -> Log.e(TAG, "Can't update element", e))); - mConversationDisposable.add(c.getCleared() - .observeOn(mUiScheduler) - .subscribe(view::refreshView, e -> Log.e(TAG, "Can't update elements", e))); - - mConversationDisposable.add(c.getContactUpdates() - .switchMap(contacts -> Observable.merge(mContactService.observeLoadedContact(c.getAccountId(), contacts, true))) - .observeOn(mUiScheduler) - .subscribe(contact -> { - ConversationView v = getView(); - if (v != null) - v.updateContact(contact); - })); - - mConversationDisposable.add(mContactService.getLoadedContact(c.getAccountId(), c.getContacts(), true) - .observeOn(mUiScheduler) - .subscribe(contact -> initContact(account, c, view), e -> Log.e(TAG, "Can't get contact", e))); - - mConversationDisposable.add(c.getUpdatedElements() - .observeOn(mUiScheduler) - .subscribe(elementTuple -> { - switch(elementTuple.second) { - case ADD: - view.addElement(elementTuple.first); - break; - case UPDATE: - view.updateElement(elementTuple.first); - break; - case REMOVE: - view.removeElement(elementTuple.first); - break; - } - }, e -> Log.e(TAG, "Can't update element", e))); - - if (showTypingIndicator()) { - mConversationDisposable.add(c.getComposingStatus() - .observeOn(mUiScheduler) - .subscribe(view::setComposingStatus)); - } - mConversationDisposable.add(c.getLastDisplayed() - .observeOn(mUiScheduler) - .subscribe(view::setLastDisplayed)); - mConversationDisposable.add(c.getCalls() - .observeOn(mUiScheduler) - .subscribe(calls -> updateOngoingCallView(mConversation), e -> Log.e(TAG, "Can't update call view", e))); - mConversationDisposable.add(c.getColor() - .observeOn(mUiScheduler) - .subscribe(view::setConversationColor, e -> Log.e(TAG, "Can't update conversation color", e))); - mConversationDisposable.add(c.getSymbol() - .observeOn(mUiScheduler) - .subscribe(view::setConversationSymbol, e -> Log.e(TAG, "Can't update conversation color", e))); - - Log.e(TAG, "getLocationUpdates subscribe"); - mConversationDisposable.add(account - .getLocationUpdates(c.getUri()) - .observeOn(mUiScheduler) - .subscribe(u -> { - Log.e(TAG, "getLocationUpdates: update"); - getView().showMap(c.getAccountId(), c.getUri().getUri(), false); - })); - } - - public void loadMore() { - mConversationDisposable.add(mAccountService.loadMore(mConversation) - .subscribe(c -> {}, e-> {})); - } - - public void openContact() { - if (mConversation != null) - getView().goToContactActivity(mConversation.getAccountId(), mConversation.getUri()); - } - - public void sendTextMessage(String message) { - if (StringUtils.isEmpty(message) || mConversation == null) { - return; - } - Conference conference = mConversation.getCurrentCall(); - if (mConversation.isSwarm() || conference == null || !conference.isOnGoing()) { - mConversationFacade.sendTextMessage(mConversation, mConversationUri, message).subscribe(); - } else { - mConversationFacade.sendTextMessage(mConversation, conference, message); - } - } - - public void selectFile() { - getView().openFilePicker(); - } - - public void sendFile(File file) { - if (mConversation == null) - return; - mConversationFacade.sendFile(mConversation, mConversationUri, file).subscribe(); - } - - /** - * Gets the absolute path of the file dataTransfer and sends both the DataTransfer and the - * found path to the ConversationView in order to start saving the file - * - * @param interaction an interaction representing a datat transfer - */ - public void saveFile(Interaction interaction) { - DataTransfer transfer = (DataTransfer) interaction; - String fileAbsolutePath = getDeviceRuntimeService(). - getConversationPath(transfer) - .getAbsolutePath(); - getView().startSaveFile(transfer, fileAbsolutePath); - } - - public void shareFile(Interaction interaction) { - DataTransfer file = (DataTransfer) interaction; - File path = getDeviceRuntimeService().getConversationPath(file); - getView().shareFile(path, file.getDisplayName()); - } - - public void openFile(Interaction interaction) { - DataTransfer file = (DataTransfer) interaction; - File path = getDeviceRuntimeService().getConversationPath(file); - getView().openFile(path, file.getDisplayName()); - } - - public void acceptFile(DataTransfer transfer) { - getView().acceptFile(mConversation.getAccountId(), mConversationUri, transfer); - } - - public void refuseFile(DataTransfer transfer) { - getView().refuseFile(mConversation.getAccountId(), mConversationUri, transfer); - } - - public void deleteConversationItem(Interaction element) { - mConversationFacade.deleteConversationItem(mConversation, element); - } - - public void cancelMessage(Interaction message) { - mConversationFacade.cancelMessage(message); - } - - private void sendTrustRequest() { - Contact contact = mConversation.getContact(); - if (contact != null) { - contact.setStatus(Contact.Status.REQUEST_SENT); - } - mVCardService.loadSmallVCardWithDefault(mConversation.getAccountId(), VCardService.MAX_SIZE_REQUEST) - .subscribeOn(Schedulers.computation()) - .subscribe(vCard -> mAccountService.sendTrustRequest(mConversation, contact.getUri(), Blob.fromString(VCardUtils.vcardToString(vCard))), - e -> mAccountService.sendTrustRequest(mConversation, contact.getUri(), null)); - } - - public void clickOnGoingPane() { - Conference conf = mConversation == null ? null : mConversation.getCurrentCall(); - if (conf != null) { - getView().goToCallActivity(conf.getId()); - } else { - getView().displayOnGoingCallPane(false); - } - } - - public void goToCall(boolean audioOnly) { - if (audioOnly && !mHardwareService.hasMicrophone()) { - getView().displayErrorToast(Error.NO_MICROPHONE); - return; - } - - mCompositeDisposable.add(mConversationSubject - .firstElement() - .subscribe(conversation -> { - ConversationView view = getView(); - if (view != null) { - Conference conf = mConversation.getCurrentCall(); - if (conf != null - && !conf.getParticipants().isEmpty() - && conf.getParticipants().get(0).getCallStatus() != Call.CallStatus.INACTIVE - && conf.getParticipants().get(0).getCallStatus() != Call.CallStatus.FAILURE) { - view.goToCallActivity(conf.getId()); - } else { - view.goToCallActivityWithResult(mConversation.getAccountId(), mConversation.getUri(), mConversation.getContact().getUri(), audioOnly); - } - } - })); - } - - private void updateOngoingCallView(Conversation conversation) { - Conference conf = conversation == null ? null : conversation.getCurrentCall(); - if (conf != null && (conf.getState() == Call.CallStatus.CURRENT || conf.getState() == Call.CallStatus.HOLD || conf.getState() == Call.CallStatus.RINGING)) { - getView().displayOnGoingCallPane(true); - } else { - getView().displayOnGoingCallPane(false); - } - } - - public void onBlockIncomingContactRequest() { - mConversationFacade.discardRequest(mConversation.getAccountId(), mConversationUri); - mAccountService.removeContact(mConversation.getAccountId(), mConversationUri.getHost(), true); - - getView().goToHome(); - } - - public void onRefuseIncomingContactRequest() { - mConversationFacade.discardRequest(mConversation.getAccountId(), mConversationUri); - getView().goToHome(); - } - - public void onAcceptIncomingContactRequest() { - mConversationFacade.acceptRequest(mConversation.getAccountId(), mConversationUri); - getView().switchToConversationView(); - } - - public void onAddContact() { - sendTrustRequest(); - getView().switchToConversationView(); - } - - public DeviceRuntimeService getDeviceRuntimeService() { - return mDeviceRuntimeService; - } - - public void noSpaceLeft() { - Log.e(TAG, "configureForFileInfoTextMessage: no space left on device"); - getView().displayErrorToast(Error.NO_SPACE_LEFT); - } - - public void setConversationColor(int color) { - mCompositeDisposable.add(mConversationSubject - .firstElement() - .subscribe(conversation -> conversation.setColor(color))); - } - public void setConversationSymbol(CharSequence symbol) { - mCompositeDisposable.add(mConversationSubject - .firstElement() - .subscribe(conversation -> conversation.setSymbol(symbol))); - } - - public void cameraPermissionChanged(boolean isGranted) { - if (isGranted && mHardwareService.isVideoAvailable()) { - mHardwareService.initVideo() - .onErrorComplete() - .subscribe(); - } - } - - public void shareLocation() { - getView().startShareLocation(mConversation.getAccountId(), mConversationUri.getUri()); - } - - public void showPluginListHandlers() { - getView().showPluginListHandlers(mConversation.getAccountId(), mConversationUri.getUri()); - } - - public Tuple<String, String> getPath() { - return new Tuple<>(mConversation.getAccountId(), mConversationUri.getUri()); - } - - public void onComposingChanged(boolean hasMessage) { - if (mConversation == null || !showTypingIndicator()) { - return; - } - mConversationFacade.setIsComposing(mConversation.getAccountId(), mConversationUri, hasMessage); - } - - public boolean showTypingIndicator() { - return mPreferencesService.getSettings().isAllowTypingIndicator(); - } - - private boolean showReadIndicator() { - return mPreferencesService.getSettings().isAllowReadIndicator(); - } - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationPresenter.kt b/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationPresenter.kt new file mode 100644 index 000000000..42e68480d --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationPresenter.kt @@ -0,0 +1,434 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.conversation + +import ezvcard.VCard +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.daemon.Blob +import net.jami.services.ConversationFacade +import net.jami.model.* +import net.jami.model.Account.ComposingStatus +import net.jami.model.Conversation.ElementStatus +import net.jami.mvp.RootPresenter +import net.jami.services.* +import net.jami.utils.Log +import net.jami.utils.StringUtils.isEmpty +import net.jami.utils.Tuple +import net.jami.utils.VCardUtils.vcardToString +import java.io.File +import javax.inject.Inject +import javax.inject.Named + +class ConversationPresenter @Inject constructor( + private val mContactService: ContactService, + private val mAccountService: AccountService, + private val mHardwareService: HardwareService, + private val mConversationFacade: ConversationFacade, + private val mVCardService: VCardService, + val deviceRuntimeService: DeviceRuntimeService, + private val mPreferencesService: PreferencesService, + @Named("UiScheduler") private var mUiScheduler: Scheduler +) : RootPresenter<ConversationView>() { + private var mConversation: Conversation? = null + private var mConversationUri: Uri? = null + private var mConversationDisposable: CompositeDisposable? = null + private val mVisibilityDisposable = CompositeDisposable() + private val mConversationSubject: Subject<Conversation> = BehaviorSubject.create() + + fun init(conversationUri: Uri, accountId: String) { + Log.w(TAG, "init $conversationUri $accountId") + if (conversationUri == mConversationUri) return + mConversationUri = conversationUri + mCompositeDisposable.add(mConversationFacade.getAccountSubject(accountId) + .flatMap { a: Account -> + mConversationFacade.loadConversationHistory(a, conversationUri) + .observeOn(mUiScheduler) + .doOnSuccess { c: Conversation -> setConversation(a, c) } + } + .observeOn(mUiScheduler) + .subscribe({}) { e: Throwable -> + Log.e(TAG, "Error loading conversation", e) + view?.goToHome() + }) + view?.setReadIndicatorStatus(showReadIndicator()) + } + + override fun unbindView() { + super.unbindView() + mConversation = null + mConversationUri = null + mConversationDisposable?.let { conversationDisposable -> + conversationDisposable.dispose() + mConversationDisposable = null + } + } + + private fun setConversation(account: Account, conversation: Conversation) { + Log.w(TAG, "setConversation " + conversation.aggregateHistory.size) + if (mConversation == conversation) return + mConversation = conversation + mConversationSubject.onNext(conversation) + val view = view + view?.let { initView(account, conversation, it) } + } + + fun pause() { + mVisibilityDisposable.clear() + mConversation?.isVisible = false + } + + fun resume(isBubble: Boolean) { + Log.w(TAG, "resume $mConversationUri") + mVisibilityDisposable.clear() + mVisibilityDisposable.add(mConversationSubject + .subscribe({ conversation: Conversation -> + conversation.isVisible = true + updateOngoingCallView(conversation) + mAccountService.getAccount(conversation.accountId)?.let { account -> + mConversationFacade.readMessages(account, conversation, !isBubble)} + }) { e -> Log.e(TAG, "Error loading conversation", e) }) + } + + private fun initContact(account: Account, conversation: Conversation, mode: Conversation.Mode, view: ConversationView) { + if (account.isJami) { + Log.w(TAG, "initContact " + conversation.uri) + if (mode === Conversation.Mode.Syncing) { + view.switchToSyncingView() + } else if (conversation.isSwarm || account.isContact(conversation)) { + //if (conversation.isEnded()) + // conversation.s + view.switchToConversationView() + } else { + val uri = conversation.uri + val req = account.getRequest(uri) + if (req == null) { + view.switchToUnknownView(uri.rawUriString) + } else { + view.switchToIncomingTrustRequestView(req.displayname) + } + } + } else { + view.switchToConversationView() + } + view.displayContact(conversation) + } + + private fun initView(account: Account, c: Conversation, view: ConversationView) { + Log.w(TAG, "initView " + c.uri + " " + c.mode) + val disposable = mConversationDisposable?.apply { clear() } ?: CompositeDisposable().apply { + mConversationDisposable = this + mCompositeDisposable.add(this) + } + + view.hideNumberSpinner() + disposable.add(c.mode + .switchMapSingle { mode: Conversation.Mode -> + mContactService.getLoadedContact(c.accountId, c.contacts, true) + .observeOn(mUiScheduler) + .doOnSuccess { initContact(account, c, mode, view) } + } + .subscribe()) + disposable.add(c.mode + .switchMap { mode: Conversation.Mode -> if (mode === Conversation.Mode.Legacy || mode === Conversation.Mode.OneToOne) c.contact!!.conversationUri else Observable.empty() } + .observeOn(mUiScheduler) + .subscribe { uri: Uri -> init(uri, account.accountID) }) + disposable.add( + Observable.combineLatest( + mHardwareService.connectivityState, + mAccountService.getObservableAccount(account), + { isConnected: Boolean, a: Account -> isConnected || a.isRegistered }) + .observeOn(mUiScheduler) + .subscribe { isOk: Boolean -> + val v = getView() + if (v != null) { + if (!isOk) v.displayNetworkErrorPanel() else if (!account.isEnabled) { + v.displayAccountOfflineErrorPanel() + } else { + v.hideErrorPanel() + } + } + }) + disposable.add(c.sortedHistory + .observeOn(mUiScheduler) + .subscribe({ conversation: List<Interaction> -> view.refreshView(conversation) }) { e: Throwable -> + Log.e(TAG, "Can't update element", e) + }) + disposable.add(c.cleared + .observeOn(mUiScheduler) + .subscribe({ conversation: List<Interaction> -> view.refreshView(conversation) }) { e: Throwable -> + Log.e(TAG, "Can't update elements", e) + }) + disposable.add(c.contactUpdates + .switchMap { contacts: List<Contact> -> + Observable.merge(mContactService.observeLoadedContact(c.accountId, contacts, true)) + } + .observeOn(mUiScheduler) + .subscribe { contact: Contact -> getView()?.updateContact(contact) }) + disposable.add(c.updatedElements + .observeOn(mUiScheduler) + .subscribe({ elementTuple -> + when (elementTuple.second) { + ElementStatus.ADD -> view.addElement(elementTuple.first) + ElementStatus.UPDATE -> view.updateElement(elementTuple.first) + ElementStatus.REMOVE -> view.removeElement(elementTuple.first) + } + }, { e: Throwable -> Log.e(TAG, "Can't update element", e) }) + ) + if (showTypingIndicator()) { + disposable.add(c.composingStatus + .observeOn(mUiScheduler) + .subscribe { composingStatus: ComposingStatus -> view.setComposingStatus(composingStatus) }) + } + disposable.add(c.getLastDisplayed() + .observeOn(mUiScheduler) + .subscribe { interaction: Interaction -> view.setLastDisplayed(interaction) }) + disposable.add(c.calls + .observeOn(mUiScheduler) + .subscribe({ updateOngoingCallView(c) }) { e: Throwable -> + Log.e(TAG, "Can't update call view", e) + }) + disposable.add(c.getColor() + .observeOn(mUiScheduler) + .subscribe({ integer: Int -> view.setConversationColor(integer) }) { e: Throwable -> + Log.e(TAG, "Can't update conversation color", e) + }) + disposable.add(c.getSymbol() + .observeOn(mUiScheduler) + .subscribe({ symbol: CharSequence -> view.setConversationSymbol(symbol) }) { e: Throwable -> + Log.e(TAG, "Can't update conversation color", e) + }) + disposable.add(account + .getLocationUpdates(c.uri) + .observeOn(mUiScheduler) + .subscribe { + Log.e(TAG, "getLocationUpdates: update") + getView()?.showMap(c.accountId, c.uri.uri, false) + } + ) + } + + fun loadMore() { + mConversationDisposable?.add(mAccountService.loadMore(mConversation!!).subscribe({}) {}) + } + + fun openContact() { + mConversation?.let { conversation -> view?.goToContactActivity(conversation.accountId, conversation.uri) } + } + + fun sendTextMessage(message: String?) { + val conversation = mConversation + if (isEmpty(message) || conversation == null) { + return + } + val conference = conversation.currentCall + if (conversation.isSwarm || conference == null || !conference.isOnGoing) { + mConversationFacade.sendTextMessage(conversation, conversation.uri, message).subscribe() + } else { + mConversationFacade.sendTextMessage(conversation, conference, message) + } + } + + fun selectFile() { + view!!.openFilePicker() + } + + fun sendFile(file: File?) { + mConversation?.let { conversation -> + mConversationFacade.sendFile(conversation, conversation.uri, file).subscribe() } + } + + /** + * Gets the absolute path of the file dataTransfer and sends both the DataTransfer and the + * found path to the ConversationView in order to start saving the file + * + * @param interaction an interaction representing a datat transfer + */ + fun saveFile(interaction: Interaction) { + val transfer = interaction as DataTransfer + val fileAbsolutePath = deviceRuntimeService.getConversationPath(transfer).absolutePath + view?.startSaveFile(transfer, fileAbsolutePath) + } + + fun shareFile(interaction: Interaction) { + val file = interaction as DataTransfer + val path = deviceRuntimeService.getConversationPath(file) + view?.shareFile(path, file.displayName) + } + + fun openFile(interaction: Interaction) { + val file = interaction as DataTransfer + val path = deviceRuntimeService.getConversationPath(file) + view?.openFile(path, file.displayName) + } + + fun acceptFile(transfer: DataTransfer) { + view?.acceptFile(mConversation!!.accountId, mConversationUri!!, transfer) + } + + fun refuseFile(transfer: DataTransfer) { + view!!.refuseFile(mConversation!!.accountId, mConversationUri!!, transfer) + } + + fun deleteConversationItem(element: Interaction) { + mConversationFacade.deleteConversationItem(mConversation, element) + } + + fun cancelMessage(message: Interaction) { + mConversationFacade.cancelMessage(message) + } + + private fun sendTrustRequest() { + val conversation = mConversation ?: return + val contact = conversation.contact ?: return + contact.status = Contact.Status.REQUEST_SENT + mVCardService.loadSmallVCardWithDefault(conversation.accountId, VCardService.MAX_SIZE_REQUEST) + .subscribeOn(Schedulers.computation()) + .subscribe({ vCard -> mAccountService.sendTrustRequest(conversation, contact.uri, Blob.fromString(vcardToString(vCard)))}) + { mAccountService.sendTrustRequest(conversation, contact.uri, null) } + } + + fun clickOnGoingPane() { + val conf = mConversation?.currentCall + if (conf != null) { + view?.goToCallActivity(conf.id) + } else { + view?.displayOnGoingCallPane(false) + } + } + + fun goToCall(audioOnly: Boolean) { + if (audioOnly && !mHardwareService.hasMicrophone()) { + view!!.displayErrorToast(Error.NO_MICROPHONE) + return + } + mCompositeDisposable.add(mConversationSubject + .firstElement() + .subscribe { conversation: Conversation -> + val view = view + if (view != null) { + val conf = conversation.currentCall + if (conf != null && conf.participants.isNotEmpty() + && conf.participants[0].callStatus !== Call.CallStatus.INACTIVE + && conf.participants[0].callStatus !== Call.CallStatus.FAILURE) { + view.goToCallActivity(conf.id) + } else { + view.goToCallActivityWithResult(conversation.accountId, conversation.uri, conversation.contact!!.uri, audioOnly) + } + } + }) + } + + private fun updateOngoingCallView(conversation: Conversation?) { + val conf = conversation?.currentCall + view?.displayOnGoingCallPane(conf != null && (conf.state === Call.CallStatus.CURRENT || conf.state === Call.CallStatus.HOLD || conf.state === Call.CallStatus.RINGING)) + } + + fun onBlockIncomingContactRequest() { + mConversation?.let { conversation -> + mConversationFacade.discardRequest(conversation.accountId, conversation.uri) + mAccountService.removeContact(conversation.accountId, conversation.uri.host, true) + } + view?.goToHome() + } + + fun onRefuseIncomingContactRequest() { + mConversation?.let { conversation -> + mConversationFacade.discardRequest(conversation.accountId, conversation.uri) + } + view?.goToHome() + } + + fun onAcceptIncomingContactRequest() { + mConversation?.let { conversation -> + mConversationFacade.acceptRequest(conversation.accountId, conversation.uri) + } + view?.switchToConversationView() + } + + fun onAddContact() { + sendTrustRequest() + view?.switchToConversationView() + } + + fun noSpaceLeft() { + Log.e(TAG, "configureForFileInfoTextMessage: no space left on device") + view?.displayErrorToast(Error.NO_SPACE_LEFT) + } + + fun setConversationColor(color: Int) { + mCompositeDisposable.add(mConversationSubject + .firstElement() + .subscribe { conversation: Conversation -> conversation.setColor(color) }) + } + + fun setConversationSymbol(symbol: CharSequence) { + mCompositeDisposable.add(mConversationSubject.firstElement() + .subscribe { conversation -> conversation.setSymbol(symbol) }) + } + + fun cameraPermissionChanged(isGranted: Boolean) { + if (isGranted && mHardwareService.isVideoAvailable) { + mHardwareService.initVideo() + .onErrorComplete() + .subscribe() + } + } + + fun shareLocation() { + view?.startShareLocation(mConversation!!.accountId, mConversationUri!!.uri) + } + + fun showPluginListHandlers() { + view?.showPluginListHandlers(mConversation!!.accountId, mConversationUri!!.uri) + } + + val path: Tuple<String, String> + get() = Tuple(mConversation!!.accountId, mConversationUri!!.uri) + + fun onComposingChanged(hasMessage: Boolean) { + if (showTypingIndicator()) { + mConversation?.let { conversation -> + mConversationFacade.setIsComposing(conversation.accountId, conversation.uri, hasMessage) + } + } + } + + private fun showTypingIndicator(): Boolean { + return mPreferencesService.settings.isAllowTypingIndicator + } + + private fun showReadIndicator(): Boolean { + return mPreferencesService.settings.isAllowReadIndicator + } + + companion object { + private val TAG = ConversationPresenter::class.simpleName!! + } + + init { + mCompositeDisposable.add(mVisibilityDisposable) + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationView.java b/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationView.java deleted file mode 100644 index 9194f0f8a..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationView.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.conversation; - -import java.io.File; -import java.util.List; - -import net.jami.model.Account; -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.model.Error; -import net.jami.model.Interaction; -import net.jami.model.DataTransfer; -import net.jami.model.Uri; -import net.jami.mvp.BaseView; - -public interface ConversationView extends BaseView { - - void refreshView(List<Interaction> conversation); - - void scrollToEnd(); - - void updateContact(Contact contact); - - void displayContact(Conversation conversation); - - void displayOnGoingCallPane(boolean display); - - void displayNumberSpinner(Conversation conversation, Uri number); - - void displayErrorToast(Error error); - - void hideNumberSpinner(); - - void clearMsgEdit(); - - void goToHome(); - - void goToAddContact(Contact contact); - - void goToCallActivity(String conferenceId); - - void goToCallActivityWithResult(String accountId, Uri conversationUri, Uri contactUri, boolean audioOnly); - - void goToContactActivity(String accountId, Uri uri); - - void switchToUnknownView(String name); - - void switchToIncomingTrustRequestView(String message); - - void switchToConversationView(); - - void askWriteExternalStoragePermission(); - - void openFilePicker(); - - void acceptFile(String accountId, Uri conversationUri, DataTransfer transfer); - void refuseFile(String accountId, Uri conversationUri, DataTransfer transfer); - void shareFile(File path, String displayName); - void openFile(File path, String displayName); - - void addElement(Interaction e); - void updateElement(Interaction e); - void removeElement(Interaction e); - void setComposingStatus(Account.ComposingStatus composingStatus); - void setLastDisplayed(Interaction interaction); - - void setConversationColor(int integer); - void setConversationSymbol(CharSequence symbol); - - void startSaveFile(DataTransfer currentFile, String fileAbsolutePath); - - void startShareLocation(String accountId, String contactId); - - void showMap(String accountId, String contactId, boolean open); - void hideMap(); - - void showPluginListHandlers(String accountId, String peerId); - - void hideErrorPanel(); - - void displayNetworkErrorPanel(); - - void displayAccountOfflineErrorPanel(); - - void setReadIndicatorStatus(boolean show); - - void updateLastRead(String last); - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationView.kt b/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationView.kt new file mode 100644 index 000000000..d69902d1e --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/conversation/ConversationView.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.conversation + +import net.jami.model.* +import net.jami.model.Account.ComposingStatus +import net.jami.mvp.BaseView +import java.io.File + +interface ConversationView : BaseView { + fun refreshView(conversation: List<Interaction>) + fun scrollToEnd() + fun updateContact(contact: Contact) + fun displayContact(conversation: Conversation) + fun displayOnGoingCallPane(display: Boolean) + fun displayNumberSpinner(conversation: Conversation, number: Uri) + override fun displayErrorToast(error: Error) + fun hideNumberSpinner() + fun clearMsgEdit() + fun goToHome() + fun goToAddContact(contact: Contact) + fun goToCallActivity(conferenceId: String) + fun goToCallActivityWithResult(accountId: String, conversationUri: Uri, contactUri: Uri, audioOnly: Boolean) + fun goToContactActivity(accountId: String, uri: Uri) + fun switchToUnknownView(name: String) + fun switchToIncomingTrustRequestView(message: String) + fun switchToConversationView() + fun switchToSyncingView() + fun switchToEndedView() + fun askWriteExternalStoragePermission() + fun openFilePicker() + fun acceptFile(accountId: String, conversationUri: Uri, transfer: DataTransfer) + fun refuseFile(accountId: String, conversationUri: Uri, transfer: DataTransfer) + fun shareFile(path: File, displayName: String) + fun openFile(path: File, displayName: String) + fun addElement(e: Interaction) + fun updateElement(e: Interaction) + fun removeElement(e: Interaction) + fun setComposingStatus(composingStatus: ComposingStatus) + fun setLastDisplayed(interaction: Interaction) + fun setConversationColor(integer: Int) + fun setConversationSymbol(symbol: CharSequence) + fun startSaveFile(currentFile: DataTransfer, fileAbsolutePath: String) + fun startShareLocation(accountId: String, contactId: String) + fun showMap(accountId: String, contactId: String, open: Boolean) + fun hideMap() + fun showPluginListHandlers(accountId: String, peerId: String) + fun hideErrorPanel() + fun displayNetworkErrorPanel() + fun displayAccountOfflineErrorPanel() + fun setReadIndicatorStatus(show: Boolean) + fun updateLastRead(last: String) +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/facades/ConversationFacade.java b/ring-android/libringclient/src/main/java/net/jami/facades/ConversationFacade.java deleted file mode 100644 index 168d9e1a9..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/facades/ConversationFacade.java +++ /dev/null @@ -1,742 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.facades; - -import net.jami.model.Account; -import net.jami.model.Call; -import net.jami.model.Conference; -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.model.DataTransfer; -import net.jami.model.Interaction; -import net.jami.model.TextMessage; -import net.jami.model.Uri; -import net.jami.services.AccountService; -import net.jami.services.CallService; -import net.jami.services.ContactService; -import net.jami.services.DeviceRuntimeService; -import net.jami.services.HardwareService; -import net.jami.services.HistoryService; -import net.jami.services.NotificationService; -import net.jami.services.PreferencesService; -import net.jami.smartlist.SmartListViewModel; -import net.jami.utils.FileUtils; -import net.jami.utils.Log; -import net.jami.utils.Tuple; - -import java.io.File; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.NavigableMap; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; - -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.PublishSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public class ConversationFacade { - - private final static String TAG = ConversationFacade.class.getSimpleName(); - - private final AccountService mAccountService; - private final HistoryService mHistoryService; - private final CallService mCallService; - private final ContactService mContactService; - private final NotificationService mNotificationService; - private final CompositeDisposable mDisposableBag = new CompositeDisposable(); - private final Observable<Account> currentAccountSubject; - private final Subject<Conversation> conversationSubject = PublishSubject.create(); - @Inject - HardwareService mHardwareService; - @Inject - DeviceRuntimeService mDeviceRuntimeService; - @Inject - PreferencesService mPreferencesService; - - public ConversationFacade(HistoryService historyService, - CallService callService, - AccountService accountService, - ContactService contactService, - NotificationService notificationService) { - mHistoryService = historyService; - mCallService = callService; - mAccountService = accountService; - mContactService = contactService; - mNotificationService = notificationService; - - currentAccountSubject = mAccountService - .getCurrentAccountSubject() - .switchMapSingle(this::loadSmartlist); - - mDisposableBag.add(mCallService.getCallsUpdates() - .subscribe(this::onCallStateChange)); - - /*mDisposableBag.add(mCallService.getConnectionUpdates() - .subscribe(mNotificationService::onConnectionUpdate));*/ - - mDisposableBag.add(mCallService.getConfsUpdates() - .observeOn(Schedulers.io()) - .subscribe(this::onConfStateChange)); - - mDisposableBag.add(currentAccountSubject - .switchMap(a -> a.getPendingSubject() - .doOnNext(p -> mNotificationService.showIncomingTrustRequestNotification(a))) - .subscribe()); - - mDisposableBag.add(mAccountService.getIncomingRequests() - .concatMapSingle(r -> getAccountSubject(r.getAccountId())) - .subscribe(mNotificationService::showIncomingTrustRequestNotification, - e -> Log.e(TAG, "Error showing contact request"))); - - mDisposableBag.add(mAccountService - .getIncomingMessages() - .concatMapSingle(msg -> getAccountSubject(msg.getAccount()) - .map(a -> { - a.addTextMessage(msg); - return msg; - })) - .subscribe(this::parseNewMessage, - e -> Log.e(TAG, "Error adding text message", e))); - mDisposableBag.add(mAccountService - .getIncomingSwarmMessages() - .subscribe(this::parseNewMessage, - e -> Log.e(TAG, "Error adding text message", e))); - - mDisposableBag.add(mAccountService.getLocationUpdates() - .concatMapSingle(location -> getAccountSubject(location.getAccount()) - .map(a -> { - long expiration = a.onLocationUpdate(location); - mDisposableBag.add(Completable.timer(expiration, TimeUnit.MILLISECONDS) - .subscribe(a::maintainLocation)); - return location; - })) - .subscribe()); - - mDisposableBag.add(mAccountService.getObservableAccountList() - .switchMap(accounts -> { - List<Observable<Tuple<Account, Account.ContactLocationEntry>>> r = new ArrayList<>(accounts.size()); - for (Account a : accounts) - r.add(a.getLocationUpdates().map(s -> new Tuple<>(a, s))); - return Observable.merge(r); - }) - .distinctUntilChanged() - .subscribe(t -> { - Log.e(TAG, "Location reception started for " + t.second.contact); - mNotificationService.showLocationNotification(t.first, t.second.contact); - mDisposableBag.add(t.second.location.doOnComplete(() -> - mNotificationService.cancelLocationNotification(t.first, t.second.contact)).subscribe()); - })); - - mDisposableBag.add(mAccountService - .getMessageStateChanges() - .concatMapSingle(e -> getAccountSubject(e.getAccount()) - .map(a -> e.getConversation() == null ? a.getSwarm(e.getConversationId()) : a.getByUri(e.getConversation().getParticipant())) - .doOnSuccess(conversation -> conversation.updateInteraction(e))) - .subscribe(c -> { - }, e -> Log.e(TAG, "Error updating text message", e))); - - mDisposableBag.add(mAccountService - .getDataTransfers() - .subscribe(this::handleDataTransferEvent, - e -> Log.e(TAG, "Error adding data transfer", e))); - } - - public Observable<Conversation> getUpdatedConversation() { - return conversationSubject; - } - - public Single<Conversation> startConversation(String accountId, final Uri contactId) { - return getAccountSubject(accountId) - .map(account -> account.getByUri(contactId)); - } - - public Observable<Account> getCurrentAccountSubject() { - return currentAccountSubject; - } - - public Single<Account> getAccountSubject(String accountId) { - return mAccountService - .getAccountSingle(accountId) - .flatMap(this::loadSmartlist); - } - - public Observable<List<Conversation>> getConversationsSubject() { - return currentAccountSubject - .switchMap(Account::getConversationsSubject); - } - - public String readMessages(String accountId, Uri contact) { - Account account = mAccountService.getAccount(accountId); - return account != null ? - readMessages(account, account.getByUri(contact), true) : null; - } - - public String readMessages(Account account, Conversation conversation, boolean cancelNotification) { - if (conversation != null) { - String lastMessage = readMessages(conversation); - if (lastMessage != null) { - account.refreshed(conversation); - if (mPreferencesService.getSettings().isAllowReadIndicator()) { - mAccountService.setMessageDisplayed(account.getAccountID(), conversation.getUri(), lastMessage); - } - if (cancelNotification) { - mNotificationService.cancelTextNotification(account.getAccountID(), conversation.getUri()); - } - } - return lastMessage; - } - return null; - } - - private String readMessages(Conversation conversation) { - String lastRead = null; - if (conversation.isSwarm()) { - lastRead = conversation.readMessages(); - if (lastRead != null) - mHistoryService.setMessageRead(conversation.getAccountId(), conversation.getUri(), lastRead); - } else { - NavigableMap<Long, Interaction> messages = conversation.getRawHistory(); - for (Interaction e : messages.descendingMap().values()) { - if (!(e.getType().equals(Interaction.InteractionType.TEXT))) - continue; - if (e.isRead()) { - break; - } - e.read(); - Long did = e.getDaemonId(); - if (lastRead == null && did != null && did != 0L) - lastRead = Long.toString(did, 16); - mHistoryService.updateInteraction(e, conversation.getAccountId()).subscribe(); - } - } - return lastRead; - } - - public Completable sendTextMessage(Conversation c, Uri to, String txt) { - if (c.isSwarm()) { - mAccountService.sendConversationMessage(c.getAccountId(), c.getUri(), txt); - return Completable.complete(); - } - return mCallService.sendAccountTextMessage(c.getAccountId(), to.getRawUriString(), txt) - .map(id -> { - TextMessage message = new TextMessage(null, c.getAccountId(), Long.toHexString(id), c, txt); - if (c.isVisible()) - message.read(); - mHistoryService.insertInteraction(c.getAccountId(), c, message).subscribe(); - c.addTextMessage(message); - mAccountService.getAccount(c.getAccountId()).conversationUpdated(c); - return message; - }).ignoreElement(); - } - - public void sendTextMessage(Conversation c, Conference conf, String txt) { - mCallService.sendTextMessage(conf.getId(), txt); - TextMessage message = new TextMessage(null, c.getAccountId(), conf.getId(), c, txt); - message.read(); - mHistoryService.insertInteraction(c.getAccountId(), c, message).subscribe(); - c.addTextMessage(message); - } - - public void setIsComposing(String accountId, Uri conversationUri, boolean isComposing) { - mCallService.setIsComposing(accountId, conversationUri.getUri(), isComposing); - } - - public Completable sendFile(Conversation conversation, Uri to, File file) { - if (file == null || !file.exists() || !file.canRead()) { - Log.w(TAG, "sendFile: file not found or not readable: " + file); - return null; - } - - if (conversation.isSwarm()) { - File destPath = mDeviceRuntimeService.getNewConversationPath(conversation.getAccountId(), conversation.getUri().getRawRingId(), file.getName()); - FileUtils.moveFile(file, destPath); - mAccountService.sendFile(conversation, destPath); - return Completable.complete(); - } - - return Single.fromCallable(() -> { - DataTransfer transfer = new DataTransfer(conversation, to.getRawRingId(), conversation.getAccountId(), file.getName(), true, file.length(), 0, null); - mHistoryService.insertInteraction(conversation.getAccountId(), conversation, transfer).blockingAwait(); - - transfer.destination = mDeviceRuntimeService.getConversationDir(conversation.getUri().getRawRingId()); - return transfer; - }) - .flatMap(t -> mAccountService.sendFile(file, t)) - .flatMapCompletable(transfer -> Completable.fromAction(() -> { - File destination = new File(transfer.destination, transfer.getStoragePath()); - if (!mDeviceRuntimeService.hardLinkOrCopy(file, destination)) { - Log.e(TAG, "sendFile: can't move file to " + destination); - } - })) - .subscribeOn(Schedulers.io()); - } - - public void deleteConversationItem(Conversation conversation, Interaction element) { - if (element.getType() == Interaction.InteractionType.DATA_TRANSFER) { - DataTransfer transfer = (DataTransfer) element; - if (transfer.getStatus() == Interaction.InteractionStatus.TRANSFER_ONGOING) { - mAccountService.cancelDataTransfer(conversation.getAccountId(), conversation.getUri().getRawRingId(), transfer.getMessageId(), transfer.getFileId()); - } else { - File file = mDeviceRuntimeService.getConversationPath(conversation.getUri().getRawRingId(), transfer.getStoragePath()); - mDisposableBag.add(Completable.mergeArrayDelayError( - mHistoryService.deleteInteraction(element.getId(), element.getAccount()), - Completable.fromAction(file::delete) - .subscribeOn(Schedulers.io())) - .subscribe(() -> conversation.removeInteraction(transfer), - e -> Log.e(TAG, "Can't delete file transfer", e))); - } - } else { - // handling is the same for calls and texts - mDisposableBag.add(mHistoryService.deleteInteraction(element.getId(), element.getAccount()).subscribeOn(Schedulers.io()) - .subscribe(() -> conversation.removeInteraction(element), - e -> Log.e(TAG, "Can't delete message", e))); - } - } - - public void cancelMessage(Interaction message) { - mDisposableBag.add(Completable.mergeArrayDelayError( - mCallService.cancelMessage(message.getAccount(), message.getId()).subscribeOn(Schedulers.io())) - .andThen(startConversation(message.getAccount(), Uri.fromString(message.getConversation().getParticipant()))) - .subscribe(c -> c.removeInteraction(message), - e -> Log.e(TAG, "Can't cancel message sending", e))); - } - - /** - * Loads the smartlist from cache or database - * - * @param account the user account - * @return an account single - */ - private Single<Account> loadSmartlist(final Account account) { - synchronized (account) { - if (account.historyLoader == null) { - Log.d(TAG, "loadSmartlist(): start loading"); - account.historyLoader = getSmartlist(account); - } - return account.historyLoader; - } - } - - /** - * Loads history for a specific conversation from cache or database - * - * @param account the user account - * @param conversationUri the conversation - * @return a conversation single - */ - public Single<Conversation> loadConversationHistory(final Account account, final Uri conversationUri) { - Conversation conversation = account.getByUri(conversationUri); - if (conversation == null) - return Single.error(new RuntimeException("Can't get conversation")); - synchronized (conversation) { - if (!conversation.isSwarm() && conversation.getId() == null) { - return Single.just(conversation); - } - Single<Conversation> ret = conversation.getLoaded(); - if (ret == null) { - ret = conversation.isSwarm() ? mAccountService.loadMore(conversation) : getConversationHistory(conversation); - conversation.setLoaded(ret); - } - return ret; - } - } - - private Observable<SmartListViewModel> observeConversation(Account account, Conversation conversation, boolean hasPresence) { - return Observable.merge(account.getConversationSubject() - .filter(c -> c == conversation) - .startWithItem(conversation), - mContactService - .observeContact(conversation.getAccountId(), conversation.getContacts(), hasPresence)) - .map(e -> new SmartListViewModel(conversation, hasPresence)); - /*return account.getConversationSubject() - .filter(c -> c == conversation) - .startWith(conversation) - .switchMap(c -> mContactService - .observeContact(c.getAccountId(), c.getContacts(), hasPresence) - .map(contact -> new SmartListViewModel(c, hasPresence)));*/ - } - - public Observable<List<Observable<SmartListViewModel>>> getSmartList(Observable<Account> currentAccount, boolean hasPresence) { - return currentAccount.switchMap(account -> account.getConversationsSubject() - .switchMapSingle(conversations -> Observable.fromIterable(conversations) - .map(conversation -> observeConversation(account, conversation, hasPresence)) - .toList())); - } - - public Observable<List<SmartListViewModel>> getContactList(Observable<Account> currentAccount) { - return currentAccount.switchMap(account -> account.getConversationsSubject() - .switchMapSingle(conversations -> Observable.fromIterable(conversations) - .filter(conversation -> !conversation.isSwarm()) - .map(conversation -> new SmartListViewModel(conversation, false)) - .toList())); - } - - public Observable<List<Observable<SmartListViewModel>>> getPendingList(Observable<Account> currentAccount) { - return currentAccount.switchMap(account -> account.getPendingSubject() - .switchMapSingle(conversations -> Observable.fromIterable(conversations) - .map(conversation -> observeConversation(account, conversation, false)) - .toList())); - } - - public Observable<List<Observable<SmartListViewModel>>> getSmartList(boolean hasPresence) { - return getSmartList(mAccountService.getCurrentAccountSubject(), hasPresence); - } - - public Observable<List<Observable<SmartListViewModel>>> getPendingList() { - return getPendingList(mAccountService.getCurrentAccountSubject()); - } - - public Observable<List<SmartListViewModel>> getContactList() { - return getContactList(mAccountService.getCurrentAccountSubject()); - } - - private Single<List<Observable<SmartListViewModel>>> getSearchResults(Account account, String query) { - Uri uri = Uri.fromString(query); - if (account.isSip()) { - Contact contact = account.getContactFromCache(uri); - return mContactService.loadContactData(contact, account.getAccountID()) - .andThen(Single.just(Collections.singletonList(Observable.just(new SmartListViewModel(account.getAccountID(), contact, contact.getPrimaryNumber(), null))))); - } else if (uri.isHexId()) { - return mContactService.getLoadedContact(account.getAccountID(), account.getContactFromCache(uri)) - .map(contact -> Collections.singletonList(Observable.just(new SmartListViewModel(account.getAccountID(), contact, contact.getPrimaryNumber(), null)))); - } else if (account.canSearch() && !query.contains("@")) { - return mAccountService.searchUser(account.getAccountID(), query) - .map(AccountService.UserSearchResult::getResultsViewModels); - } else { - return mAccountService.findRegistrationByName(account.getAccountID(), "", query) - .map(result -> result.state == 0 ? Collections.singletonList(observeConversation(account, account.getByUri(result.address), false)) : Collections.emptyList()); - } - } - - private Observable<List<Observable<SmartListViewModel>>> getSearchResults(Account account, Observable<String> query) { - return query.switchMapSingle(q -> q.isEmpty() - ? SmartListViewModel.EMPTY_LIST - : getSearchResults(account, q)) - .distinctUntilChanged(); - } - - public Observable<List<Observable<SmartListViewModel>>> getFullList(Observable<Account> currentAccount, Observable<String> query, boolean hasPresence) { - return currentAccount.switchMap(account -> Observable.combineLatest( - account.getConversationsSubject(), - getSearchResults(account, query), - query, - (conversations, searchResults, q) -> { - List<Observable<SmartListViewModel>> newList = new ArrayList<>(conversations.size() + searchResults.size() + 2); - if (!searchResults.isEmpty()) { - newList.add(SmartListViewModel.TITLE_PUBLIC_DIR); - newList.addAll(searchResults); - } - if (!conversations.isEmpty()) { - if (q.isEmpty()) { - for (Conversation conversation : conversations) - newList.add(observeConversation(account, conversation, hasPresence)); - } else { - String lq = q.toLowerCase(); - newList.add(SmartListViewModel.TITLE_CONVERSATIONS); - int nRes = 0; - for (Conversation conversation : conversations) { - if (conversation.matches(lq)) { - newList.add(observeConversation(account, conversation, hasPresence)); - nRes++; - } - } - if (nRes == 0) - newList.remove(newList.size() - 1); - } - } - return newList; - })); - } - - /** - * Loads the smartlist from the database and updates the view - * - * @param account the user account - */ - private Single<Account> getSmartlist(final Account account) { - List<Completable> actions = new ArrayList<>(account.getConversations().size() + 1); - for (Conversation c : account.getConversations()) { - if (c.isSwarm()) - actions.add(c.getLastElementLoaded()); - } - actions.add(mHistoryService.getSmartlist(account.getAccountID()) - .flatMapCompletable(conversationHistoryList -> Completable.fromAction(() -> { - List<Conversation> conversations = new ArrayList<>(); - for (Interaction e : conversationHistoryList) { - Conversation conversation = account.getByUri(e.getConversation().getParticipant()); - if (conversation == null) - continue; - conversation.setId(e.getConversation().getId()); - conversation.addElement(e); - conversations.add(conversation); - } - account.setHistoryLoaded(conversations); - }))); - return Completable.merge(actions) - .andThen(Single.just(account)) - .cache(); - } - - /** - * Loads a conversation's history from the database - * - * @param conversation a conversation object with a valid conversation ID - * @return a conversation single - */ - private Single<Conversation> getConversationHistory(final Conversation conversation) { - Log.d(TAG, "getConversationHistory() " + conversation.getAccountId() + " " + conversation.getUri()); - - return mHistoryService.getConversationHistory(conversation.getAccountId(), conversation.getId()) - .map(loadedConversation -> { - /*if (loadedConversation.isEmpty()) - return conversation;*/ - conversation.clearHistory(true); - conversation.setHistory(loadedConversation); - return conversation; - }) - .cache(); - } - - public Completable clearHistory(final String accountId, final Uri contact) { - return mHistoryService - .clearHistory(contact.getUri(), accountId, false) - .doOnSubscribe(s -> { - Account account = mAccountService.getAccount(accountId); - if (account != null) { - account.clearHistory(contact, false); - } - }); - } - - public Completable clearAllHistory() { - List<Account> accounts = mAccountService.getAccounts(); - return mHistoryService - .clearHistory(accounts) - .doOnSubscribe(s -> { - for (Account account : accounts) { - if (account != null) { - account.clearAllHistory(); - } - } - }); - } - - public void updateTextNotifications(String accountId, List<Conversation> conversations) { - Log.d(TAG, "updateTextNotifications() " + accountId + " " + conversations.size()); - - for (Conversation conversation : conversations) { - mNotificationService.showTextNotification(accountId, conversation); - } - } - - private void parseNewMessage(final TextMessage txt) { - if (txt.isRead()) { - if (txt.getMessageId() == null) { - mHistoryService.updateInteraction(txt, txt.getAccount()).subscribe(); - } - if (mPreferencesService.getSettings().isAllowReadIndicator()) { - if (txt.getMessageId() != null) { - mAccountService.setMessageDisplayed(txt.getAccount(), new Uri(Uri.SWARM_SCHEME, txt.getConversationId()), txt.getMessageId()); - } else { - mAccountService.setMessageDisplayed(txt.getAccount(), new Uri(Uri.JAMI_URI_SCHEME, txt.getAuthor()), txt.getDaemonIdString()); - } - } - } - getAccountSubject(txt.getAccount()) - .flatMapObservable(Account::getConversationsSubject) - .firstOrError() - .subscribeOn(Schedulers.io()) - .subscribe(c -> updateTextNotifications(txt.getAccount(), c), e -> Log.e(TAG, e.getMessage())); - } - - public void acceptRequest(String accountId, Uri contactUri) { - if (accountId == null || contactUri == null) - return; - mPreferencesService.removeRequestPreferences(accountId, contactUri.getRawRingId()); - mAccountService.acceptTrustRequest(accountId, contactUri); - } - - public void discardRequest(String accountId, Uri contact) { - mHistoryService.clearHistory(contact.getUri(), accountId, true).subscribe(); - mPreferencesService.removeRequestPreferences(accountId, contact.getRawRingId()); - mAccountService.discardTrustRequest(accountId, contact); - } - - private void handleDataTransferEvent(DataTransfer transfer) { - Conversation conversation = mAccountService.getAccount(transfer.getAccount()).onDataTransferEvent(transfer); - Interaction.InteractionStatus status = transfer.getStatus(); - if ((status == Interaction.InteractionStatus.TRANSFER_CREATED || status == Interaction.InteractionStatus.FILE_AVAILABLE) && !transfer.isOutgoing()) { - if (transfer.canAutoAccept(mPreferencesService.getMaxFileAutoAccept(transfer.getAccount()))) { - mAccountService.acceptFileTransfer(conversation, transfer.getFileId(), transfer); - return; - } - } - mNotificationService.handleDataTransferNotification(transfer, conversation, conversation.isVisible()); - } - - private void onConfStateChange(Conference conference) { - Log.d(TAG, "onConfStateChange Thread id: " + Thread.currentThread().getId()); - } - - private void onCallStateChange(Call call) { - Log.d(TAG, "onCallStateChange Thread id: " + Thread.currentThread().getId()); - Call.CallStatus newState = call.getCallStatus(); - boolean incomingCall = newState == Call.CallStatus.RINGING && call.isIncoming(); - mHardwareService.updateAudioState(newState, incomingCall, !call.isAudioOnly()); - - Account account = mAccountService.getAccount(call.getAccount()); - if (account == null) - return; - Contact contact = call.getContact(); - String conversationId = call.getConversationId(); - Log.w(TAG, "CallStateChange " + call.getDaemonIdString() + " conversationId:" + conversationId); - - Conversation conversation = conversationId == null - ? (contact == null ? null : account.getByUri(contact.getUri())) - : account.getSwarm(conversationId); - Conference conference = null; - if (conversation != null) { - conference = conversation.getConference(call.getDaemonIdString()); - if (conference == null) { - if (newState == Call.CallStatus.OVER) - return; - conference = new Conference(call); - conversation.addConference(conference); - account.updated(conversation); - } - } - - Log.w(TAG, "CALL_STATE_CHANGED : updating call state to " + newState); - if ((call.isRinging() || newState == Call.CallStatus.CURRENT) && call.getTimestamp() == 0) { - call.setTimestamp(System.currentTimeMillis()); - } - - if (incomingCall) { - mNotificationService.handleCallNotification(conference, false); - mHardwareService.setPreviewSettings(); - } else if ((newState == Call.CallStatus.CURRENT && call.isIncoming()) - || newState == Call.CallStatus.RINGING && !call.isIncoming()) { - mNotificationService.handleCallNotification(conference, false); - mAccountService.sendProfile(call.getDaemonIdString(), call.getAccount()); - } else if (newState == Call.CallStatus.HUNGUP - || newState == Call.CallStatus.BUSY - || newState == Call.CallStatus.FAILURE - || newState == Call.CallStatus.OVER) { - mNotificationService.handleCallNotification(conference, true); - mHardwareService.closeAudioState(); - long now = System.currentTimeMillis(); - if (call.getTimestamp() == 0) { - call.setTimestamp(now); - } - if (newState == Call.CallStatus.HUNGUP || call.getTimestampEnd() == 0) { - call.setTimestampEnd(now); - } - if (conference != null && conference.removeParticipant(call) && !conversation.isSwarm()) { - Log.w(TAG, "Adding call history for conversation " + conversation.getUri()); - mHistoryService.insertInteraction(account.getAccountID(), conversation, call).subscribe(); - conversation.addCall(call); - if (call.isIncoming() && call.isMissed()) { - mNotificationService.showMissedCallNotification(call); - } - account.updated(conversation); - } - mCallService.removeCallForId(call.getDaemonIdString()); - if (conversation != null && conference.getParticipants().isEmpty()) { - conversation.removeConference(conference); - } - } - } - - public Single<Call> placeCall(String accountId, Uri contactUri, boolean video) { - //String rawId = contactUri.getRawRingId(); - return getAccountSubject(accountId).flatMap(account -> { - //CallContact contact = account.getContact(rawId); - //if (contact == null) - // mAccountService.addContact(accountId, rawId); - return mCallService.placeCall(accountId, null, contactUri, video); - }); - } - - public void cancelFileTransfer(String accountId, Uri conversationId, String messageId, String fileId) { - mAccountService.cancelDataTransfer(accountId, conversationId.isSwarm() ? conversationId.getRawRingId() : "", messageId, fileId); - mNotificationService.removeTransferNotification(accountId, conversationId, fileId); - DataTransfer transfer = mAccountService.getAccount(accountId).getDataTransfer(fileId); - if (transfer != null) - deleteConversationItem((Conversation) transfer.getConversation(), transfer); - } - - public Completable removeConversation(String accountId, Uri conversationUri) { - if (conversationUri.isSwarm()) { - // For a one to one conversation, contact is strongly related, so remove the contact. - // This will remove related conversations - Account account = mAccountService.getAccount(accountId); - Conversation conversation = account.getSwarm(conversationUri.getRawRingId()); - if (conversation != null && conversation.getMode().blockingFirst() == Conversation.Mode.OneToOne) { - Contact contact = conversation.getContact(); - mAccountService.removeContact(accountId, contact.getUri().getRawRingId(), false); - return Completable.complete(); - } else { - return mAccountService.removeConversation(accountId, conversationUri); - } - } else { - return mHistoryService - .clearHistory(conversationUri.getUri(), accountId, true) - .doOnSubscribe(s -> { - Account account = mAccountService.getAccount(accountId); - account.clearHistory(conversationUri, true); - mAccountService.removeContact(accountId, conversationUri.getRawRingId(), false); - }); - } - } - - public void banConversation(String accountId, Uri conversationUri) { - if (conversationUri.isSwarm()) { - startConversation(accountId, conversationUri) - .subscribe(conversation -> { - try { - Contact contact = conversation.getContact(); - mAccountService.removeContact(accountId, contact.getUri().getRawUriString(), true); - } catch (Exception e) { - mAccountService.removeConversation(accountId, conversationUri); - } - }); - //return mAccountService.removeConversation(accountId, conversationUri); - } else { - mAccountService.removeContact(accountId, conversationUri.getRawUriString(), true); - } - } - - - public Single<Conversation> createConversation(String accountId, Collection<Contact> currentSelection) { - List<String> contactIds = new ArrayList<>(currentSelection.size()); - for (Contact contact : currentSelection) - contactIds.add(contact.getPrimaryNumber()); - return mAccountService.startConversation(accountId, contactIds); - } -} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Account.java b/ring-android/libringclient/src/main/java/net/jami/model/Account.java deleted file mode 100644 index c41dfaf8a..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/Account.java +++ /dev/null @@ -1,1124 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package net.jami.model; - -import net.jami.services.AccountService; -import net.jami.smartlist.SmartListViewModel; -import net.jami.utils.Log; -import net.jami.utils.StringUtils; -import net.jami.utils.Tuple; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.Date; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; - -import ezvcard.VCard; -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.PublishSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public class Account { - private static final String TAG = Account.class.getSimpleName(); - - private static final String CONTACT_ADDED = "added"; - private static final String CONTACT_CONFIRMED = "confirmed"; - private static final String CONTACT_BANNED = "banned"; - private static final String CONTACT_ID = "id"; - private static final int LOCATION_SHARING_EXPIRATION_MS = 1000 * 60 * 2; - - private final String accountID; - - private AccountConfig mVolatileDetails; - private AccountConfig mDetails; - private String mUsername; - - private final ArrayList<AccountCredentials> credentialsDetails = new ArrayList<>(); - private Map<String, String> devices = new HashMap<>(); - private final Map<String, Contact> mContacts = new HashMap<>(); - private final Map<String, TrustRequest> mRequests = new HashMap<>(); - private final Map<String, Contact> mContactCache = new HashMap<>(); - private final Map<String, Conversation> swarmConversations = new HashMap<>(); - private final HashMap<String, DataTransfer> mDataTransfers = new HashMap<>(); - - private final Map<String, Conversation> conversations = new HashMap<>(); - private final Map<String, Conversation> pending = new HashMap<>(); - private final Map<String, Conversation> cache = new HashMap<>(); - - private final List<Conversation> sortedConversations = new ArrayList<>(); - private final List<Conversation> sortedPending = new ArrayList<>(); - - public boolean registeringUsername = false; - private boolean conversationsChanged = true; - private boolean pendingsChanged = true; - private boolean historyLoaded = false; - - private final Subject<Conversation> conversationSubject = PublishSubject.create(); - private final Subject<List<Conversation>> conversationsSubject = BehaviorSubject.create(); - private final Subject<List<Conversation>> pendingSubject = BehaviorSubject.create(); - private final Subject<Integer> unreadConversationsSubject = BehaviorSubject.create(); - private final Subject<Integer> unreadPendingSubject = BehaviorSubject.create(); - private final Observable<Integer> unreadConversationsCount = unreadConversationsSubject.distinctUntilChanged(); - private final Observable<Integer> unreadPendingCount = unreadPendingSubject.distinctUntilChanged(); - - private final BehaviorSubject<Collection<Contact>> contactListSubject = BehaviorSubject.create(); - - private final Map<Contact, Observable<ContactLocation>> contactLocations = new HashMap<>(); - private final Subject<Map<Contact, Observable<ContactLocation>>> mLocationSubject = BehaviorSubject.createDefault(contactLocations); - private final Subject<ContactLocationEntry> mLocationStartedSubject = PublishSubject.create(); - - public Single<Account> historyLoader; - private Single<Tuple<String, Object>> mLoadedProfile = null; - - public Account(String bAccountID) { - accountID = bAccountID; - mDetails = new AccountConfig(); - mVolatileDetails = new AccountConfig(); - } - - public Account(String bAccountID, final Map<String, String> details, - final List<Map<String, String>> credentials, - final Map<String, String> volDetails) { - accountID = bAccountID; - setDetails(details); - mVolatileDetails = new AccountConfig(volDetails); - setCredentials(credentials); - } - - public void cleanup() { - conversationSubject.onComplete(); - conversationsSubject.onComplete(); - pendingSubject.onComplete(); - contactListSubject.onComplete(); - //trustRequestsSubject.onComplete(); - } - - public boolean canSearch() { - return !StringUtils.isEmpty(getDetail(ConfigKey.MANAGER_URI)); - } - - public boolean isContact(Conversation conversation) { - Contact contact = conversation.getContact(); - return contact != null && getContact(contact.getUri().getRawRingId()) != null; - } - - public void conversationStarted(Conversation conversation) { - Log.w(TAG, "conversationStarted " + conversation.getAccountId() + " " + conversation.getUri() + " " + conversation.isSwarm() + " " + conversation.getContacts().size()); - synchronized (conversations) { - if (conversation.isSwarm() && conversation.getMode() == Conversation.Mode.OneToOne) { - Contact contact = conversation.getContact(); - String key = contact.getUri().getUri(); - Conversation removed = cache.remove(key); - conversations.remove(key); - //Conversation contactConversation = getByUri(contact.getPrimaryUri()); - //Log.w(TAG, "conversationStarted " + conversation.getAccountId() + " contact " + key + " " + removed); - /*if (contactConversation != null) { - conversations.remove(contactConversation.getUri().getUri()); - }*/ - contact.setConversationUri(conversation.getUri()); - } - conversations.put(conversation.getUri().getUri(), conversation); - conversationChanged(); - } - } - public Conversation getSwarm(String conversationId) { - synchronized (conversations) { - return swarmConversations.get(conversationId); - } - } - - public Conversation newSwarm(String conversationId, Conversation.Mode mode) { - synchronized (conversations) { - Conversation c = swarmConversations.get(conversationId); - if (c == null) { - c = new Conversation(accountID, new Uri(Uri.SWARM_SCHEME, conversationId), mode); - swarmConversations.put(conversationId, c); - } - return c; - } - } - - public void removeSwarm(String conversationId) { - Log.w(TAG, "removeSwarm " + conversationId); - synchronized (conversations) { - Conversation conversation = swarmConversations.remove(conversationId); - if (conversation != null) { - Conversation c = conversations.remove(conversation.getUri().getUri()); - try { - Contact contact = c.getContact(); - Log.w(TAG, "removeSwarm: adding back contact conversation " + contact + " " + contact.getConversationUri().blockingFirst() + " " + c.getUri()); - if (contact.getConversationUri().blockingFirst().equals(c.getUri())) { - contact.setConversationUri(contact.getUri()); - contactAdded(contact); - } - } catch (Exception ignored) {} - conversationChanged(); - } - } - } - - public static class ContactLocation { - public double latitude; - public double longitude; - public long timestamp; - public Date receivedDate; - } - public static class ContactLocationEntry { - public Contact contact; - public Observable<ContactLocation> location; - } - public enum ComposingStatus { - Idle, - Active; - - public static ComposingStatus fromInt(int status) { - return status == 1 ? Active : Idle; - } - } - - public Observable<List<Conversation>> getConversationsSubject() { - return conversationsSubject; - } - - public Observable<List<SmartListViewModel>> getConversationsViewModels(boolean withPresence) { - return conversationsSubject - .map(conversations -> { - ArrayList<SmartListViewModel> viewModel = new ArrayList<>(conversations.size()); - for (Conversation c : conversations) - viewModel.add(new SmartListViewModel(c, withPresence)); - return viewModel; - }); - } - - public Observable<Conversation> getConversationSubject() { - return conversationSubject; - } - - public Observable<List<Conversation>> getPendingSubject() { - return pendingSubject; - } - - public Collection<Conversation> getConversations() { - return conversations.values(); - } - - public Collection<Conversation> getPending() { - return pending.values(); - } - - public Observable<Integer> getUnreadConversations() { - return unreadConversationsCount; - } - - public Observable<Integer> getUnreadPending() { - return unreadPendingCount; - } - - private void pendingRefreshed() { - if (historyLoaded) { - pendingSubject.onNext(getSortedPending()); - updateUnreadPending(); - } - } - - private void pendingChanged() { - pendingsChanged = true; - pendingRefreshed(); - } - - private void pendingUpdated(Conversation conversation) { - if (!historyLoaded) - return; - if (pendingsChanged) { - getSortedPending(); - } else { - if (conversation != null) - conversation.sortHistory(); - Collections.sort(sortedPending, (a, b) -> Interaction.compare(b.getLastEvent(), a.getLastEvent())); - } - pendingSubject.onNext(getSortedPending()); - } - - private void conversationRefreshed(Conversation conversation) { - if (historyLoaded) { - conversationSubject.onNext(conversation); - updateUnreadConversations(); - } - } - - public void conversationChanged() { - synchronized (conversations) { - conversationsChanged = true; - if (historyLoaded) { - conversationsSubject.onNext(new ArrayList<>(getSortedConversations())); - } - updateUnreadConversations(); - } - } - - public void conversationUpdated(Conversation conversation) { - synchronized (conversations) { - if (!historyLoaded) - return; - if (conversationsChanged) { - getSortedConversations(); - } else { - if (conversation != null) - conversation.sortHistory(); - Collections.sort(sortedConversations, (a, b) -> Interaction.compare(b.getLastEvent(), a.getLastEvent())); - } - conversationsSubject.onNext(new ArrayList<>(sortedConversations)); - updateUnreadConversations(); - } - } - - private void updateUnreadConversations() { - int unread = 0; - for (Conversation model : sortedConversations) { - Interaction last = model.getLastEvent(); - if (last != null && !last.isRead()) - unread++; - } - // Log.w(TAG, "updateUnreadConversations " + unread); - unreadConversationsSubject.onNext(unread); - } - - private void updateUnreadPending() { - unreadPendingSubject.onNext(sortedPending.size()); - } - - /** - * Clears a conversation - * - * @param contact the contact - * @param delete true if you want to remove the conversation - */ - public void clearHistory(Uri contact, boolean delete) { - Conversation conversation = getByUri(contact); - // if it is a sip account, we do not add a contact event - conversation.clearHistory(delete || isSip()); - conversationChanged(); - } - - public void clearAllHistory() { - for (Conversation conversation : getConversations()) { - // if it is a sip account, we do not add a contact event - conversation.clearHistory(isSip()); - } - for (Conversation conversation : pending.values()) { - conversation.clearHistory(true); - } - conversationChanged(); - pendingChanged(); - } - - public void updated(Conversation conversation) { - String key = conversation.getUri().getUri(); - synchronized (conversations) { - if (conversation == conversations.get(key)) { - conversationUpdated(conversation); - return; - } - } - synchronized (pending) { - if (conversation == pending.get(key)) { - pendingUpdated(conversation); - return; - } - } - if (conversation == cache.get(key)) { - if (isJami() && !conversation.isSwarm() && conversation.getContacts().size() == 1 && !conversation.getContact().getConversationUri().blockingFirst().equals(conversation.getUri())) { - return; - } - if (mContacts.containsKey(key) || !isJami()) { - Log.w(TAG, "updated " + conversation.getAccountId() + " contact " + key); - conversations.put(key, conversation); - conversationChanged(); - } else { - pending.put(key, conversation); - pendingChanged(); - } - } - } - - public void refreshed(Conversation conversation) { - synchronized (conversations) { - if (conversations.containsValue(conversation)) { - conversationRefreshed(conversation); - return; - } - } - synchronized (pending) { - if (pending.containsValue(conversation)) - pendingRefreshed(); - } - } - - public void addTextMessage(TextMessage txt) { - Conversation conversation = null; - String daemonId = txt.getDaemonIdString(); - if (daemonId != null && !StringUtils.isEmpty(daemonId)) { - conversation = getConversationByCallId(daemonId); - } - if (conversation == null) { - conversation = getByKey(txt.getConversation().getParticipant()); - txt.setContact(conversation.getContact()); - } - conversation.addTextMessage(txt); - updated(conversation); - } - - public Conversation onDataTransferEvent(DataTransfer transfer) { - Log.d(TAG, "Account onDataTransferEvent " + transfer.getMessageId()); - Conversation conversation = (Conversation) transfer.getConversation(); - Interaction.InteractionStatus transferEventCode = transfer.getStatus(); - if (transferEventCode == Interaction.InteractionStatus.TRANSFER_CREATED) { - conversation.addFileTransfer(transfer); - } else { - conversation.updateFileTransfer(transfer, transferEventCode); - } - updated(conversation); - return conversation; - } - - public Observable<Collection<Contact>> getBannedContactsUpdates() { - return contactListSubject.concatMapSingle(list -> Observable.fromIterable(list).filter(Contact::isBanned).toList()); - } - - public Contact getContactFromCache(String key) { - if (StringUtils.isEmpty(key)) - return null; - synchronized (mContactCache) { - Contact contact = mContactCache.get(key); - if (contact == null) { - if (isSip()) - contact = Contact.buildSIP(Uri.fromString(key)); - else - contact = Contact.build(key, isMe(key)); - mContactCache.put(key, contact); - } - return contact; - } - } - - boolean isMe(String uri) { - //Log.w(TAG, "isMe " + uri + " " + getUsername()); - return getUsername().equals(uri); - } - - public Contact getContactFromCache(Uri uri) { - return getContactFromCache(uri.getUri()); - } - - public void dispose() { - contactListSubject.onComplete(); - //trustRequestsSubject.onComplete(); - } - - public Map<String, String> getDevices() { - return devices; - } - - public void setCredentials(List<Map<String, String>> credentials) { - credentialsDetails.clear(); - if (credentials != null) { - credentialsDetails.ensureCapacity(credentials.size()); - for (int i = 0; i < credentials.size(); ++i) { - credentialsDetails.add(new AccountCredentials(credentials.get(i))); - } - } - } - - public void setDetails(Map<String, String> details) { - mDetails = new AccountConfig(details); - mUsername = mDetails.get(ConfigKey.ACCOUNT_USERNAME); - } - - public void setDetail(ConfigKey key, String val) { - mDetails.put(key, val); - } - - public void setDetail(ConfigKey key, boolean val) { - mDetails.put(key, val); - } - - public AccountConfig getConfig() { - return mDetails; - } - - public void setDevices(Map<String, String> devs) { - devices = devs; - } - - public String getAccountID() { - return accountID; - } - - public String getUsername() { - return mUsername; - } - - public String getDisplayname() { - return mDetails.get(ConfigKey.ACCOUNT_DISPLAYNAME); - } - - public String getDisplayUsername() { - if (isJami()) { - String registeredName = getRegisteredName(); - if (registeredName != null && !registeredName.isEmpty()) { - return registeredName; - } - } - return getUsername(); - } - - public String getHost() { - return mDetails.get(ConfigKey.ACCOUNT_HOSTNAME); - } - - public void setHost(String host) { - mDetails.put(ConfigKey.ACCOUNT_HOSTNAME, host); - } - - public String getProxy() { - return mDetails.get(ConfigKey.ACCOUNT_ROUTESET); - } - - public void setProxy(String proxy) { - mDetails.put(ConfigKey.ACCOUNT_ROUTESET, proxy); - } - - public boolean isDhtProxyEnabled() { - return mDetails.getBool(ConfigKey.PROXY_ENABLED); - } - - public void setDhtProxyEnabled(boolean active) { - mDetails.put(ConfigKey.PROXY_ENABLED, active ? "true" : "false"); - } - - public String getRegistrationState() { - return mVolatileDetails.get(ConfigKey.ACCOUNT_REGISTRATION_STATUS); - } - - public void setRegistrationState(String registeredState, int code) { - mVolatileDetails.put(ConfigKey.ACCOUNT_REGISTRATION_STATUS, registeredState); - mVolatileDetails.put(ConfigKey.ACCOUNT_REGISTRATION_STATE_CODE, Integer.toString(code)); - } - - public void setVolatileDetails(Map<String, String> volatileDetails) { - mVolatileDetails = new AccountConfig(volatileDetails); - } - - public String getRegisteredName() { - return mVolatileDetails.get(ConfigKey.ACCOUNT_REGISTERED_NAME); - } - - public String getAlias() { - return mDetails.get(ConfigKey.ACCOUNT_ALIAS); - } - - public Boolean isSip() { - return mDetails.get(ConfigKey.ACCOUNT_TYPE).equals(AccountConfig.ACCOUNT_TYPE_SIP); - } - - public Boolean isJami() { - return mDetails.get(ConfigKey.ACCOUNT_TYPE).equals(AccountConfig.ACCOUNT_TYPE_RING); - } - - public void setAlias(String alias) { - mDetails.put(ConfigKey.ACCOUNT_ALIAS, alias); - } - - private String getDetail(ConfigKey key) { - return mDetails.get(key); - } - - public boolean getDetailBoolean(ConfigKey key) { - return mDetails.getBool(key); - } - - public boolean isEnabled() { - return mDetails.getBool(ConfigKey.ACCOUNT_ENABLE); - } - - public boolean isActive() { - return mVolatileDetails.getBool(ConfigKey.ACCOUNT_ACTIVE); - } - - public void setEnabled(boolean isChecked) { - mDetails.put(ConfigKey.ACCOUNT_ENABLE, isChecked); - } - - public boolean hasPassword() { - return mDetails.getBool(ConfigKey.ARCHIVE_HAS_PASSWORD); - } - - public boolean hasManager() { - return !mDetails.get(ConfigKey.MANAGER_URI).isEmpty(); - } - - public HashMap<String, String> getDetails() { - return mDetails.getAll(); - } - - public boolean isTrying() { - return getRegistrationState().contentEquals(AccountConfig.STATE_TRYING); - } - - public boolean isRegistered() { - return (getRegistrationState().contentEquals(AccountConfig.STATE_READY) || getRegistrationState().contentEquals(AccountConfig.STATE_REGISTERED)); - } - - public boolean isInError() { - String state = getRegistrationState(); - return (state.contentEquals(AccountConfig.STATE_ERROR) - || state.contentEquals(AccountConfig.STATE_ERROR_AUTH) - || state.contentEquals(AccountConfig.STATE_ERROR_CONF_STUN) - || state.contentEquals(AccountConfig.STATE_ERROR_EXIST_STUN) - || state.contentEquals(AccountConfig.STATE_ERROR_GENERIC) - || state.contentEquals(AccountConfig.STATE_ERROR_HOST) - || state.contentEquals(AccountConfig.STATE_ERROR_NETWORK) - || state.contentEquals(AccountConfig.STATE_ERROR_NOT_ACCEPTABLE) - || state.contentEquals(AccountConfig.STATE_ERROR_SERVICE_UNAVAILABLE) - || state.contentEquals(AccountConfig.STATE_REQUEST_TIMEOUT)); - } - - public boolean isIP2IP() { - boolean emptyHost = getHost() == null || (getHost() != null && getHost().isEmpty()); - return isSip() && emptyHost; - } - - public boolean isAutoanswerEnabled() { - return mDetails.getBool(ConfigKey.ACCOUNT_AUTOANSWER); - } - - public ArrayList<AccountCredentials> getCredentials() { - return credentialsDetails; - } - - public void addCredential(AccountCredentials newValue) { - credentialsDetails.add(newValue); - } - - public void removeCredential(AccountCredentials accountCredentials) { - credentialsDetails.remove(accountCredentials); - } - - public List<Map<String, String>> getCredentialsHashMapList() { - ArrayList<Map<String, String>> result = new ArrayList<>(credentialsDetails.size()); - for (AccountCredentials cred : credentialsDetails) { - result.add(cred.getDetails()); - } - return result; - } - - private String getUri(boolean display) { - String username = display ? getDisplayUsername() : getUsername(); - if (isJami()) { - return username; - } else { - return username + "@" + getHost(); - } - } - - public String getUri() { - return getUri(false); - } - - public String getDisplayUri() { - return getUri(true); - } - public String getDisplayUri(CharSequence defaultNameSip) { - return isIP2IP() ? defaultNameSip.toString() : getDisplayUri(); - } - - public boolean needsMigration() { - return AccountConfig.STATE_NEED_MIGRATION.equals(getRegistrationState()); - } - - public String getDeviceId() { - return getDetail(ConfigKey.ACCOUNT_DEVICE_ID); - } - - public String getDeviceName() { - return getDetail(ConfigKey.ACCOUNT_DEVICE_NAME); - } - - public Map<String, Contact> getContacts() { - return mContacts; - } - - public List<Contact> getBannedContacts() { - ArrayList<Contact> banned = new ArrayList<>(); - for (Contact contact : mContacts.values()) { - if (contact.isBanned()) { - banned.add(contact); - } - } - return banned; - } - - public Contact getContact(String ringId) { - return mContacts.get(ringId); - } - - public void addContact(String id, boolean confirmed) { - Contact contact = mContacts.get(id); - if (contact == null) { - contact = getContactFromCache(Uri.fromId(id)); - mContacts.put(id, contact); - } - contact.setAddedDate(new Date()); - if (confirmed) { - contact.setStatus(Contact.Status.CONFIRMED); - } else { - contact.setStatus(Contact.Status.REQUEST_SENT); - } - TrustRequest req = mRequests.get(id); - if (req != null) { - mRequests.remove(id); - } - contactAdded(contact); - contactListSubject.onNext(mContacts.values()); - } - - public void removeContact(String id, boolean banned) { - Contact contact = mContacts.get(id); - if (banned) { - if (contact == null) { - contact = getContactFromCache(Uri.fromId(id)); - mContacts.put(id, contact); - } - contact.setStatus(Contact.Status.BANNED); - } else { - mContacts.remove(id); - } - TrustRequest req = mRequests.get(id); - if (req != null) { - mRequests.remove(id); - } - if (contact != null) { - contactRemoved(contact.getUri()); - } - contactListSubject.onNext(mContacts.values()); - } - - private void addContact(Map<String, String> contact) { - String contactId = contact.get(CONTACT_ID); - Contact callContact = mContacts.get(contactId); - if (callContact == null) { - callContact = getContactFromCache(Uri.fromId(contactId)); - } - String addedStr = contact.get(CONTACT_ADDED); - if (!StringUtils.isEmpty(addedStr)) { - long added = Long.parseLong(contact.get(CONTACT_ADDED)); - callContact.setAddedDate(new Date(added * 1000)); - } - if (contact.containsKey(CONTACT_BANNED) && contact.get(CONTACT_BANNED).equals("true")) { - callContact.setStatus(Contact.Status.BANNED); - } else if (contact.containsKey(CONTACT_CONFIRMED)) { - callContact.setStatus(Boolean.parseBoolean(contact.get(CONTACT_CONFIRMED)) ? - Contact.Status.CONFIRMED : - Contact.Status.REQUEST_SENT); - } - mContacts.put(contactId, callContact); - contactAdded(callContact); - } - - public void setContacts(List<Map<String, String>> contacts) { - for (Map<String, String> contact : contacts) { - addContact(contact); - } - contactListSubject.onNext(mContacts.values()); - } - - public List<TrustRequest> getRequests() { - ArrayList<TrustRequest> requests = new ArrayList<>(mRequests.size()); - for (TrustRequest request : mRequests.values()) { - if (request.isNameResolved()) { - requests.add(request); - } - } - return requests; - } - - public TrustRequest getRequest(Uri uri) { - return mRequests.get(uri.getUri()); - } - - public void addRequest(TrustRequest request) { - synchronized (pending) { - String key = request.getUri().getUri(); - mRequests.put(key, request); - Conversation conversation = pending.get(key); - if (conversation == null) { - conversation = getByKey(key); - pending.put(key, conversation); - if (!conversation.isSwarm()) { - Contact contact = getContactFromCache(request.getUri()); - conversation.addRequestEvent(request, contact); - } - pendingChanged(); - } - } - } - - public void setRequests(List<TrustRequest> requests) { - Log.w(TAG, "setRequests " + requests.size()); - synchronized (pending) { - for (TrustRequest request : requests) { - String key = request.getUri().getUri(); - mRequests.put(key, request); - Conversation conversation = pending.get(key); - if (conversation == null) { - conversation = getByKey(key); - pending.put(key, conversation); - Contact contact = getContactFromCache(request.getUri()); - conversation.addRequestEvent(request, contact); - } - } - pendingChanged(); - } - } - - public boolean removeRequest(Uri contact) { - synchronized (pending) { - String contactUri = contact.getUri(); - TrustRequest request = mRequests.remove(contactUri); - if (pending.remove(contactUri) != null) { - pendingChanged(); - return true; - } - } - return false; - } - - public boolean registeredNameFound(int state, String address, String name) { - Uri uri = Uri.fromString(address); - String key = uri.getUri(); - Contact contact = getContactFromCache(key); - if (contact.setUsername(state == 0 ? name : null)) { - synchronized (conversations) { - Conversation conversation = conversations.get(key); - if (conversation != null) - conversationRefreshed(conversation); - } - synchronized (pending) { - if (pending.containsKey(key)) - pendingRefreshed(); - } - return true; - } - return false; - } - - public Conversation getByUri(Uri uri) { - //Log.w(TAG, "getByUri " + getAccountID() + " " + uri); - if (uri == null || uri.isEmpty()) - return null; - return uri.isSwarm() - ? getSwarm(uri.getRawRingId()) - : getByKey(uri.getUri()); - } - - public Conversation getByUri(String uri) { - return getByUri(Uri.fromString(uri)); - } - - private Conversation getByKey(String key) { - Conversation conversation = cache.get(key); - if (conversation != null) { - return conversation; - } - Contact contact = getContactFromCache(key); - conversation = new Conversation(getAccountID(), contact); - //Log.w(TAG, "getByKey " + getAccountID() + " contact " + key); - cache.put(key, conversation); - return conversation; - } - - public void setHistoryLoaded(List<Conversation> conversations) { - synchronized (this.conversations) { - if (historyLoaded) - return; - //Log.w(TAG, "setHistoryLoaded " + getAccountID() + " " + conversations.size()); - for (Conversation c : conversations) { - Contact contact = c.getContact(); - if (!c.isSwarm() && contact != null && contact.getConversationUri().blockingFirst().equals(c.getUri())) - updated(c); - } - historyLoaded = true; - conversationChanged(); - pendingChanged(); - } - } - - private List<Conversation> getSortedConversations() { - if (conversationsChanged) { - sortedConversations.clear(); - sortedConversations.addAll(conversations.values()); - for (Conversation c : sortedConversations) - c.sortHistory(); - Collections.sort(sortedConversations, new ConversationComparator()); - conversationsChanged = false; - } - return sortedConversations; - } - - private List<Conversation> getSortedPending() { - if (pendingsChanged) { - sortedPending.clear(); - sortedPending.addAll(pending.values()); - for (Conversation c : sortedPending) - c.sortHistory(); - Collections.sort(sortedPending, new ConversationComparator()); - pendingsChanged = false; - } - return sortedPending; - } - - private void contactAdded(Contact contact) { - Uri uri = contact.getUri(); - String key = uri.getUri(); - //Log.w(TAG, "contactAdded " + getAccountID() + " " + uri + " " + contact.getConversationUri().blockingFirst()); - if (!contact.getConversationUri().blockingFirst().equals(uri)) { - // Don't add conversation if we have a swarm conversation - return; - } - synchronized (conversations) { - if (conversations.containsKey(key)) - return; - synchronized (pending) { - Conversation pendingConversation = pending.get(key); - if (pendingConversation == null) { - pendingConversation = getByKey(key); - conversations.put(key, pendingConversation); - } else { - pending.remove(key); - conversations.put(key, pendingConversation); - pendingChanged(); - } - pendingConversation.addContactEvent(contact); - } - conversationChanged(); - } - } - - private void contactRemoved(Uri uri) { - String key = uri.getUri(); - synchronized (conversations) { - synchronized (pending) { - if (pending.remove(key) != null) - pendingChanged(); - } - conversations.remove(key); - conversationChanged(); - } - } - - private Conversation getConversationByCallId(String callId) { - for (Conversation conversation : conversations.values()) { - Conference conf = conversation.getConference(callId); - if (conf != null) { - return conversation; - } - } - return null; - } - - public void presenceUpdate(String contactUri, boolean isOnline) { - //Log.w(TAG, "presenceUpdate " + contactUri + " " + isOnline); - Contact contact = getContactFromCache(contactUri); - if (contact.isOnline() == isOnline) - return; - contact.setOnline(isOnline); - synchronized (conversations) { - Conversation conversation = conversations.get(contactUri); - if (conversation != null) { - conversationRefreshed(conversation); - } - } - synchronized (pending) { - if (pending.containsKey(contactUri)) - pendingRefreshed(); - } - } - - public void composingStatusChanged(String conversationId, Uri contactUri, ComposingStatus status) { - boolean isSwarm = !StringUtils.isEmpty(conversationId); - Conversation conversation = isSwarm ? getSwarm(conversationId) : getByUri(contactUri); - if (conversation != null) { - Contact contact = isSwarm ? conversation.findContact(contactUri) : getContactFromCache(contactUri); - if (contact != null) { - conversation.composingStatusChanged(contact, status); - } - } - } - - synchronized public long onLocationUpdate(AccountService.Location location) { - Log.w(TAG, "onLocationUpdate " + location.getPeer() + " " + location.getLatitude() + ", " + location.getLongitude()); - Contact contact = getContactFromCache(location.getPeer()); - - switch (location.getType()) { - case position: - ContactLocation cl = new ContactLocation(); - cl.timestamp = location.getDate(); - cl.latitude = location.getLatitude(); - cl.longitude = location.getLongitude(); - cl.receivedDate = new Date(); - - Observable<ContactLocation> ls = contactLocations.get(contact); - if (ls == null) { - ls = BehaviorSubject.createDefault(cl); - contactLocations.put(contact, ls); - mLocationSubject.onNext(contactLocations); - ContactLocationEntry entry = new ContactLocationEntry(); - entry.contact = contact; - entry.location = ls; - mLocationStartedSubject.onNext(entry); - } else { - if (ls.blockingFirst().timestamp < cl.timestamp) - ((Subject<ContactLocation>) ls).onNext(cl); - } - break; - - case stop: - forceExpireContact(contact); - break; - } - - return LOCATION_SHARING_EXPIRATION_MS; - } - - synchronized private void forceExpireContact(Contact contact) { - Log.w(TAG, "forceExpireContact " + contactLocations.size()); - Observable<ContactLocation> cl = contactLocations.remove(contact); - if (cl != null) { - Log.w(TAG, "Contact stopped sharing location: " + contact.getDisplayName()); - ((Subject<ContactLocation>) cl).onComplete(); - mLocationSubject.onNext(contactLocations); - } - } - - synchronized public void maintainLocation() { - Log.w(TAG, "maintainLocation " + contactLocations.size()); - if (contactLocations.isEmpty()) - return; - boolean changed = false; - - final Date expiration = new Date(System.currentTimeMillis() - LOCATION_SHARING_EXPIRATION_MS); - Iterator<Map.Entry<Contact, Observable<ContactLocation>>> it = contactLocations.entrySet().iterator(); - while (it.hasNext()) { - Map.Entry<Contact, Observable<ContactLocation>> e = it.next(); - if (e.getValue().blockingFirst().receivedDate.before(expiration)) { - Log.w(TAG, "maintainLocation clearing " + e.getKey().getDisplayName()); - ((Subject<ContactLocation>) e.getValue()).onComplete(); - changed = true; - it.remove(); - } - } - - if (changed) - mLocationSubject.onNext(contactLocations); - } - - public Observable<ContactLocationEntry> getLocationUpdates() { - return mLocationStartedSubject; - } - - public Observable<Map<Contact, Observable<ContactLocation>>> getLocationsUpdates() { - return mLocationSubject; - } - - public Observable<Observable<ContactLocation>> getLocationUpdates(Uri contactId) { - Contact contact = getContactFromCache(contactId); - Log.w(TAG, "getLocationUpdates " + contactId + " " + contact); - if (contact == null || contact.isUser()) - return Observable.empty(); - return mLocationSubject - .flatMapMaybe(locations -> { - Observable<ContactLocation> r = locations.get(contact); - Log.w(TAG, "getLocationUpdates flatMapMaybe " + locations.size() + " " + r); - return r == null ? Maybe.empty() : Maybe.just(r); - }) - .distinctUntilChanged(); - } - - public Single<String> getAccountAlias() { - if (isJami()) { - if (mLoadedProfile == null) - return Single.just(getJamiAlias()); - return mLoadedProfile.map(p -> StringUtils.isEmpty(p.first) ? getJamiAlias() : p.first); - } else { - if (mLoadedProfile == null) - return Single.just(getAlias()); - return mLoadedProfile.map(p -> StringUtils.isEmpty(p.first) ? getAlias() : p.first); - } - } - - /** - * Registered name, fallback to Alias - */ - private String getJamiAlias() { - String registeredName = getRegisteredName(); - if (StringUtils.isEmpty(registeredName)) - return getAlias(); - else - return registeredName; - } - - public void resetProfile() { - mLoadedProfile = null; - } - - public Single<Tuple<String, Object>> getLoadedProfile() { - return mLoadedProfile; - } - - public void setLoadedProfile(Single<Tuple<String, Object>> profile) { - mLoadedProfile = profile; - } - - public DataTransfer getDataTransfer(String id) { - return mDataTransfers.get(id); - } - - public void putDataTransfer(String fileId, DataTransfer transfer) { - mDataTransfers.put(fileId, transfer); - } - - private static class ConversationComparator implements Comparator<Conversation> { - @Override - public int compare(Conversation a, Conversation b) { - return Interaction.compare(b.getLastEvent(), a.getLastEvent()); - } - } - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Account.kt b/ring-android/libringclient/src/main/java/net/jami/model/Account.kt new file mode 100644 index 000000000..5b32d3296 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/Account.kt @@ -0,0 +1,995 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ +package net.jami.model + +import io.reactivex.rxjava3.core.* +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.PublishSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.model.Interaction.InteractionStatus +import net.jami.services.AccountService +import net.jami.smartlist.SmartListViewModel +import net.jami.utils.Log +import net.jami.utils.StringUtils +import net.jami.utils.Tuple +import java.lang.IllegalStateException +import java.util.* + +class Account( + bAccountID: String, + details: Map<String, String>, + credentials: List<Map<String, String>>, + volDetails: Map<String, String> +) { + val accountID: String = bAccountID + private var mVolatileDetails: AccountConfig + var config: AccountConfig + private set + var username: String? = null + private set + val credentials = ArrayList<AccountCredentials>() + var devices: Map<String, String> = HashMap() + private val mContacts: MutableMap<String, Contact> = HashMap() + private val mRequests: MutableMap<String, TrustRequest> = HashMap() + private val mContactCache: MutableMap<String, Contact> = HashMap() + private val swarmConversations: MutableMap<String, Conversation> = HashMap() + private val mDataTransfers = HashMap<String, DataTransfer>() + private val conversations: MutableMap<String, Conversation> = HashMap() + private val pending: MutableMap<String, Conversation> = HashMap() + private val cache: MutableMap<String, Conversation> = HashMap() + private val sortedConversations: MutableList<Conversation> = ArrayList() + private val sortedPending: MutableList<Conversation> = ArrayList() + var registeringUsername = false + private var conversationsChanged = true + private var pendingsChanged = true + private var historyLoaded = false + private val conversationSubject: Subject<Conversation> = PublishSubject.create() + private val conversationsSubject: Subject<List<Conversation>> = BehaviorSubject.create() + private val pendingSubject: Subject<List<Conversation>> = BehaviorSubject.create() + private val unreadConversationsSubject: Subject<Int> = BehaviorSubject.create() + private val unreadPendingSubject: Subject<Int> = BehaviorSubject.create() + val unreadConversations: Observable<Int> = unreadConversationsSubject.distinctUntilChanged() + val unreadPending: Observable<Int> = unreadPendingSubject.distinctUntilChanged() + private val contactListSubject = BehaviorSubject.create<Collection<Contact>>() + private val contactLocations: MutableMap<Contact, Observable<ContactLocation>> = HashMap() + private val mLocationSubject: Subject<Map<Contact, Observable<ContactLocation>>> = BehaviorSubject.createDefault(contactLocations) + private val mLocationStartedSubject: Subject<ContactLocationEntry> = PublishSubject.create() + + var historyLoader: Single<Account>? = null + var loadedProfile: Single<Tuple<String?, Any?>>? = null + set(profile) { + field = profile + mProfileSubject.onNext(profile) + } + + private val mProfileSubject: Subject<Single<Tuple<String?, Any?>>> = BehaviorSubject.create() + val loadedProfileObservable: Observable<Tuple<String?, Any?>> = mProfileSubject.switchMapSingle { single -> single } + + fun cleanup() { + conversationSubject.onComplete() + conversationsSubject.onComplete() + pendingSubject.onComplete() + contactListSubject.onComplete() + //trustRequestsSubject.onComplete(); + } + + fun canSearch(): Boolean { + return !StringUtils.isEmpty(getDetail(ConfigKey.MANAGER_URI)) + } + + fun isContact(conversation: Conversation): Boolean { + val contact = conversation.contact + return contact != null && getContact(contact.uri.rawRingId) != null + } + + fun conversationStarted(conversation: Conversation) { + Log.w(TAG, "conversationStarted " + conversation.accountId + " " + conversation.uri + " " + conversation.isSwarm + " " + conversation.contacts.size + " " + conversation.mode.blockingFirst()) + synchronized(conversations) { + if (conversation.isSwarm && conversation.mode.blockingFirst() === Conversation.Mode.OneToOne) { + val contact = conversation.contact + val key = contact!!.uri.uri + val removed = cache.remove(key) + conversations.remove(key) + //Conversation contactConversation = getByUri(contact.getPrimaryUri()); + Log.w( + TAG, + "conversationStarted " + conversation.accountId + " contact " + key + " " + removed + ) + /*if (contactConversation != null) { + conversations.remove(contactConversation.getUri().getUri()); + }*/contact.setConversationUri(conversation.uri) + } + conversations[conversation.uri.uri] = conversation + conversationChanged() + } + } + + fun getSwarm(conversationId: String): Conversation? { + synchronized(conversations) { return swarmConversations[conversationId] } + } + + fun newSwarm(conversationId: String, mode: Conversation.Mode?): Conversation { + synchronized(conversations) { + var c = swarmConversations[conversationId] + if (c == null) { + c = Conversation(accountID, Uri(Uri.SWARM_SCHEME, conversationId), mode!!) + swarmConversations[conversationId] = c + } + c.setMode(mode!!) + return c + } + } + + fun removeSwarm(conversationId: String) { + Log.d(TAG, "removeSwarm $conversationId") + synchronized(conversations) { + val conversation = swarmConversations.remove(conversationId) + if (conversation != null) { + val c = conversations.remove(conversation.uri.uri) + try { + val contact = c!!.contact + Log.w( + TAG, + "removeSwarm: adding back contact conversation " + contact + " " + contact!!.conversationUri.blockingFirst() + " " + c.uri + ) + if (contact.conversationUri.blockingFirst().equals(c.uri)) { + contact.setConversationUri(contact.uri) + contactAdded(contact) + } + } catch (ignored: Exception) { + } + conversationChanged() + } + } + } + + class ContactLocation ( + val latitude: Double, + val longitude: Double, + val timestamp: Long, + val receivedDate: Date + ) + + class ContactLocationEntry ( + val contact: Contact, + val location: Observable<ContactLocation> + ) + + enum class ComposingStatus { + Idle, Active; + + companion object { + fun fromInt(status: Int): ComposingStatus { + return if (status == 1) Active else Idle + } + } + } + + fun getConversationsSubject(): Observable<List<Conversation>> { + return conversationsSubject + } + + fun getConversationsViewModels(withPresence: Boolean): Observable<MutableList<SmartListViewModel>> { + return conversationsSubject + .map { conversations: List<Conversation> -> + val viewModel = ArrayList<SmartListViewModel>(conversations.size) + for (c in conversations) viewModel.add(SmartListViewModel(c, withPresence)) + viewModel + } + } + + fun getConversationSubject(): Observable<Conversation> { + return conversationSubject + } + + fun getPendingSubject(): Observable<List<Conversation>> { + return pendingSubject + } + + fun getConversations(): Collection<Conversation> { + return conversations.values + } + + fun getPending(): Collection<Conversation> { + return pending.values + } + + private fun pendingRefreshed() { + if (historyLoaded) { + pendingSubject.onNext(getSortedPending()) + updateUnreadPending() + } + } + + private fun pendingChanged() { + pendingsChanged = true + pendingRefreshed() + } + + private fun pendingUpdated(conversation: Conversation?) { + if (!historyLoaded) return + if (pendingsChanged) { + getSortedPending() + } else { + conversation?.sortHistory() + sortedPending.sortWith { a, b -> Interaction.compare(b.lastEvent, a.lastEvent) } + } + pendingSubject.onNext(getSortedPending()) + } + + private fun conversationRefreshed(conversation: Conversation) { + if (historyLoaded) { + conversationSubject.onNext(conversation) + updateUnreadConversations() + } + } + + fun conversationChanged() { + synchronized(conversations) { + conversationsChanged = true + if (historyLoaded) { + conversationsSubject.onNext(ArrayList(getSortedConversations())) + updateUnreadConversations() + } + } + } + + fun conversationUpdated(conversation: Conversation?) { + synchronized(conversations) { + if (!historyLoaded) return + if (conversationsChanged) { + getSortedConversations() + } else { + conversation?.sortHistory() + sortedConversations.sortWith { a: Conversation, b: Conversation -> + Interaction.compare(b.lastEvent, a.lastEvent) } + } + conversationsSubject.onNext(ArrayList(sortedConversations)) + updateUnreadConversations() + } + } + + private fun updateUnreadConversations() { + var unread = 0 + for (model in sortedConversations) { + val last = model.lastEvent + if (last != null && !last.isRead) unread++ + } + // Log.w(TAG, "updateUnreadConversations " + unread); + unreadConversationsSubject.onNext(unread) + } + + private fun updateUnreadPending() { + unreadPendingSubject.onNext(sortedPending.size) + } + + /** + * Clears a conversation + * + * @param contact the contact + * @param delete true if you want to remove the conversation + */ + fun clearHistory(contact: Uri?, delete: Boolean) { + val conversation = getByUri(contact) + // if it is a sip account, we do not add a contact event + conversation!!.clearHistory(delete || isSip) + conversationChanged() + } + + fun clearAllHistory() { + for (conversation in getConversations()) { + // if it is a sip account, we do not add a contact event + conversation.clearHistory(isSip) + } + for (conversation in pending.values) { + conversation.clearHistory(true) + } + conversationChanged() + pendingChanged() + } + + fun updated(conversation: Conversation?) { + val key = conversation!!.uri.uri + synchronized(conversations) { + if (conversation == conversations[key]) { + conversationUpdated(conversation) + return + } + } + synchronized(pending) { + if (conversation == pending[key]) { + pendingUpdated(conversation) + return + } + } + if (conversation == cache[key]) { + if (isJami && !conversation.isSwarm + && conversation.contacts.size == 1 + && !conversation.contact!!.conversationUri.blockingFirst().equals(conversation.uri)) { + return + } + if (mContacts.containsKey(key) || !isJami) { + Log.w(TAG, "updated " + conversation.accountId + " contact " + key) + conversations[key] = conversation + conversationChanged() + } else { + pending[key] = conversation + pendingChanged() + } + } + } + + fun refreshed(conversation: Conversation) { + synchronized(conversations) { + if (conversations.containsValue(conversation)) { + conversationRefreshed(conversation) + return + } + } + synchronized(pending) { + if (pending.containsValue(conversation)) + pendingRefreshed() + } + } + + fun addTextMessage(txt: TextMessage) { + var conversation: Conversation? = null + val daemonId = txt.daemonIdString + if (daemonId != null && !StringUtils.isEmpty(daemonId)) { + conversation = getConversationByCallId(daemonId) + } + if (conversation == null) { + conversation = getByKey(txt.conversation!!.participant) + txt.contact = conversation.contact + } + conversation.addTextMessage(txt) + updated(conversation) + } + + fun onDataTransferEvent(transfer: DataTransfer): Conversation { + Log.d(TAG, "Account onDataTransferEvent " + transfer.messageId) + val conversation = transfer.conversation as Conversation + val transferEventCode = transfer.status + if (transferEventCode == InteractionStatus.TRANSFER_CREATED) { + conversation.addFileTransfer(transfer) + } else { + conversation.updateFileTransfer(transfer, transferEventCode) + } + updated(conversation) + return conversation + } + + val bannedContactsUpdates: Observable<Collection<Contact>> + get() = contactListSubject.concatMapSingle { list: Collection<Contact> -> + Observable.fromIterable(list).filter(Contact::isBanned).toList(list.size) + } + + fun getContactFromCache(key: String): Contact { + if (key.isEmpty()) throw IllegalStateException() + synchronized(mContactCache) { + var contact = mContactCache[key] + if (contact == null) { + contact = if (isSip) Contact.buildSIP(Uri.fromString(key)) + else Contact.build(key, isMe(key)) + mContactCache[key] = contact + } + return contact + } + } + + fun isMe(uri: String): Boolean { + //Log.w(TAG, "isMe " + uri + " " + getUsername()); + return username == uri + } + + fun getContactFromCache(uri: Uri): Contact { + return getContactFromCache(uri.uri) + } + + fun dispose() { + contactListSubject.onComplete() + //trustRequestsSubject.onComplete(); + } + + fun setCredentials(creds: List<Map<String, String>>) { + credentials.clear() + credentials.ensureCapacity(creds.size) + creds.forEach { c -> credentials.add(AccountCredentials(c)) } + } + + fun setDetails(details: Map<String, String>) { + config = AccountConfig(details) + username = config[ConfigKey.ACCOUNT_USERNAME] + } + + fun setDetail(key: ConfigKey, value: String) { + config.put(key, value) + } + + fun setDetail(key: ConfigKey, value: Boolean) { + config.put(key, value) + } + + val displayname: String + get() = config[ConfigKey.ACCOUNT_DISPLAYNAME] + val displayUsername: String? + get() { + if (isJami) { + val registeredName: String? = registeredName + if (registeredName != null && registeredName.isNotEmpty()) { + return registeredName + } + } + return username + } + var host: String? + get() = config[ConfigKey.ACCOUNT_HOSTNAME] + set(host) { + config.put(ConfigKey.ACCOUNT_HOSTNAME, host!!) + } + var proxy: String? + get() = config[ConfigKey.ACCOUNT_ROUTESET] + set(proxy) { + config.put(ConfigKey.ACCOUNT_ROUTESET, proxy!!) + } + var isDhtProxyEnabled: Boolean + get() = config.getBool(ConfigKey.PROXY_ENABLED) + set(active) { + config.put(ConfigKey.PROXY_ENABLED, if (active) "true" else "false") + } + val registrationState: String + get() = mVolatileDetails[ConfigKey.ACCOUNT_REGISTRATION_STATUS] + + fun setRegistrationState(registeredState: String, code: Int) { + mVolatileDetails.put(ConfigKey.ACCOUNT_REGISTRATION_STATUS, registeredState) + mVolatileDetails.put(ConfigKey.ACCOUNT_REGISTRATION_STATE_CODE, code.toString()) + } + + fun setVolatileDetails(volatileDetails: Map<String, String>) { + mVolatileDetails = AccountConfig(volatileDetails) + } + + val registeredName: String + get() = mVolatileDetails[ConfigKey.ACCOUNT_REGISTERED_NAME] + var alias: String? + get() = config[ConfigKey.ACCOUNT_ALIAS] + set(alias) { + config.put(ConfigKey.ACCOUNT_ALIAS, alias!!) + } + val isSip: Boolean + get() = config[ConfigKey.ACCOUNT_TYPE] == AccountConfig.ACCOUNT_TYPE_SIP + val isJami: Boolean + get() = config[ConfigKey.ACCOUNT_TYPE] == AccountConfig.ACCOUNT_TYPE_RING + + private fun getDetail(key: ConfigKey): String { + return config[key] + } + + fun getDetailBoolean(key: ConfigKey): Boolean { + return config.getBool(key) + } + + var isEnabled: Boolean + get() = config.getBool(ConfigKey.ACCOUNT_ENABLE) + set(isChecked) { + config.put(ConfigKey.ACCOUNT_ENABLE, isChecked) + } + val isActive: Boolean + get() = mVolatileDetails.getBool(ConfigKey.ACCOUNT_ACTIVE) + + fun hasPassword(): Boolean { + return config.getBool(ConfigKey.ARCHIVE_HAS_PASSWORD) + } + + fun hasManager(): Boolean { + return config[ConfigKey.MANAGER_URI].isNotEmpty() + } + + val details: HashMap<String, String> + get() = config.all + val isTrying: Boolean + get() = registrationState.contentEquals(AccountConfig.STATE_TRYING) + val isRegistered: Boolean + get() = registrationState.contentEquals(AccountConfig.STATE_READY) || registrationState.contentEquals( + AccountConfig.STATE_REGISTERED + ) + val isInError: Boolean + get() { + val state = registrationState + return (state.contentEquals(AccountConfig.STATE_ERROR) + || state.contentEquals(AccountConfig.STATE_ERROR_AUTH) + || state.contentEquals(AccountConfig.STATE_ERROR_CONF_STUN) + || state.contentEquals(AccountConfig.STATE_ERROR_EXIST_STUN) + || state.contentEquals(AccountConfig.STATE_ERROR_GENERIC) + || state.contentEquals(AccountConfig.STATE_ERROR_HOST) + || state.contentEquals(AccountConfig.STATE_ERROR_NETWORK) + || state.contentEquals(AccountConfig.STATE_ERROR_NOT_ACCEPTABLE) + || state.contentEquals(AccountConfig.STATE_ERROR_SERVICE_UNAVAILABLE) + || state.contentEquals(AccountConfig.STATE_REQUEST_TIMEOUT)) + } + val isIP2IP: Boolean + get() { + val emptyHost = host == null || host != null && host!!.isEmpty() + return isSip && emptyHost + } + val isAutoanswerEnabled: Boolean + get() = config.getBool(ConfigKey.ACCOUNT_AUTOANSWER) + + fun addCredential(newValue: AccountCredentials) { + credentials.add(newValue) + } + + fun removeCredential(accountCredentials: AccountCredentials) { + credentials.remove(accountCredentials) + } + + val credentialsHashMapList: List<Map<String, String>> + get() { + val result = ArrayList<Map<String, String>>( + credentials.size + ) + for (cred in credentials) { + result.add(cred.details) + } + return result + } + + private fun getUri(display: Boolean): String? { + val username = if (display) displayUsername else username + return if (isJami) { + username + } else { + "$username@$host" + } + } + + val uri: String? + get() = getUri(false) + val displayUri: String? + get() = getUri(true) + + fun getDisplayUri(defaultNameSip: CharSequence): String { + return if (isIP2IP) defaultNameSip.toString() else displayUri!! + } + + fun needsMigration(): Boolean { + return AccountConfig.STATE_NEED_MIGRATION == registrationState + } + + val deviceId: String + get() = getDetail(ConfigKey.ACCOUNT_DEVICE_ID) + val deviceName: String + get() = getDetail(ConfigKey.ACCOUNT_DEVICE_NAME) + val contacts: Map<String, Contact> + get() = mContacts + val bannedContacts: List<Contact> + get() { + val banned = ArrayList<Contact>() + for (contact in mContacts.values) { + if (contact.isBanned) { + banned.add(contact) + } + } + return banned + } + + fun getContact(ringId: String?): Contact? { + return mContacts[ringId] + } + + fun addContact(id: String, confirmed: Boolean) { + var contact = mContacts[id] + if (contact == null) { + contact = getContactFromCache(Uri.fromId(id)) + mContacts[id] = contact + } + contact.addedDate = Date() + if (confirmed) { + contact.status = Contact.Status.CONFIRMED + } else { + contact.status = Contact.Status.REQUEST_SENT + } + val req = mRequests[id] + if (req != null) { + mRequests.remove(id) + } + contactAdded(contact) + contactListSubject.onNext(mContacts.values) + } + + fun removeContact(id: String, banned: Boolean) { + var contact = mContacts[id] + if (banned) { + if (contact == null) { + contact = getContactFromCache(Uri.fromId(id)) + mContacts[id] = contact + } + contact.status = Contact.Status.BANNED + } else { + mContacts.remove(id) + } + val req = mRequests[id] + if (req != null) { + mRequests.remove(id) + } + if (contact != null) { + contactRemoved(contact.uri) + } + contactListSubject.onNext(mContacts.values) + } + + fun addContact(contact: Map<String, String>): Contact { + val contactId = contact[CONTACT_ID]!! + val callContact = mContacts[contactId] ?: getContactFromCache(Uri.fromId(contactId)) + val addedStr = contact[CONTACT_ADDED] + if (!StringUtils.isEmpty(addedStr)) { + val added = contact[CONTACT_ADDED]!!.toLong() + callContact.addedDate = Date(added * 1000) + } + if (contact.containsKey(CONTACT_BANNED) && contact[CONTACT_BANNED] == "true") { + callContact.status = Contact.Status.BANNED + } else if (contact.containsKey(CONTACT_CONFIRMED)) { + callContact.status = + if (java.lang.Boolean.parseBoolean(contact[CONTACT_CONFIRMED])) Contact.Status.CONFIRMED else Contact.Status.REQUEST_SENT + } + val conversationUri = contact[CONTACT_CONVERSATION] + if (!StringUtils.isEmpty(conversationUri)) { + callContact.setConversationUri(Uri(Uri.SWARM_SCHEME, conversationUri!!)) + } + mContacts[contactId] = callContact + contactAdded(callContact) + return callContact + } + + fun setContacts(contacts: List<Map<String, String>>) { + for (contact in contacts) { + addContact(contact) + } + contactListSubject.onNext(mContacts.values) + } + + var requests: List<TrustRequest> + get() { + val requests = ArrayList<TrustRequest>(mRequests.size) + for (request in mRequests.values) { + if (request.isNameResolved) { + requests.add(request) + } + } + return requests + } + set(requests) { + Log.w(TAG, "setRequests " + requests.size) + synchronized(pending) { + for (request in requests) { + val key = request.uri.uri + mRequests[key] = request + var conversation = pending[key] + if (conversation == null) { + conversation = getByKey(key) + pending[key] = conversation + val contact = getContactFromCache(request.uri) + conversation.addRequestEvent(request, contact) + } + } + pendingChanged() + } + } + + fun getRequest(uri: Uri): TrustRequest? { + return mRequests[uri.uri] + } + + fun addRequest(request: TrustRequest) { + synchronized(pending) { + val key = request.uri.uri + mRequests[key] = request + var conversation = pending[key] + if (conversation == null) { + conversation = getByKey(key) + pending[key] = conversation + if (!conversation.isSwarm) { + val contact = getContactFromCache(request.uri) + conversation.addRequestEvent(request, contact) + } + pendingChanged() + } + } + } + + fun removeRequest(conversationUri: Uri): TrustRequest? { + synchronized(pending) { + val uri = conversationUri.uri + val request = mRequests.remove(uri) + if (pending.remove(uri) != null) { + pendingChanged() + } + return request + } + } + + fun removeRequestPerConvId(conversationId: String) { + synchronized(pending) { + for ((_, request) in mRequests) { + if (request.conversationId != null && request.conversationId == conversationId) { + removeRequest(request.uri) + return + } + } + } + } + + fun registeredNameFound(state: Int, address: String, name: String?): Boolean { + val uri = Uri.fromString(address) + val key = uri.uri + val contact = getContactFromCache(key) + if (contact.setUsername(if (state == 0) name else null)) { + synchronized(conversations) { + conversations[key]?.let { conversationRefreshed(it) } + } + synchronized(pending) { if (pending.containsKey(key)) pendingRefreshed() } + return true + } + return false + } + + fun getByUri(uri: Uri?): Conversation? { + //Log.w(TAG, "getByUri " + getAccountID() + " " + uri); + if (uri == null || uri.isEmpty) return null + return if (uri.isSwarm) getSwarm(uri.rawRingId) else getByKey(uri.uri) + } + + fun getByUri(uri: String?): Conversation? { + return if (uri != null) getByUri(Uri.fromString(uri)) else null + } + + private fun getByKey(key: String): Conversation { + cache[key]?.let { return it } + val contact = getContactFromCache(key) + val conversation = Conversation(accountID, contact) + //Log.w(TAG, "getByKey " + getAccountID() + " contact " + key); + cache[key] = conversation + return conversation + } + + fun setHistoryLoaded(conversations: List<Conversation>) { + synchronized(this.conversations) { + if (historyLoaded) return + //Log.w(TAG, "setHistoryLoaded " + getAccountID() + " " + conversations.size()); + for (c in conversations) { + val contact = c.contact + if (!c.isSwarm && contact != null && contact.conversationUri.blockingFirst().equals(c.uri)) + updated(c) + } + historyLoaded = true + conversationChanged() + pendingChanged() + } + } + + private fun getSortedConversations(): List<Conversation> { + if (conversationsChanged) { + sortedConversations.clear() + sortedConversations.addAll(conversations.values) + for (c in sortedConversations) c.sortHistory() + Collections.sort(sortedConversations, ConversationComparator()) + conversationsChanged = false + } + return sortedConversations + } + + private fun getSortedPending(): List<Conversation> { + if (pendingsChanged) { + sortedPending.clear() + sortedPending.addAll(pending.values) + for (c in sortedPending) c.sortHistory() + Collections.sort(sortedPending, ConversationComparator()) + pendingsChanged = false + } + return sortedPending + } + + private fun contactAdded(contact: Contact?) { + val uri = contact!!.uri + val key = uri.uri + Log.w(TAG, "contactAdded " + accountID + " " + uri + " " + contact.conversationUri.blockingFirst()) + if (!contact.conversationUri.blockingFirst().equals(uri)) { + Log.w(TAG, "contactAdded Don't add conversation if we have a swarm conversation") + // Don't add conversation if we have a swarm conversation + return + } + synchronized(conversations) { + if (conversations.containsKey(key)) return + synchronized(pending) { + var pendingConversation = pending[key] + if (pendingConversation == null) { + pendingConversation = getByKey(key) + conversations[key] = pendingConversation + } else { + pending.remove(key) + conversations[key] = pendingConversation + pendingChanged() + } + pendingConversation.addContactEvent(contact) + } + conversationChanged() + } + } + + private fun contactRemoved(uri: Uri) { + val key = uri.uri + synchronized(conversations) { + synchronized(pending) { if (pending.remove(key) != null) pendingChanged() } + conversations.remove(key) + conversationChanged() + } + } + + private fun getConversationByCallId(callId: String): Conversation? { + for (conversation in conversations.values) { + val conf = conversation.getConference(callId) + if (conf != null) { + return conversation + } + } + return null + } + + fun presenceUpdate(contactUri: String, isOnline: Boolean) { + //Log.w(TAG, "presenceUpdate " + contactUri + " " + isOnline); + val contact = getContactFromCache(contactUri) + if (contact.isOnline == isOnline) return + contact.isOnline = isOnline + synchronized(conversations) { + val conversation = conversations[contactUri] + conversation?.let { conversationRefreshed(it) } + } + synchronized(pending) { if (pending.containsKey(contactUri)) pendingRefreshed() } + } + + fun composingStatusChanged(conversationId: String, contactUri: Uri, status: ComposingStatus?) { + val isSwarm = !StringUtils.isEmpty(conversationId) + val conversation = if (isSwarm) getSwarm(conversationId) else getByUri(contactUri) + if (conversation != null) { + val contact = if (isSwarm) conversation.findContact(contactUri) else getContactFromCache(contactUri) + if (contact != null) { + conversation.composingStatusChanged(contact, status!!) + } + } + } + + @Synchronized + fun onLocationUpdate(location: AccountService.Location): Long { + Log.w(TAG, "onLocationUpdate " + location.peer + " " + location.latitude + ", " + location.longitude) + val contact = getContactFromCache(location.peer) + when (location.type) { + AccountService.Location.Type.Position -> { + val cl = ContactLocation(location.latitude, location.longitude, location.date, Date()) + var ls = contactLocations[contact] + if (ls == null) { + ls = BehaviorSubject.createDefault(cl) + contactLocations[contact] = ls + mLocationSubject.onNext(contactLocations) + mLocationStartedSubject.onNext(ContactLocationEntry(contact, ls)) + } else if (ls.blockingFirst().timestamp < cl.timestamp) { + (ls as Subject<ContactLocation>).onNext(cl) + } + } + AccountService.Location.Type.Stop -> forceExpireContact(contact) + } + return LOCATION_SHARING_EXPIRATION_MS.toLong() + } + + @Synchronized + private fun forceExpireContact(contact: Contact?) { + Log.w(TAG, "forceExpireContact " + contactLocations.size) + val cl = contactLocations.remove(contact) + if (cl != null) { + Log.w(TAG, "Contact stopped sharing location: " + contact!!.displayName) + (cl as Subject<ContactLocation>).onComplete() + mLocationSubject.onNext(contactLocations) + } + } + + @Synchronized + fun maintainLocation() { + Log.w(TAG, "maintainLocation " + contactLocations.size) + if (contactLocations.isEmpty()) return + var changed = false + val expiration = Date(System.currentTimeMillis() - LOCATION_SHARING_EXPIRATION_MS) + val it: MutableIterator<Map.Entry<Contact, Observable<ContactLocation>>> = + contactLocations.entries.iterator() + while (it.hasNext()) { + val e = it.next() + if (e.value.blockingFirst().receivedDate!!.before(expiration)) { + Log.w(TAG, "maintainLocation clearing " + e.key.displayName) + (e.value as Subject<ContactLocation>?)!!.onComplete() + changed = true + it.remove() + } + } + if (changed) mLocationSubject.onNext(contactLocations) + } + + val locationUpdates: Observable<ContactLocationEntry> + get() = mLocationStartedSubject + val locationsUpdates: Observable<Map<Contact, Observable<ContactLocation>>> + get() = mLocationSubject + + fun getLocationUpdates(contactId: Uri): Observable<Observable<ContactLocation>> { + val contact = getContactFromCache(contactId) + return if (contact.isUser) Observable.empty() else mLocationSubject + .flatMapMaybe{ locations: Map<Contact, Observable<ContactLocation>> -> + val r = locations[contact] + if (r == null) Maybe.empty() else Maybe.just(r) + } + .distinctUntilChanged() + } + + val accountAlias: Single<String> + get() = loadedProfileObservable.firstOrError() + .map { p: Tuple<String?, Any?> -> if (StringUtils.isEmpty(p.first)) if (isJami) jamiAlias else alias else p.first } + + /** + * Registered name, fallback to Alias + */ + private val jamiAlias: String + get() { + val registeredName = registeredName + return if (StringUtils.isEmpty(registeredName)) alias!! else registeredName + } + + fun resetProfile() { + loadedProfile = null + } + + fun getDataTransfer(id: String): DataTransfer? { + return mDataTransfers[id] + } + + fun putDataTransfer(fileId: String, transfer: DataTransfer) { + mDataTransfers[fileId] = transfer + } + + private class ConversationComparator : Comparator<Conversation> { + override fun compare(a: Conversation, b: Conversation): Int { + return Interaction.compare(b.lastEvent, a.lastEvent) + } + } + + companion object { + private val TAG = Account::class.simpleName!! + private const val CONTACT_ADDED = "added" + private const val CONTACT_CONFIRMED = "confirmed" + private const val CONTACT_BANNED = "banned" + private const val CONTACT_ID = "id" + private const val CONTACT_CONVERSATION = "conversationId" + private const val LOCATION_SHARING_EXPIRATION_MS = 1000 * 60 * 2 + } + + init { + config = AccountConfig(details) + username = config[ConfigKey.ACCOUNT_USERNAME] + mVolatileDetails = AccountConfig(volDetails) + setCredentials(credentials) + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/AccountConfig.java b/ring-android/libringclient/src/main/java/net/jami/model/AccountConfig.java deleted file mode 100644 index d41d0f416..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/AccountConfig.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package net.jami.model; - -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - -public class AccountConfig { - - private static final String TAG = AccountConfig.class.getSimpleName(); - - public static final String TRUE_STR = "true"; - public static final String FALSE_STR = "false"; - public static final String ACCOUNT_TYPE_RING = "RING"; - public static final String ACCOUNT_TYPE_SIP = "SIP"; - - public static final String STATE_REGISTERED = "REGISTERED"; - public static final String STATE_READY = "READY"; - public static final String STATE_UNREGISTERED = "UNREGISTERED"; - public static final String STATE_TRYING = "TRYING"; - public static final String STATE_ERROR = "ERROR"; - public static final String STATE_ERROR_GENERIC = "ERROR_GENERIC"; - public static final String STATE_ERROR_AUTH = "ERROR_AUTH"; - public static final String STATE_ERROR_NETWORK = "ERROR_NETWORK"; - public static final String STATE_ERROR_HOST = "ERROR_HOST"; - public static final String STATE_ERROR_CONF_STUN = "ERROR_CONF_STUN"; - public static final String STATE_ERROR_EXIST_STUN = "ERROR_EXIST_STUN"; - public static final String STATE_ERROR_SERVICE_UNAVAILABLE = "ERROR_SERVICE_UNAVAILABLE"; - public static final String STATE_ERROR_NOT_ACCEPTABLE = "ERROR_NOT_ACCEPTABLE"; - public static final String STATE_REQUEST_TIMEOUT = "Request Timeout"; - public static final String STATE_INITIALIZING = "INITIALIZING"; - public static final String STATE_NEED_MIGRATION = "ERROR_NEED_MIGRATION"; - public static final String STATE_SUCCESS = "SUCCESS"; - public static final String STATE_INVALID = "INVALID"; - - private final Map<ConfigKey, String> mValues; - - public AccountConfig() { - mValues = new HashMap<>(); - } - - public AccountConfig(Map<String, String> details) { - if (details != null) { - mValues = new HashMap<>(details.size()); - for (Map.Entry<String, String> entry : details.entrySet()) { - ConfigKey confKey = ConfigKey.fromString(entry.getKey()); - if (confKey != null) { - mValues.put(confKey, entry.getValue()); - } - } - } else { - mValues = new HashMap<>(); - } - } - - public String get(ConfigKey key) { - return mValues.get(key) != null ? mValues.get(key) : ""; - } - - public boolean getBool(ConfigKey key) { - return TRUE_STR.equals(get(key)); - } - - public HashMap<String, String> getAll() { - HashMap<String, String> details = new HashMap<>(mValues.size()); - for (Map.Entry<ConfigKey, String> entry : mValues.entrySet()) { - details.put(entry.getKey().key(), entry.getValue()); - } - return details; - } - - void put(ConfigKey key, String value) { - mValues.put(key, value); - } - - void put(ConfigKey key, boolean value) { - mValues.put(key, value ? TRUE_STR : FALSE_STR); - } - - public Set<ConfigKey> getKeys() { - return mValues.keySet(); - } - - public Set<Map.Entry<ConfigKey, String>> getEntries() { - return mValues.entrySet(); - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/AccountConfig.kt b/ring-android/libringclient/src/main/java/net/jami/model/AccountConfig.kt new file mode 100644 index 000000000..1ed759a18 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/AccountConfig.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package net.jami.model + +import java.util.* + +class AccountConfig(details: Map<String, String>) { + private val mValues: MutableMap<ConfigKey, String> = EnumMap(ConfigKey::class.java) + + operator fun get(key: ConfigKey): String { + return mValues[key] ?: "" + } + + fun getBool(key: ConfigKey): Boolean { + return TRUE_STR == get(key) + } + + val all: HashMap<String, String> + get() { + val details = HashMap<String, String>(mValues.size) + for ((key, value) in mValues) { + details[key.key()] = value + } + return details + } + + fun put(key: ConfigKey, value: String) { + mValues[key] = value + } + + fun put(key: ConfigKey, value: Boolean) { + mValues[key] = if (value) TRUE_STR else FALSE_STR + } + + val keys: Set<ConfigKey> + get() = mValues.keys + val entries: Set<Map.Entry<ConfigKey, String>> + get() = mValues.entries + + companion object { + private val TAG = AccountConfig::class.java.simpleName + const val TRUE_STR = "true" + const val FALSE_STR = "false" + const val ACCOUNT_TYPE_RING = "RING" + const val ACCOUNT_TYPE_SIP = "SIP" + const val STATE_REGISTERED = "REGISTERED" + const val STATE_READY = "READY" + const val STATE_UNREGISTERED = "UNREGISTERED" + const val STATE_TRYING = "TRYING" + const val STATE_ERROR = "ERROR" + const val STATE_ERROR_GENERIC = "ERROR_GENERIC" + const val STATE_ERROR_AUTH = "ERROR_AUTH" + const val STATE_ERROR_NETWORK = "ERROR_NETWORK" + const val STATE_ERROR_HOST = "ERROR_HOST" + const val STATE_ERROR_CONF_STUN = "ERROR_CONF_STUN" + const val STATE_ERROR_EXIST_STUN = "ERROR_EXIST_STUN" + const val STATE_ERROR_SERVICE_UNAVAILABLE = "ERROR_SERVICE_UNAVAILABLE" + const val STATE_ERROR_NOT_ACCEPTABLE = "ERROR_NOT_ACCEPTABLE" + const val STATE_REQUEST_TIMEOUT = "Request Timeout" + const val STATE_INITIALIZING = "INITIALIZING" + const val STATE_NEED_MIGRATION = "ERROR_NEED_MIGRATION" + const val STATE_SUCCESS = "SUCCESS" + const val STATE_INVALID = "INVALID" + } + + init { + for ((key, value) in details) { + val confKey = ConfigKey.fromString(key) + if (confKey != null) { + mValues[confKey] = value + } + } + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Call.java b/ring-android/libringclient/src/main/java/net/jami/model/Call.java deleted file mode 100644 index 0e9a14cd8..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/Call.java +++ /dev/null @@ -1,366 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package net.jami.model; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import net.jami.utils.Log; -import net.jami.utils.ProfileChunk; -import net.jami.utils.StringUtils; -import net.jami.utils.VCardUtils; - -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; - -import ezvcard.Ezvcard; -import ezvcard.VCard; - -public class Call extends Interaction { - public final static String TAG = Call.class.getSimpleName(); - - public final static String KEY_ACCOUNT_ID = "ACCOUNTID"; - public final static String KEY_AUDIO_ONLY = "AUDIO_ONLY"; - public final static String KEY_CALL_TYPE = "CALL_TYPE"; - public final static String KEY_CALL_STATE = "CALL_STATE"; - public final static String KEY_PEER_NUMBER = "PEER_NUMBER"; - public final static String KEY_PEER_HOLDING = "PEER_HOLDING"; - public final static String KEY_AUDIO_MUTED = "PEER_NUMBER"; - public final static String KEY_VIDEO_MUTED = "VIDEO_MUTED"; - public final static String KEY_AUDIO_CODEC = "AUDIO_CODEC"; - public final static String KEY_VIDEO_CODEC = "VIDEO_CODEC"; - public final static String KEY_REGISTERED_NAME = "REGISTERED_NAME"; - public final static String KEY_DURATION = "duration"; - public final static String KEY_CONF_ID = "CONF_ID"; - - private final String mIdDaemon; - - private boolean isPeerHolding = false; - private boolean isAudioMuted = false; - private boolean isVideoMuted = false; - private boolean isRecording = false; - private boolean isAudioOnly = false; - - private CallStatus mCallStatus = CallStatus.NONE; - - private long timestampEnd = 0; - private Long duration = null; - private boolean missed = true; - private String mAudioCodec; - private String mVideoCodec; - private String mContactNumber; - private String mConfId; - - private ProfileChunk mProfileChunk = null; - - public Call(String daemonId, String author, String account, ConversationHistory conversation, Contact contact, Direction direction) { - mIdDaemon = daemonId; - try { - mDaemonId = daemonId == null ? null : Long.parseLong(daemonId); - } catch (Exception e) { - Log.e(TAG, "Can't parse CallId " + mDaemonId); - } - mAuthor = direction == Direction.INCOMING ? author : null; - mAccount = account; - mConversation = conversation; - mIsIncoming = direction == Direction.INCOMING; - mTimestamp = System.currentTimeMillis(); - mType = InteractionType.CALL.toString(); - mContact = contact; - mIsRead = 1; - } - - public Call(Interaction interaction) { - mId = interaction.getId(); - mAuthor = interaction.getAuthor(); - mConversation = interaction.getConversation(); - mIsIncoming = mAuthor != null; - mTimestamp = interaction.getTimestamp(); - mType = InteractionType.CALL.toString(); - mStatus = interaction.getStatus().toString(); - mDaemonId = interaction.getDaemonId(); - mIdDaemon = super.getDaemonIdString(); - mIsRead = interaction.isRead() ? 1 : 0; - mAccount = interaction.getAccount(); - mExtraFlag = fromJson(interaction.getExtraFlag()); - missed = getDuration() == 0; - mIsRead = 1; - mContact = interaction.getContact(); - } - - public Call(String daemonId, String account, String contactNumber, Direction direction, long timestamp) { - mIdDaemon = daemonId; - try { - mDaemonId = daemonId == null ? null : Long.parseLong(daemonId); - } catch (Exception e) { - Log.e(TAG, "Can't parse CallId " + mDaemonId); - } - mIsIncoming = direction == Direction.INCOMING; - mAccount = account; - mAuthor = direction == Direction.INCOMING ? contactNumber : null; - mContactNumber = contactNumber; - mTimestamp = timestamp; - mType = InteractionType.CALL.toString(); - mIsRead = 1; - } - - public Call(String daemonId, Map<String, String> call_details) { - this(daemonId, call_details.get(KEY_ACCOUNT_ID), call_details.get(KEY_PEER_NUMBER), Direction.fromInt(Integer.parseInt(call_details.get(KEY_CALL_TYPE))), System.currentTimeMillis()); - setCallState(CallStatus.fromString(call_details.get(KEY_CALL_STATE))); - setDetails(call_details); - } - - public void setDetails(Map<String, String> details) { - isPeerHolding = "true".equals(details.get(KEY_PEER_HOLDING)); - isAudioMuted = "true".equals(details.get(KEY_AUDIO_MUTED)); - isVideoMuted = "true".equals(details.get(KEY_VIDEO_MUTED)); - isAudioOnly = "true".equals(details.get(KEY_AUDIO_ONLY)); - mAudioCodec = details.get(KEY_AUDIO_CODEC); - mVideoCodec = details.get(KEY_VIDEO_CODEC); - String confId = details.get(KEY_CONF_ID); - mConfId = StringUtils.isEmpty(confId) ? null : confId; - } - - @Override - public String getDaemonIdString() { - return mIdDaemon; - } - - public boolean isConferenceParticipant() { - return mConfId != null; - } - - public String getContactNumber() { - return mContactNumber; - } - - public Long getDuration() { - if (duration == null) { - JsonElement element = toJson(mExtraFlag).get(KEY_DURATION); - if (element != null) { - duration = element.getAsLong(); - } - } - return duration == null ? 0 : duration; - } - - public void setDuration(Long value) { - if (Objects.equals(value, duration)) - return; - duration = value; - if (duration != null && duration != 0) { - JsonObject jsonObject = getExtraFlag(); - jsonObject.addProperty(KEY_DURATION, value); - mExtraFlag = fromJson(jsonObject); - missed = false; - } - } - - public String getDurationString() { - long mDuration = getDuration() / 1000; - if (mDuration < 60) { - return String.format(Locale.getDefault(), "%02d secs", mDuration); - } - - if (mDuration < 3600) { - return String.format(Locale.getDefault(), "%02d mins %02d secs", (mDuration % 3600) / 60, (mDuration % 60)); - } - - return String.format(Locale.getDefault(), "%d h %02d mins %02d secs", mDuration / 3600, (mDuration % 3600) / 60, (mDuration % 60)); - } - - public long getTimestampEnd() { - return timestampEnd; - } - - public void setTimestampEnd(long timestampEnd) { - this.timestampEnd = timestampEnd; - if (timestampEnd != 0 && !isMissed()) - setDuration(timestampEnd - mTimestamp); - } - - public boolean isMissed() { - return missed; - } - - public boolean isAudioOnly() { - return isAudioOnly; - } - - - public void muteVideo(boolean mute) { - isVideoMuted = mute; - } - - public void muteAudio(boolean mute) { - isAudioMuted = mute; - } - - public boolean isAudioMuted() { - return isAudioMuted; - } - - public String getVideoCodec() { - return mVideoCodec; - } - - public String getAudioCodec() { - return mAudioCodec; - } - - public String getConfId() { - return mConfId; - } - - public void setConfId(String confId) { - mConfId = confId; - } - - public void setCallState(CallStatus callStatus) { - mCallStatus = callStatus; - if (callStatus == CallStatus.CURRENT) { - missed = false; - mStatus = InteractionStatus.SUCCESS.toString(); - } else if (isRinging() || isOnGoing()) { - mStatus = InteractionStatus.SUCCESS.toString(); - } else if (mCallStatus == CallStatus.FAILURE) { - mStatus = InteractionStatus.FAILURE.toString(); - } - } - - public CallStatus getCallStatus() { - return mCallStatus; - } - - public void setTimestamp(long timestamp) { - mTimestamp = timestamp; - } - - public boolean isRinging() { - return mCallStatus == CallStatus.CONNECTING || mCallStatus == CallStatus.RINGING || mCallStatus == CallStatus.NONE || mCallStatus == CallStatus.SEARCHING; - } - - public boolean isOnGoing() { - return mCallStatus == CallStatus.CURRENT || mCallStatus == CallStatus.HOLD || mCallStatus == CallStatus.UNHOLD; - } - - public void setIsIncoming(Direction direction) { - mIsIncoming = (direction == Direction.INCOMING); - } - - public VCard appendToVCard(Map<String, String> messages) { - for (Map.Entry<String, String> message : messages.entrySet()) { - HashMap<String, String> messageKeyValue = VCardUtils.parseMimeAttributes(message.getKey()); - String mimeType = messageKeyValue.get(VCardUtils.VCARD_KEY_MIME_TYPE); - if (!VCardUtils.MIME_PROFILE_VCARD.equals(mimeType)) { - continue; - } - int part = Integer.parseInt(messageKeyValue.get(VCardUtils.VCARD_KEY_PART)); - int nbPart = Integer.parseInt(messageKeyValue.get(VCardUtils.VCARD_KEY_OF)); - if (null == mProfileChunk) { - mProfileChunk = new ProfileChunk(nbPart); - } - mProfileChunk.addPartAtIndex(message.getValue(), part); - if (mProfileChunk.isProfileComplete()) { - VCard ret = Ezvcard.parse(mProfileChunk.getCompleteProfile()).first(); - mProfileChunk = null; - return ret; - } - } - return null; - } - - public enum CallStatus { - NONE, - SEARCHING, - CONNECTING, - RINGING, - CURRENT, - HUNGUP, - BUSY, - FAILURE, - HOLD, - UNHOLD, - INACTIVE, - OVER; - - public static CallStatus fromString(String state) { - switch (state) { - case "SEARCHING": - return SEARCHING; - case "CONNECTING": - return CONNECTING; - case "INCOMING": - case "RINGING": - return RINGING; - case "CURRENT": - return CURRENT; - case "HUNGUP": - return HUNGUP; - case "BUSY": - return BUSY; - case "FAILURE": - return FAILURE; - case "HOLD": - return HOLD; - case "UNHOLD": - return UNHOLD; - case "INACTIVE": - return INACTIVE; - case "OVER": - return OVER; - case "NONE": - default: - return NONE; - } - } - - public static CallStatus fromConferenceString(String state) { - switch (state) { - case "ACTIVE_ATTACHED": - return CURRENT; - case "ACTIVE_DETACHED": - case "HOLD": - return HOLD; - default: - return NONE; - } - } - - } - - public enum Direction { - INCOMING(0), - OUTGOING(1); - - private final int value; - Direction(int v) { - value = v; - } - int getValue() { - return value; - } - static Direction fromInt(int value) { - return value == INCOMING.value ? INCOMING : OUTGOING; - } - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Call.kt b/ring-android/libringclient/src/main/java/net/jami/model/Call.kt new file mode 100644 index 000000000..02eaf8823 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/Call.kt @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package net.jami.model + +import net.jami.utils.StringUtils.isEmpty +import net.jami.utils.ProfileChunk +import ezvcard.VCard +import net.jami.utils.VCardUtils +import ezvcard.Ezvcard +import net.jami.utils.Log +import java.lang.Exception +import java.util.* + +class Call : Interaction { + override val daemonIdString: String? + private var isPeerHolding = false + var isAudioMuted = false + private set + private var isVideoMuted = false + private val isRecording = false + var isAudioOnly = false + private set + var callStatus = CallStatus.NONE + private set + var timestampEnd: Long = 0 + set(timestampEnd) { + field = timestampEnd + if (timestampEnd != 0L && !isMissed) duration = timestampEnd - timestamp + } + var duration: Long? = null + get() { + if (field == null) { + val element = toJson(mExtraFlag)[KEY_DURATION] + if (element != null) { + field = element.asLong + } + } + return if (field == null) 0 else field + } + set(value) { + if (value == duration) return + field = value + if (duration != null && duration != 0L) { + val jsonObject = extraFlag + jsonObject.addProperty(KEY_DURATION, value) + mExtraFlag = fromJson(jsonObject) + isMissed = false + } + } + var isMissed = true + private set + var audioCodec: String? = null + private set + var videoCodec: String? = null + private set + var contactNumber: String? = null + private set + var confId: String? = null + private var mProfileChunk: ProfileChunk? = null + + constructor( + daemonId: String?, + author: String?, + account: String?, + conversation: ConversationHistory?, + contact: Contact?, + direction: Direction + ) { + daemonIdString = daemonId + try { + this.daemonId = daemonId?.toLong() + } catch (e: Exception) { + Log.e(TAG, "Can't parse CallId $daemonId") + } + this.author = if (direction == Direction.INCOMING) author else null + this.account = account + this.conversation = conversation + isIncoming = direction == Direction.INCOMING + timestamp = System.currentTimeMillis() + mType = InteractionType.CALL.toString() + this.contact = contact + mIsRead = 1 + } + + constructor(interaction: Interaction) { + id = interaction.id + author = interaction.author + conversation = interaction.conversation + isIncoming = author != null + timestamp = interaction.timestamp + mType = InteractionType.CALL.toString() + mStatus = interaction.status.toString() + daemonId = interaction.daemonId + daemonIdString = super.daemonIdString + mIsRead = if (interaction.isRead) 1 else 0 + account = interaction.account + mExtraFlag = fromJson(interaction.extraFlag) + isMissed = duration == 0L + mIsRead = 1 + contact = interaction.contact + } + + constructor(daemonId: String?, account: String?, contactNumber: String?, direction: Direction, timestamp: Long) { + daemonIdString = daemonId + try { + this.daemonId = daemonId?.toLong() + } catch (e: Exception) { + Log.e(TAG, "Can't parse CallId $daemonId") + } + isIncoming = direction == Direction.INCOMING + this.account = account + author = if (direction == Direction.INCOMING) contactNumber else null + this.contactNumber = contactNumber + this.timestamp = timestamp + mType = InteractionType.CALL.toString() + mIsRead = 1 + } + + constructor(daemonId: String?, call_details: Map<String?, String>) : this( + daemonId, call_details[KEY_ACCOUNT_ID], call_details[KEY_PEER_NUMBER], Direction.fromInt( + call_details[KEY_CALL_TYPE]!!.toInt() + ), System.currentTimeMillis() + ) { + setCallState(CallStatus.fromString(call_details[KEY_CALL_STATE])) + setDetails(call_details) + } + + fun setDetails(details: Map<String?, String>) { + isPeerHolding = "true" == details[KEY_PEER_HOLDING] + isAudioMuted = "true" == details[KEY_AUDIO_MUTED] + isVideoMuted = "true" == details[KEY_VIDEO_MUTED] + isAudioOnly = "true" == details[KEY_AUDIO_ONLY] + audioCodec = details[KEY_AUDIO_CODEC] + videoCodec = details[KEY_VIDEO_CODEC] + val confId = details[KEY_CONF_ID] + this.confId = if (isEmpty(confId)) null else confId + } + + val isConferenceParticipant: Boolean + get() = confId != null + + val durationString: String + get() { + val mDuration = duration!! / 1000 + if (mDuration < 60) { + return String.format(Locale.getDefault(), "%02d secs", mDuration) + } + return if (mDuration < 3600) + String.format(Locale.getDefault(), "%02d mins %02d secs", mDuration % 3600 / 60, mDuration % 60) + else + String.format(Locale.getDefault(), "%d h %02d mins %02d secs", mDuration / 3600, mDuration % 3600 / 60, mDuration % 60) + } + + fun muteVideo(mute: Boolean) { + isVideoMuted = mute + } + + fun muteAudio(mute: Boolean) { + isAudioMuted = mute + } + + fun setCallState(callStatus: CallStatus) { + this.callStatus = callStatus + if (callStatus == CallStatus.CURRENT) { + isMissed = false + mStatus = InteractionStatus.SUCCESS.toString() + } else if (isRinging || isOnGoing) { + mStatus = InteractionStatus.SUCCESS.toString() + } else if (this.callStatus == CallStatus.FAILURE) { + mStatus = InteractionStatus.FAILURE.toString() + } + } + + /*override var timestamp: Long + get() = super.timestamp + set(timestamp) { + var timestamp = timestamp + timestamp = timestamp + }*/ + val isRinging: Boolean + get() = callStatus == CallStatus.CONNECTING || callStatus == CallStatus.RINGING || callStatus == CallStatus.NONE || callStatus == CallStatus.SEARCHING + val isOnGoing: Boolean + get() = callStatus == CallStatus.CURRENT || callStatus == CallStatus.HOLD || callStatus == CallStatus.UNHOLD + /*override var isIncoming: Direction + get() = super.isIncoming + set(direction) { + field = direction == Direction.INCOMING + }*/ + + fun appendToVCard(messages: Map<String, String>): VCard? { + for ((key, value) in messages) { + val messageKeyValue = VCardUtils.parseMimeAttributes(key) + val mimeType = messageKeyValue[VCardUtils.VCARD_KEY_MIME_TYPE] + if (VCardUtils.MIME_PROFILE_VCARD != mimeType) { + continue + } + val part = messageKeyValue[VCardUtils.VCARD_KEY_PART]!!.toInt() + val nbPart = messageKeyValue[VCardUtils.VCARD_KEY_OF]!!.toInt() + if (null == mProfileChunk) { + mProfileChunk = ProfileChunk(nbPart) + } + mProfileChunk?.let { profile -> + profile.addPartAtIndex(value, part) + if (profile.isProfileComplete) { + val ret = Ezvcard.parse(profile.completeProfile).first() + mProfileChunk = null + return@appendToVCard ret + } + } + } + return null + } + + enum class CallStatus { + NONE, SEARCHING, CONNECTING, RINGING, CURRENT, HUNGUP, BUSY, FAILURE, HOLD, UNHOLD, INACTIVE, OVER; + + companion object { + @JvmStatic + fun fromString(state: String?): CallStatus { + return when (state) { + "SEARCHING" -> SEARCHING + "CONNECTING" -> CONNECTING + "INCOMING", "RINGING" -> RINGING + "CURRENT" -> CURRENT + "HUNGUP" -> HUNGUP + "BUSY" -> BUSY + "FAILURE" -> FAILURE + "HOLD" -> HOLD + "UNHOLD" -> UNHOLD + "INACTIVE" -> INACTIVE + "OVER" -> OVER + "NONE" -> NONE + else -> NONE + } + } + + @JvmStatic + fun fromConferenceString(state: String?): CallStatus { + return when (state) { + "ACTIVE_ATTACHED" -> CURRENT + "ACTIVE_DETACHED", "HOLD" -> HOLD + else -> NONE + } + } + } + } + + enum class Direction(val value: Int) { + INCOMING(0), OUTGOING(1); + + companion object { + fun fromInt(value: Int): Direction { + return if (value == INCOMING.value) INCOMING else OUTGOING + } + } + } + + companion object { + val TAG = Call::class.simpleName!! + const val KEY_ACCOUNT_ID = "ACCOUNTID" + const val KEY_AUDIO_ONLY = "AUDIO_ONLY" + const val KEY_CALL_TYPE = "CALL_TYPE" + const val KEY_CALL_STATE = "CALL_STATE" + const val KEY_PEER_NUMBER = "PEER_NUMBER" + const val KEY_PEER_HOLDING = "PEER_HOLDING" + const val KEY_AUDIO_MUTED = "PEER_NUMBER" + const val KEY_VIDEO_MUTED = "VIDEO_MUTED" + const val KEY_AUDIO_CODEC = "AUDIO_CODEC" + const val KEY_VIDEO_CODEC = "VIDEO_CODEC" + const val KEY_REGISTERED_NAME = "REGISTERED_NAME" + const val KEY_DURATION = "duration" + const val KEY_CONF_ID = "CONF_ID" + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Conference.java b/ring-android/libringclient/src/main/java/net/jami/model/Conference.java deleted file mode 100644 index efc9b29d9..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/Conference.java +++ /dev/null @@ -1,242 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ - -package net.jami.model; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public class Conference { - - public static class ParticipantInfo { - public final Call call; - public final Contact contact; - public int x, y, w, h; - public boolean videoMuted, audioMuted, isModerator; - - public ParticipantInfo(Call call, Contact c, Map<String, String> i) { - this.call = call; - contact = c; - x = Integer.parseInt(i.get("x")); - y = Integer.parseInt(i.get("y")); - w = Integer.parseInt(i.get("w")); - h = Integer.parseInt(i.get("h")); - videoMuted = Boolean.parseBoolean(i.get("videoMuted")); - audioMuted = Boolean.parseBoolean(i.get("audioMuted")); - isModerator = Boolean.parseBoolean(i.get("isModerator")); - } - - public boolean isEmpty() { - return x == 0 && y == 0 && w == 0 && h == 0; - } - } - private final Subject<List<ParticipantInfo>> mParticipantInfo = BehaviorSubject.createDefault(Collections.emptyList()); - - private final Set<Contact> mParticipantRecordingSet = new HashSet<>(); - private final Subject<Set<Contact>> mParticipantRecording = BehaviorSubject.createDefault(Collections.emptySet()); - - private final String mId; - private Call.CallStatus mConfState; - private final ArrayList<Call> mParticipants; - private boolean mRecording; - private Contact mMaximizedParticipant; - private boolean isModerator; - - public Conference(Call call) { - this(call.getDaemonIdString()); - mParticipants.add(call); - } - - public Conference(String cID) { - mId = cID; - mParticipants = new ArrayList<>(); - mRecording = false; - } - - public Conference(Conference c) { - mId = c.mId; - mConfState = c.mConfState; - mParticipants = new ArrayList<>(c.mParticipants); - mRecording = c.mRecording; - } - - public boolean isRinging() { - return !mParticipants.isEmpty() && mParticipants.get(0).isRinging(); - } - - public boolean isConference() { - return mParticipants.size() > 1; - } - - public Call getCall() { - if (!isConference()) { - return getFirstCall(); - } - return null; - } - public Call getFirstCall() { - if (!mParticipants.isEmpty()) { - return mParticipants.get(0); - } - return null; - } - - public String getId() { - return mId; - } - - public void setMaximizedParticipant(Contact contact) { - mMaximizedParticipant = contact; - } - - public Contact getMaximizedParticipant() { - return mMaximizedParticipant; - } - - public String getPluginId() { - return "local"; - } - - public String getConfId() { - return mId; - } - - public void setIsModerator(boolean isModerator) { - this.isModerator = isModerator; - } - - public boolean getIsModerator() { - return isModerator; - } - - public Call.CallStatus getState() { - if (isSimpleCall()) { - return mParticipants.get(0).getCallStatus(); - } - return mConfState; - } - - public Call.CallStatus getConfState() { - if (mParticipants.size() == 1) { - return mParticipants.get(0).getCallStatus(); - } - return mConfState; - } - - public boolean isSimpleCall() { - return mParticipants.size() == 1 && mId.equals(mParticipants.get(0).getDaemonIdString()); - } - - public void setState(String state) { - mConfState = Call.CallStatus.fromConferenceString(state); - } - - public List<Call> getParticipants() { - return mParticipants; - } - - public void addParticipant(Call part) { - mParticipants.add(part); - } - - public boolean removeParticipant(Call toRemove) { - return mParticipants.remove(toRemove); - } - - public boolean contains(String callID) { - for (Call participant : mParticipants) { - if (participant.getDaemonIdString().contentEquals(callID)) - return true; - } - return false; - } - - public Call getCallById(String callID) { - for (Call participant : mParticipants) { - if (participant.getDaemonIdString().contentEquals(callID)) - return participant; - } - return null; - } - - public Call findCallByContact(Uri uri) { - for (Call call : mParticipants) { - if (call.getContact().getUri().toString().equals(uri.toString())) - return call; - } - return null; - } - - public boolean isIncoming() { - return mParticipants.size() == 1 && mParticipants.get(0).isIncoming(); - } - - public boolean isOnGoing() { - return mParticipants.size() == 1 && mParticipants.get(0).isOnGoing() || mParticipants.size() > 1; - } - - public boolean hasVideo() { - for (Call call : mParticipants) - if (!call.isAudioOnly()) - return true; - return false; - } - - public long getTimestampStart() { - long t = Long.MAX_VALUE; - for (Call call : mParticipants) - t = Math.min(call.getTimestamp(), t); - return t; - } - - public void removeParticipants() { - mParticipants.clear(); - } - - public void setInfo(List<ParticipantInfo> info) { - mParticipantInfo.onNext(info); - } - - public Observable<List<ParticipantInfo>> getParticipantInfo() { - return mParticipantInfo; - } - public Observable<Set<Contact>> getParticipantRecording() { - return mParticipantRecording; - } - - public void setParticipantRecording(Contact contact, boolean state) { - if (state) { - mParticipantRecordingSet.add(contact); - } else { - mParticipantRecordingSet.remove(contact); - } - mParticipantRecording.onNext(mParticipantRecordingSet); - } - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Conference.kt b/ring-android/libringclient/src/main/java/net/jami/model/Conference.kt new file mode 100644 index 000000000..df220e288 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/Conference.kt @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.model + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.model.Call.CallStatus +import net.jami.model.Call.CallStatus.Companion.fromConferenceString +import java.util.* +import kotlin.math.min + +class Conference { + class ParticipantInfo(val call: Call?, val contact: Contact, i: Map<String, String>) { + var x: Int = i["x"]?.toInt() ?: 0 + var y: Int = i["y"]?.toInt() ?: 0 + var w: Int = i["w"]?.toInt() ?: 0 + var h: Int = i["h"]?.toInt() ?: 0 + var videoMuted: Boolean = java.lang.Boolean.parseBoolean(i["videoMuted"]) + var audioMuted: Boolean = java.lang.Boolean.parseBoolean(i["audioMuted"]) + var isModerator: Boolean = java.lang.Boolean.parseBoolean(i["isModerator"]) + val isEmpty: Boolean + get() = x == 0 && y == 0 && w == 0 && h == 0 + } + + private val mParticipantInfo: Subject<List<ParticipantInfo>> = BehaviorSubject.createDefault(emptyList()) + private val mParticipantRecordingSet: MutableSet<Contact> = HashSet() + private val mParticipantRecording: Subject<Set<Contact>> = BehaviorSubject.createDefault(emptySet()) + val id: String + private var mConfState: CallStatus? = null + private val mParticipants: ArrayList<Call> + private var mRecording: Boolean + var maximizedParticipant: Contact? = null + var isModerator = false + + constructor(call: Call) : this(call.daemonIdString!!) { + mParticipants.add(call) + } + + constructor(cID: String) { + id = cID + mParticipants = ArrayList() + mRecording = false + } + + constructor(c: Conference) { + id = c.id + mConfState = c.mConfState + mParticipants = ArrayList(c.mParticipants) + mRecording = c.mRecording + } + + val isRinging: Boolean + get() = mParticipants.isNotEmpty() && mParticipants[0].isRinging + val isConference: Boolean + get() = mParticipants.size > 1 + val call: Call? + get() = if (!isConference) { + firstCall + } else null + val firstCall: Call? + get() = if (mParticipants.isNotEmpty()) { + mParticipants[0] + } else null + val pluginId: String + get() = "local" + val state: CallStatus? + get() = if (isSimpleCall) { + mParticipants[0].callStatus + } else mConfState + val confState: CallStatus? + get() = if (mParticipants.size == 1) { + mParticipants[0].callStatus + } else mConfState + + val isSimpleCall: Boolean + get() = mParticipants.size == 1 && id == mParticipants[0].daemonIdString + + fun setState(state: String?) { + mConfState = fromConferenceString(state) + } + + val participants: MutableList<Call> + get() = mParticipants + + fun addParticipant(part: Call) { + mParticipants.add(part) + } + + fun removeParticipant(toRemove: Call): Boolean { + return mParticipants.remove(toRemove) + } + + operator fun contains(callID: String?): Boolean { + for (participant in mParticipants) { + if (participant.daemonIdString.contentEquals(callID)) return true + } + return false + } + + fun getCallById(callID: String?): Call? { + for (participant in mParticipants) { + if (participant.daemonIdString.contentEquals(callID)) return participant + } + return null + } + + fun findCallByContact(uri: Uri): Call? { + for (call in mParticipants) { + if (call.contact!!.uri.toString() == uri.toString()) return call + } + return null + } + + val isIncoming: Boolean + get() = mParticipants.size == 1 && mParticipants[0].isIncoming + val isOnGoing: Boolean + get() = mParticipants.size == 1 && mParticipants[0].isOnGoing || mParticipants.size > 1 + + fun hasVideo(): Boolean { + for (call in mParticipants) if (!call.isAudioOnly) return true + return false + } + + val timestampStart: Long + get() { + var t = Long.MAX_VALUE + for (call in mParticipants) t = min(call.timestamp, t) + return t + } + + fun removeParticipants() { + mParticipants.clear() + } + + fun setInfo(info: List<ParticipantInfo>) { + mParticipantInfo.onNext(info) + } + + val participantInfo: Observable<List<ParticipantInfo>> + get() = mParticipantInfo + val participantRecording: Observable<Set<Contact>> + get() = mParticipantRecording + + fun setParticipantRecording(contact: Contact, state: Boolean) { + if (state) { + mParticipantRecordingSet.add(contact) + } else { + mParticipantRecordingSet.remove(contact) + } + mParticipantRecording.onNext(mParticipantRecordingSet) + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/ConfigKey.java b/ring-android/libringclient/src/main/java/net/jami/model/ConfigKey.kt similarity index 84% rename from ring-android/libringclient/src/main/java/net/jami/model/ConfigKey.java rename to ring-android/libringclient/src/main/java/net/jami/model/ConfigKey.kt index d6b6bc63a..82927d0ab 100644 --- a/ring-android/libringclient/src/main/java/net/jami/model/ConfigKey.java +++ b/ring-android/libringclient/src/main/java/net/jami/model/ConfigKey.kt @@ -17,9 +17,9 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ -package net.jami.model; +package net.jami.model -public enum ConfigKey { +enum class ConfigKey { MAILBOX("Account.mailbox"), REGISTRATION_EXPIRE("Account.registrationExpire"), CREDENTIAL_NUMBER("Credential.count"), @@ -57,7 +57,7 @@ public enum ConfigKey { ACCOUNT_ACTIVE("Account.active", true), ACCOUNT_DEVICE_ID("Account.deviceID"), ACCOUNT_DEVICE_NAME("Account.deviceName"), - ACCOUNT_PEER_DISCOVERY("Account.peerDiscovery",true), + ACCOUNT_PEER_DISCOVERY("Account.peerDiscovery", true), ACCOUNT_DISCOVERY("Account.accountDiscovery", true), ACCOUNT_PUBLISH("Account.accountPublish", true), ACCOUNT_DISPLAYNAME("Account.displayName"), @@ -103,36 +103,36 @@ public enum ConfigKey { MANAGER_URI("Account.managerUri"), MANAGER_USERNAME("Account.managerUsername"); - private final String mKey; - private final boolean mIsBool; + private val mKey: String + val isTwoState: Boolean - ConfigKey(String key) { - mKey = key; - mIsBool = false; - } - ConfigKey(String key, boolean isBool) { - mKey = key; - mIsBool = isBool; + constructor(key: String) { + mKey = key + isTwoState = false } - public String key() { - return mKey; + constructor(key: String, isBool: Boolean) { + mKey = key + isTwoState = isBool } - public boolean equals(ConfigKey other) { - return other != null && mKey.equals(other.mKey); + fun key(): String { + return mKey } - public boolean isTwoState() { - return mIsBool; + fun equals(other: ConfigKey?): Boolean { + return other != null && mKey == other.mKey } - public static ConfigKey fromString(String stringKey) { - for (ConfigKey confKey : ConfigKey.values()) { - if (stringKey.contentEquals(confKey.mKey) || stringKey.equals(confKey.mKey)) { - return confKey; + companion object { + @JvmStatic + fun fromString(stringKey: String): ConfigKey? { + for (confKey in values()) { + if (stringKey.contentEquals(confKey.mKey) || stringKey == confKey.mKey) { + return confKey + } } + return null } - return null; } -} +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Contact.java b/ring-android/libringclient/src/main/java/net/jami/model/Contact.java deleted file mode 100644 index 4740a0438..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/Contact.java +++ /dev/null @@ -1,361 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.model; - -import net.jami.utils.StringUtils; - -import java.util.ArrayList; -import java.util.Date; - -import io.reactivex.rxjava3.core.Emitter; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public class Contact { - protected static final String TAG = Contact.class.getSimpleName(); - - public static final int UNKNOWN_ID = -1; - public static final int DEFAULT_ID = 0; - public static final String PREFIX_RING = Uri.RING_URI_SCHEME; - - public enum Status {BANNED, REQUEST_SENT, CONFIRMED, NO_REQUEST} - - private final Uri mUri; - - private String mUsername = null; - private long mPhotoId; - private final ArrayList<Phone> mPhones = new ArrayList<>(); - private final boolean isUser; - private boolean stared = false; - private boolean isFromSystem = false; - private Status mStatus = Status.NO_REQUEST; - private Date mAddedDate = null; - private boolean mOnline = false; - - private long mId; - private String mLookupKey; - - private boolean usernameLoaded = false; - public boolean detailsLoaded = false; - //private Uri mConversationUri = null; - private final BehaviorSubject<Uri> mConversationUri; - - // Profile - private String mDisplayName; - private Object mContactPhoto = null; - - private final Subject<Contact> mContactUpdates = BehaviorSubject.create(); - private Observable<Contact> mContactObservable; - - private Observable<Boolean> mContactPresenceObservable; - private Emitter<Boolean> mContactPresenceEmitter; - - public Contact(Uri uri) { - this(uri, false); - } - - public Contact(Uri uri, boolean user) { - this(uri, null, user); - } - - private Contact(Uri uri, String displayName, boolean user) { - mUri = uri; - mDisplayName = displayName; - isUser = user; - mConversationUri = BehaviorSubject.createDefault(mUri); - /*if (cID != UNKNOWN_ID && (displayName == null || !displayName.contains(PREFIX_RING))) { - mStatus = Status.CONFIRMED; - }*/ - } - - public void setConversationUri(Uri conversationUri) { - mConversationUri.onNext(conversationUri); - } - - public Observable<Uri> getConversationUri() { - return mConversationUri; - } - - public static Contact buildSIP(Uri to) { - Contact contact = new Contact(to); - contact.usernameLoaded = true; - return contact; - } - - public static Contact build(String uri, boolean isUser) { - return new Contact(Uri.fromString(uri), isUser); - } - public static Contact build(String uri) { - return build(uri, false); - } - - public Observable<Contact> getUpdatesSubject() { - return mContactUpdates; - } - public Observable<Contact> getUpdates() { - return mContactObservable; - } - public void setUpdates(Observable<Contact> observable) { - mContactObservable = observable; - } - - public Observable<Boolean> getPresenceUpdates() { - return mContactPresenceObservable; - } - public void setPresenceUpdates(Observable<Boolean> observable) { - mContactPresenceObservable = observable; - } - public void setPresenceEmitter(Emitter<Boolean> emitter) { - if (mContactPresenceEmitter != null && mContactPresenceEmitter != emitter) { - mContactPresenceEmitter.onComplete(); - } - mContactPresenceEmitter = emitter; - } - - public boolean matches(String query) { - return (mDisplayName != null && mDisplayName.toLowerCase().contains(query)) - || (mUsername != null && mUsername.contains(query)) - || (getPrimaryNumber().contains(query)); - } - - public boolean isOnline() { - return mOnline; - } - - public void setOnline(boolean present) { - mOnline = present; - if (mContactPresenceEmitter != null) - mContactPresenceEmitter.onNext(present); - } - - public void setSystemId(long id) { - mId = id; - } - - public void setSystemContactInfo(long id, String k, String displayName, long photo_id) { - mId = id; - mLookupKey = k; - mDisplayName = displayName; - this.mPhotoId = photo_id; - if (mUsername == null && displayName.contains(PREFIX_RING)) { - mUsername = displayName; - } - } - - public static String canonicalNumber(String number) { - if (number == null || number.isEmpty()) - return null; - return Uri.fromString(number).getRawUriString(); - } - - public ArrayList<String> getIds() { - ArrayList<String> ret = new ArrayList<>(mPhones.size() + (mId == UNKNOWN_ID ? 0 : 1)); - if (mId != UNKNOWN_ID) - ret.add("c:" + Long.toHexString(mId)); - for (Phone p : mPhones) - ret.add(p.getNumber().getRawUriString()); - return ret; - } - - public static long contactIdFromId(String id) { - if (!id.startsWith("c:")) - return UNKNOWN_ID; - try { - return Long.parseLong(id.substring(2), 16); - } catch (Exception e) { - return UNKNOWN_ID; - } - } - - public long getId() { - return mId; - } - - public String getDisplayName() { - return !StringUtils.isEmpty(mDisplayName) ? mDisplayName : getRingUsername(); - } - - public String getProfileName() { - return mDisplayName; - } - - public long getPhotoId() { - return mPhotoId; - } - - public ArrayList<Phone> getPhones() { - return mPhones; - } - - public boolean hasNumber(String number) { - return hasNumber(Uri.fromString(number)); - } - - public boolean hasNumber(Uri number) { - if (number == null || number.isEmpty()) - return false; - for (Phone p : mPhones) - if (p.getNumber().toString().equals(number.toString())) - return true; - return false; - } - - @Override - public String toString() { - if (!StringUtils.isEmpty(mUsername)) { - return mUsername; - } else { - return getUri().getRawUriString(); - } - } - - public void setId(long id) { - this.mId = id; - } - - /*public String getKey() { - return mKey; - }*/ - - public String getPrimaryNumber() { - return getUri().getRawRingId(); - } - public Uri getUri() { - return mUri; - } - - public void setStared() { - this.stared = true; - } - - public boolean isStared() { - return stared; - } - - public void addPhoneNumber(Uri tel, int cat, String label) { - if (!hasNumber(tel)) - mPhones.add(new Phone(tel, cat, label)); - } - - public void addNumber(String tel, int cat, String label, Phone.NumberType type) { - if (!hasNumber(tel)) - mPhones.add(new Phone(tel, cat, label, type)); - } - - public void addNumber(Uri tel, int cat, String label, Phone.NumberType type) { - if (!hasNumber(tel)) - mPhones.add(new Phone(tel, cat, label, type)); - } - - public boolean isUser() { - return isUser; - } - - public boolean hasPhoto() { - return mContactPhoto != null; - } - - public Object getPhoto() { - return mContactPhoto; - } - - public void setPhoto(Object externalArray) { - mContactPhoto = externalArray; - } - - public boolean isFromSystem() { - return isFromSystem; - } - - public Status getStatus() { - return mStatus; - } - - public void setStatus(Status status) { - mStatus = status; - } - - public boolean isBanned() { return mStatus == Status.BANNED; } - - public void setFromSystem(boolean fromSystem) { - isFromSystem = fromSystem; - } - - public void setAddedDate(Date addedDate) { - mAddedDate = addedDate; - } - public Date getAddedDate() { - return mAddedDate; - } - - /** - * A contact is Unknown when his name == his phone number - * - * @return true when Name == Number - */ - public boolean isUnknown() { - return mDisplayName == null || mDisplayName.contentEquals(mPhones.get(0).getNumber().getRawUriString()); - } - - public void setDisplayName(String displayName) { - mDisplayName = displayName; - } - - public String getRingUsername() { - if (!StringUtils.isEmpty(mUsername)) { - return mUsername; - } else if (usernameLoaded) { - return getUri().getRawUriString(); - } else { - return ""; - } - } - - public String getUsername() { - return mUsername; - } - - public boolean setUsername(String name) { - if (!usernameLoaded || (name != null && !name.equals(mUsername))) { - mUsername = name; - usernameLoaded = true; - mContactUpdates.onNext(this); - return true; - } - return false; - } - - public boolean isUsernameLoaded() { - return usernameLoaded; - } - - public void setProfile(String name, Object photo) { - if (!StringUtils.isEmpty(name) && !name.startsWith(Uri.RING_URI_SCHEME)) { - setDisplayName(name); - } - if (photo != null) { - setPhoto(photo); - } - detailsLoaded = true; - mContactUpdates.onNext(this); - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Contact.kt b/ring-android/libringclient/src/main/java/net/jami/model/Contact.kt new file mode 100644 index 000000000..dcaf93995 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/Contact.kt @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.model + +import io.reactivex.rxjava3.core.Emitter +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.utils.StringUtils +import java.lang.Exception +import java.util.* +import kotlin.jvm.JvmOverloads + +class Contact private constructor( + val uri: Uri, // Profile + var profileName: String?, + val isUser: Boolean +) { + enum class Status { + BANNED, REQUEST_SENT, CONFIRMED, NO_REQUEST + } + + var username: String? = null + private set + var photoId: Long = 0 + private set + val phones = ArrayList<Phone>() + var isStared = false + private set + var isFromSystem = false + var status = Status.NO_REQUEST + var addedDate: Date? = null + private var mOnline = false + var id: Long = 0 + private var mLookupKey: String? = null + var isUsernameLoaded = false + private set + @JvmField + var detailsLoaded = false + private val mConversationUri: BehaviorSubject<Uri> = BehaviorSubject.createDefault(uri) + var photo: Any? = null + private val mContactUpdates: Subject<Contact> = BehaviorSubject.create() + var updates: Observable<Contact>? = null + var presenceUpdates: Observable<Boolean>? = null + private var mContactPresenceEmitter: Emitter<Boolean>? = null + + @JvmOverloads + constructor(uri: Uri, user: Boolean = false) : this(uri, null, user) { + } + + fun setConversationUri(conversationUri: Uri) { + mConversationUri.onNext(conversationUri) + } + + val conversationUri: Observable<Uri> + get() = mConversationUri + val updatesSubject: Observable<Contact> + get() = mContactUpdates + + fun setPresenceEmitter(emitter: Emitter<Boolean>?) { + mContactPresenceEmitter?.let { e -> + if (e != emitter) + e.onComplete() + } + mContactPresenceEmitter = emitter + } + + fun matches(query: String): Boolean { + return (profileName != null && profileName!!.lowercase().contains(query) + || username != null && username!!.contains(query) + || primaryNumber.contains(query)) + } + + var isOnline: Boolean + get() = mOnline + set(present) { + mOnline = present + if (mContactPresenceEmitter != null) mContactPresenceEmitter!!.onNext(present) + } + + fun setSystemId(id: Long) { + this.id = id + } + + fun setSystemContactInfo(id: Long, k: String?, displayName: String, photo_id: Long) { + this.id = id + mLookupKey = k + profileName = displayName + photoId = photo_id + if (username == null && displayName.contains(PREFIX_RING)) { + username = displayName + } + } + + val ids: ArrayList<String> + get() { + val ret = ArrayList<String>(phones.size + if (id == UNKNOWN_ID.toLong()) 0 else 1) + if (id != UNKNOWN_ID.toLong()) ret.add( + "c:" + java.lang.Long.toHexString( + id + ) + ) + for (p in phones) ret.add(p.number.rawUriString) + return ret + } + var displayName: String + get() { + val profileName = profileName + return if (profileName != null && profileName.isNotEmpty()) profileName else ringUsername + } + set(displayName) { + profileName = displayName + } + + fun hasNumber(number: String): Boolean { + return hasNumber(Uri.fromString(number)) + } + + fun hasNumber(number: Uri?): Boolean { + if (number == null || number.isEmpty) return false + for (p in phones) if (p.number.toString() == number.toString()) return true + return false + } + + override fun toString(): String { + username?.let { username -> if (username.isNotEmpty()) return@toString username } + return uri.rawUriString + } + + val primaryNumber: String + get() = uri.rawRingId + + fun setStared() { + isStared = true + } + + fun addPhoneNumber(tel: Uri, cat: Int, label: String?) { + if (!hasNumber(tel)) phones.add(Phone(tel, cat, label)) + } + + fun addNumber(tel: String, cat: Int, label: String?, type: Phone.NumberType?) { + if (!hasNumber(tel)) phones.add(Phone(tel, cat, label, type)) + } + + fun addNumber(tel: Uri, cat: Int, label: String?, type: Phone.NumberType?) { + if (!hasNumber(tel)) phones.add(Phone(tel, cat, label, type)) + } + + fun hasPhoto(): Boolean { + return photo != null + } + + val isBanned: Boolean + get() = status == Status.BANNED + + /** + * A contact is Unknown when his name == his phone number + * + * @return true when Name == Number + */ + val isUnknown: Boolean + get() = profileName == null || profileName.contentEquals(phones[0].number.rawUriString) + val ringUsername: String + get() { + val username = username + return if (username != null && username.isNotEmpty()) { + username + } else if (isUsernameLoaded) { + uri.rawUriString + } else { + "" + } + } + + fun setUsername(name: String?): Boolean { + if (!isUsernameLoaded || name != null && name != username) { + username = name + isUsernameLoaded = true + mContactUpdates.onNext(this) + return true + } + return false + } + + fun setProfile(name: String?, photo: Any?) { + if (name != null && name.isNotEmpty() && !name.startsWith(Uri.RING_URI_SCHEME)) { + profileName = name + } + if (photo != null) { + this.photo = photo + } + detailsLoaded = true + mContactUpdates.onNext(this) + } + + companion object { + private val TAG = Contact::class.simpleName!! + const val UNKNOWN_ID = -1 + const val DEFAULT_ID = 0 + const val PREFIX_RING = Uri.RING_URI_SCHEME + + @JvmStatic + fun buildSIP(to: Uri): Contact { + val contact = Contact(to) + contact.isUsernameLoaded = true + return contact + } + + @JvmStatic + @JvmOverloads + fun build(uri: String, isUser: Boolean = false): Contact { + return Contact(Uri.fromString(uri), isUser) + } + + fun canonicalNumber(number: String?): String? { + return if (number == null || number.isEmpty()) null else Uri.fromString(number).rawUriString + } + + fun contactIdFromId(id: String): Long { + return if (!id.startsWith("c:")) UNKNOWN_ID.toLong() else try { + id.substring(2).toLong(16) + } catch (e: Exception) { + UNKNOWN_ID.toLong() + } + } + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/ContactEvent.java b/ring-android/libringclient/src/main/java/net/jami/model/ContactEvent.java deleted file mode 100644 index c3682e938..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/ContactEvent.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.model; - -public class ContactEvent extends Interaction { - - public TrustRequest request; - public Event event; - - - public ContactEvent(Interaction interaction) { - mId = interaction.getId(); - mConversation = interaction.getConversation(); - mAuthor = interaction.getAuthor(); - mType = InteractionType.CONTACT.toString(); - mTimestamp = interaction.getTimestamp(); - mStatus = interaction.getStatus().toString(); - mIsRead = 1; - mContact = interaction.getContact(); - event = getEventFromStatus(interaction.getStatus()); - } - - public ContactEvent() { - mAuthor = null; - event = Event.ADDED; - mType = InteractionType.CONTACT.toString(); - mTimestamp = System.currentTimeMillis(); - mStatus = InteractionStatus.SUCCESS.toString(); - mIsRead = 1; - } - - public ContactEvent(Contact contact) { - mContact = contact; - mAuthor = contact.getUri().getUri(); - mType = InteractionType.CONTACT.toString(); - event = Event.ADDED; - mStatus = InteractionStatus.SUCCESS.toString(); - mTimestamp = contact.getAddedDate().getTime(); - mIsRead = 1; - } - - public ContactEvent(Contact contact, TrustRequest request) { - this.request = request; - mContact = contact; - mAuthor = contact.getUri().getUri(); - mTimestamp = request.getTimestamp(); - mType = InteractionType.CONTACT.toString(); - event = Event.INCOMING_REQUEST; - mStatus = InteractionStatus.UNKNOWN.toString(); - mIsRead = 1; - } - - public enum Event { - UNKNOWN, - INCOMING_REQUEST, - ADDED, - REMOVED, - BANNED - } - - public void setEvent(Event event) { - this.event = event; - } - - public void setRequest(TrustRequest request) { - this.request = request; - } - - private Event getEventFromStatus(InteractionStatus status) { - // success for added contacts - if (status == InteractionStatus.SUCCESS) - return Event.ADDED; - // storage is unknown status for trust requests - else if (status == InteractionStatus.UNKNOWN) - return Event.INCOMING_REQUEST; - - return Event.UNKNOWN; - } - - public void setTimestamp(long timestamp) { - mTimestamp = timestamp; - } - - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/ContactEvent.kt b/ring-android/libringclient/src/main/java/net/jami/model/ContactEvent.kt new file mode 100644 index 000000000..c5e6222c6 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/ContactEvent.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.model + +class ContactEvent : Interaction { + var request: TrustRequest? = null + @JvmField + var event: Event + + constructor(interaction: Interaction) { + id = interaction.id + conversation = interaction.conversation + author = interaction.author + mType = InteractionType.CONTACT.toString() + timestamp = interaction.timestamp + mStatus = interaction.status.toString() + mIsRead = 1 + contact = interaction.contact + event = getEventFromStatus(interaction.status) + } + + constructor() { + author = null + event = Event.ADDED + mType = InteractionType.CONTACT.toString() + timestamp = System.currentTimeMillis() + mStatus = InteractionStatus.SUCCESS.toString() + mIsRead = 1 + } + + constructor(contact: Contact) { + this.contact = contact + author = contact.uri.uri + mType = InteractionType.CONTACT.toString() + event = Event.ADDED + mStatus = InteractionStatus.SUCCESS.toString() + timestamp = contact.addedDate!!.time + mIsRead = 1 + } + + constructor(contact: Contact, request: TrustRequest) { + this.request = request + this.contact = contact + author = contact.uri.uri + timestamp = request.timestamp + mType = InteractionType.CONTACT.toString() + event = Event.INCOMING_REQUEST + mStatus = InteractionStatus.UNKNOWN.toString() + mIsRead = 1 + } + + enum class Event { + UNKNOWN, INCOMING_REQUEST, INVITED, ADDED, REMOVED, BANNED; + + companion object { + fun fromConversationAction(action: String): Event { + return when (action) { + "add" -> INVITED + "join" -> ADDED + "remove" -> REMOVED + "ban" -> BANNED + else -> UNKNOWN + } + } + } + } + + fun setEvent(event: Event): ContactEvent { + this.event = event + return this + } + + private fun getEventFromStatus(status: InteractionStatus): Event { + // success for added contacts + if (status === InteractionStatus.SUCCESS) return Event.ADDED else if (status === InteractionStatus.UNKNOWN) return Event.INCOMING_REQUEST + return Event.UNKNOWN + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Conversation.java b/ring-android/libringclient/src/main/java/net/jami/model/Conversation.java deleted file mode 100644 index b8c74e702..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/Conversation.java +++ /dev/null @@ -1,763 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ - -package net.jami.model; - -import net.jami.utils.Log; -import net.jami.utils.StringUtils; -import net.jami.utils.Tuple; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.NavigableMap; -import java.util.Set; -import java.util.TreeMap; - -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.PublishSubject; -import io.reactivex.rxjava3.subjects.SingleSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public class Conversation extends ConversationHistory { - private static final String TAG = Conversation.class.getSimpleName(); - - private final String mAccountId; - private final Uri mKey; - private final List<Contact> mContacts; - - private final NavigableMap<Long, Interaction> mHistory = new TreeMap<>(); - private final ArrayList<Conference> mCurrentCalls = new ArrayList<>(); - private final ArrayList<Interaction> mAggregateHistory = new ArrayList<>(32); - private Interaction lastDisplayed = null; - - private final Subject<Tuple<Interaction, ElementStatus>> updatedElementSubject = PublishSubject.create(); - private final Subject<Interaction> lastDisplayedSubject = BehaviorSubject.create(); - private final Subject<List<Interaction>> clearedSubject = PublishSubject.create(); - private final Subject<List<Conference>> callsSubject = BehaviorSubject.create(); - private final Subject<Account.ComposingStatus> composingStatusSubject = BehaviorSubject.createDefault(Account.ComposingStatus.Idle); - private final Subject<Integer> color = BehaviorSubject.create(); - private final Subject<CharSequence> symbol = BehaviorSubject.create(); - private final Subject<List<Contact>> mContactSubject = BehaviorSubject.create(); - - private Single<Conversation> isLoaded = null; - private Completable lastElementLoaded = null; - - private final Set<String> mRoots = new HashSet<>(2); - private final Map<String, Interaction> mMessages = new HashMap<>(16); - private String lastRead = null; - private final Mode mMode; - - // runtime flag set to true if the user is currently viewing this conversation - private boolean mVisible = false; - private final Subject<Boolean> mVisibleSubject = BehaviorSubject.createDefault(mVisible); - - // indicate the list needs sorting - private boolean mDirty = false; - private SingleSubject<Conversation> mLoadingSubject = null; - - public Conversation(String accountId, Contact contact) { - mAccountId = accountId; - mContacts = Collections.singletonList(contact); - mKey = contact.getUri(); - mParticipant = contact.getUri().getUri(); - mContactSubject.onNext(mContacts); - mMode = null; - } - - public Conversation(String accountId, Uri uri, Mode mode) { - mAccountId = accountId; - mKey = uri; - mContacts = new ArrayList<>(3); - mMode = mode; - } - - public Conference getConference(String id) { - for (Conference c : mCurrentCalls) - if (c.getId().contentEquals(id) || c.getCallById(id) != null) { - return c; - } - return null; - } - - public void composingStatusChanged(Contact contact, Account.ComposingStatus composing) { - composingStatusSubject.onNext(composing); - } - - public Uri getUri() { - return mKey; - } - - public Mode getMode() { return mMode; } - - public boolean isSwarm() { - return Uri.SWARM_SCHEME.equals(getUri().getScheme()); - } - - public boolean matches(String query) { - for (Contact contact : getContacts()) { - if (contact.matches(query)) - return true; - } - return false; - } - - public String getDisplayName() { - return mContacts.get(0).getDisplayName(); - } - - public void addContact(Contact contact) { - mContacts.add(contact); - mContactSubject.onNext(mContacts); - } - - public void removeContact(Contact contact) { - mContacts.remove(contact); - mContactSubject.onNext(mContacts); - } - - public String getTitle() { - if (mContacts.isEmpty()) { - return null; - } else if (mContacts.size() == 1) { - return mContacts.get(0).getDisplayName(); - } - ArrayList<String> names = new ArrayList<>(mContacts.size()); - int target = mContacts.size(); - for (Contact c : mContacts) { - if (c.isUser()) { - target--; - continue; - } - String displayName = c.getDisplayName(); - if (!StringUtils.isEmpty(displayName)) { - names.add(displayName); - if (names.size() == 3) - break; - } - } - StringBuilder ret = new StringBuilder(); - ret.append(StringUtils.join(", ", names)); - if (!names.isEmpty() && names.size() < target) { - ret.append(" + ").append(mContacts.size() - names.size()); - } - String result = ret.toString(); - return result.isEmpty() ? mKey.getRawUriString() : result; - } - - public String getUriTitle() { - if (mContacts.isEmpty()) { - return null; - } else if (mContacts.size() == 1) { - return mContacts.get(0).getRingUsername(); - } - ArrayList<String> names = new ArrayList<>(mContacts.size()); - for (Contact c : mContacts) { - if (c.isUser()) - continue; - names.add(c.getRingUsername()); - } - return StringUtils.join(", ", names); - } - - public Observable<List<Contact>> getContactUpdates() { - return mContactSubject; - } - - public synchronized String readMessages() { - Interaction interaction = null; - //for (String branch : mBranches) { - Interaction i = mAggregateHistory.get(mAggregateHistory.size() - 1); - if (i != null && !i.isRead()) { - i.read(); - interaction = i; - lastRead = i.getMessageId(); - } - //} - return interaction == null ? null : interaction.getMessageId(); - } - - public synchronized Interaction getMessage(String messageId) { - return mMessages.get(messageId); - } - - public void setLastMessageRead(String lastMessageRead) { - lastRead = lastMessageRead; - } - - public String getLastRead() { - return lastRead; - } - - public SingleSubject<Conversation> getLoading() { - return mLoadingSubject; - } - - public boolean stopLoading() { - SingleSubject<Conversation> ret = mLoadingSubject; - mLoadingSubject = null; - if (ret != null) { - ret.onSuccess(this); - return true; - } - return false; - } - - public void setLoading(SingleSubject<Conversation> l) { - if (mLoadingSubject != null) { - if (!mLoadingSubject.hasValue() && !mLoadingSubject.hasThrowable()) - mLoadingSubject.onError(new IllegalStateException()); - } - mLoadingSubject = l; - } - - public Completable getLastElementLoaded() { - return lastElementLoaded; - } - - public void setLastElementLoaded(Completable c) { - lastElementLoaded = c; - } - - public enum ElementStatus { - UPDATE, REMOVE, ADD - } - - public Observable<Tuple<Interaction, ElementStatus>> getUpdatedElements() { - return updatedElementSubject; - } - - public Observable<Interaction> getLastDisplayed() { - return lastDisplayedSubject; - } - - public Observable<List<Interaction>> getCleared() { - return clearedSubject; - } - - public Observable<List<Conference>> getCalls() { - return callsSubject; - } - - public Observable<Account.ComposingStatus> getComposingStatus() { - return composingStatusSubject; - } - - public void addConference(final Conference conference) { - if (conference == null) { - return; - } - for (int i = 0; i < mCurrentCalls.size(); i++) { - final Conference currentConference = mCurrentCalls.get(i); - if (currentConference == conference) { - return; - } - if (currentConference.getId().equals(conference.getId())) { - mCurrentCalls.set(i, conference); - return; - } - } - mCurrentCalls.add(conference); - callsSubject.onNext(mCurrentCalls); - } - - public void removeConference(Conference c) { - mCurrentCalls.remove(c); - callsSubject.onNext(mCurrentCalls); - } - - public boolean isVisible() { - return mVisible; - } - - public Observable<Boolean> getVisible() { - return mVisibleSubject; - } - - public void setLoaded(Single<Conversation> loaded) { - isLoaded = loaded; - } - - public Single<Conversation> getLoaded() { - return isLoaded; - } - - public void setVisible(boolean visible) { - mVisible = visible; - mVisibleSubject.onNext(mVisible); - } - - public List<Contact> getContacts() { - return mContacts; - } - - public Contact getContact() { - if (mContacts.size() == 1) - return mContacts.get(0); - if (isSwarm()) { - if (mContacts.size() > 2) - throw new IllegalStateException("getContact() called for group conversation of size " + mContacts.size()); - } - for (Contact contact : mContacts) { - if (!contact.isUser()) - return contact; - } - return null; - } - - public void addCall(Call call) { - if (!isSwarm() && getCallHistory().contains(call)) { - return; - } - mDirty = true; - mAggregateHistory.add(call); - updatedElementSubject.onNext(new Tuple<>(call, ElementStatus.ADD)); - } - - private void setInteractionProperties(Interaction interaction) { - interaction.setAccount(getAccountId()); - if (interaction.getContact() == null) { - if (mContacts.size() == 1) - interaction.setContact(mContacts.get(0)); - else { - if (interaction.getAuthor() == null) { - Log.e(TAG, "Can't set interaction properties: no author for type:" + interaction.getType() + " id:" + interaction.getId() + " status:" + interaction.mStatus); - } else { - interaction.setContact(findContact(Uri.fromString(interaction.getAuthor()))); - } - } - } - } - - public Contact findContact(Uri uri) { - for (Contact contact : mContacts) { - if (contact.getUri().equals(uri)) { - return contact; - } - } - return null; - } - - public void addTextMessage(TextMessage txt) { - if (mVisible) { - txt.read(); - } - if (txt.getConversation() == null) { - Log.e(TAG, "Error in conversation class... No conversation is attached to this interaction"); - } - setInteractionProperties(txt); - mHistory.put(txt.getTimestamp(), txt); - mDirty = true; - mAggregateHistory.add(txt); - updatedElementSubject.onNext(new Tuple<>(txt, ElementStatus.ADD)); - } - - public void addRequestEvent(TrustRequest request, Contact contact) { - if (isSwarm()) - return; - ContactEvent event = new ContactEvent(contact, request); - mDirty = true; - mAggregateHistory.add(event); - updatedElementSubject.onNext(new Tuple<>(event, ElementStatus.ADD)); - } - - public void addContactEvent(Contact contact) { - ContactEvent event = new ContactEvent(contact); - mDirty = true; - mAggregateHistory.add(event); - updatedElementSubject.onNext(new Tuple<>(event, ElementStatus.ADD)); - } - - public void addContactEvent(ContactEvent contactEvent) { - mDirty = true; - mAggregateHistory.add(contactEvent); - updatedElementSubject.onNext(new Tuple<>(contactEvent, ElementStatus.ADD)); - } - - public void addFileTransfer(DataTransfer dataTransfer) { - if (mAggregateHistory.contains(dataTransfer)) { - return; - } - mDirty = true; - mAggregateHistory.add(dataTransfer); - updatedElementSubject.onNext(new Tuple<>(dataTransfer, ElementStatus.ADD)); - } - - boolean isAfter(Interaction previous, Interaction query) { - if (isSwarm()) { - while (query != null && query.getParentIds() != null && !query.getParentIds().isEmpty()) { - if (query.getParentIds().contains(previous.getMessageId())) - return true; - query = mMessages.get(query.getParentIds().get(0)); - } - return false; - } else { - return previous.getTimestamp() < query.getTimestamp(); - } - } - - public void updateInteraction(Interaction element) { - Log.e(TAG, "updateInteraction: " + element.getMessageId() + " " + element.getStatus()); - if (isSwarm()) { - Interaction e = mMessages.get(element.getMessageId()); - if (e != null) { - e.setStatus(element.getStatus()); - updatedElementSubject.onNext(new Tuple<>(e, ElementStatus.UPDATE)); - if (e.getStatus() == Interaction.InteractionStatus.DISPLAYED) { - if (lastDisplayed == null || isAfter(lastDisplayed, e)) { - lastDisplayed = e; - lastDisplayedSubject.onNext(e); - } - } - } else { - Log.e(TAG, "Can't find swarm message to update: " + element.getMessageId()); - } - } else { - setInteractionProperties(element); - long time = element.getTimestamp(); - NavigableMap<Long, Interaction> msgs = mHistory.subMap(time, true, time, true); - for (Interaction txt : msgs.values()) { - if (txt.getId() == element.getId()) { - txt.setStatus(element.getStatus()); - updatedElementSubject.onNext(new Tuple<>(txt, ElementStatus.UPDATE)); - if (element.getStatus() == Interaction.InteractionStatus.DISPLAYED) { - if (lastDisplayed == null || isAfter(lastDisplayed, element)) { - lastDisplayed = element; - lastDisplayedSubject.onNext(element); - } - } - return; - } - } - Log.e(TAG, "Can't find message to update: " + element.getId()); - } - } - - public ArrayList<Interaction> getAggregateHistory() { - return mAggregateHistory; - } - - private final Single<List<Interaction>> sortedHistory = Single.fromCallable(() -> { - sortHistory(); - return mAggregateHistory; - }); - - public void sortHistory() { - if (mDirty) { - Log.w(TAG, "sortHistory()"); - synchronized (mAggregateHistory) { - Collections.sort(mAggregateHistory, (c1, c2) -> Long.compare(c1.getTimestamp(), c2.getTimestamp())); - } - mDirty = false; - } - } - - public Single<List<Interaction>> getSortedHistory() { - return sortedHistory; - } - - public Interaction getLastEvent() { - sortHistory(); - return mAggregateHistory.isEmpty() ? null : mAggregateHistory.get(mAggregateHistory.size() - 1); - } - - public Conference getCurrentCall() { - if (mCurrentCalls.isEmpty()) { - return null; - } - return mCurrentCalls.get(0); - } - - public ArrayList<Conference> getCurrentCalls() { - return mCurrentCalls; - } - - public Collection<Call> getCallHistory() { - List<Call> result = new ArrayList<>(); - for (Interaction interaction : mAggregateHistory) { - if (interaction.getType() == Interaction.InteractionType.CALL) { - result.add((Call) interaction); - } - } - return result; - } - - public TreeMap<Long, TextMessage> getUnreadTextMessages() { - TreeMap<Long, TextMessage> texts = new TreeMap<>(); - if (isSwarm()) { - for(int j = mAggregateHistory.size() - 1; j >= 0; j--) { - Interaction i = mAggregateHistory.get(j); - if (i.isRead()) - break; - if (i instanceof TextMessage) - texts.put(i.getTimestamp(), (TextMessage) i); - } - } else { - for (Map.Entry<Long, Interaction> entry : mHistory.descendingMap().entrySet()) { - Interaction value = entry.getValue(); - if (value.getType() == Interaction.InteractionType.TEXT) { - TextMessage message = (TextMessage) value; - if (message.isRead()) - break; - texts.put(entry.getKey(), message); - } - } - } - return texts; - } - - public NavigableMap<Long, Interaction> getRawHistory() { - return mHistory; - } - - - private Interaction findConversationElement(int transferId) { - for (Interaction interaction : mAggregateHistory) { - if (interaction != null && interaction.getType() == (Interaction.InteractionType.DATA_TRANSFER)) { - if (transferId == (interaction.getId())) { - return interaction; - } - } - } - return null; - } - - private boolean removeSwarmInteraction(String messageId) { - Interaction i = mMessages.remove(messageId); - if (i != null) { - mAggregateHistory.remove(i); - return true; - } - return false; - } - - private boolean removeInteraction(long interactionId) { - Iterator<Interaction> it = mAggregateHistory.iterator(); - while (it.hasNext()) { - Interaction interaction = it.next(); - Integer id = interaction == null ? null : interaction.getId(); - if (id != null && interactionId == id) { - it.remove(); - return true; - } - } - return false; - } - - /** - * Clears the conversation cache. - * @param delete true if you do not want to re-add contact events - */ - public void clearHistory(boolean delete) { - mAggregateHistory.clear(); - mHistory.clear(); - mDirty = false; - if (!delete && mContacts.size() == 1) - mAggregateHistory.add(new ContactEvent(mContacts.get(0))); - clearedSubject.onNext(mAggregateHistory); - } - - static private Interaction getTypedInteraction(Interaction interaction) { - switch (interaction.getType()) { - case TEXT: - return new TextMessage(interaction); - case CALL: - return new Call(interaction); - case CONTACT: - return new ContactEvent(interaction); - case DATA_TRANSFER: - return new DataTransfer(interaction); - } - return interaction; - } - - public void setHistory(List<Interaction> loadedConversation) { - mAggregateHistory.ensureCapacity(loadedConversation.size()); - Interaction last = null; - for (Interaction i : loadedConversation) { - Interaction interaction = getTypedInteraction(i); - setInteractionProperties(interaction); - mAggregateHistory.add(interaction); - mHistory.put(interaction.getTimestamp(), interaction); - if (!i.isIncoming() && i.getStatus() == Interaction.InteractionStatus.DISPLAYED) - last = i; - } - if (last != null) { - lastDisplayed = last; - lastDisplayedSubject.onNext(last); - } - mDirty = false; - } - - public void addElement(Interaction interaction) { - setInteractionProperties(interaction); - if (interaction.getType() == Interaction.InteractionType.TEXT) { - TextMessage msg = new TextMessage(interaction); - addTextMessage(msg); - } else if (interaction.getType() == Interaction.InteractionType.CALL) { - Call call = new Call(interaction); - addCall(call); - } else if (interaction.getType() == Interaction.InteractionType.CONTACT) { - ContactEvent event = new ContactEvent(interaction); - addContactEvent(event); - } else if (interaction.getType() == Interaction.InteractionType.DATA_TRANSFER) { - DataTransfer dataTransfer = new DataTransfer(interaction); - addFileTransfer(dataTransfer); - } - } - - public boolean addSwarmElement(Interaction interaction) { - if (mMessages.containsKey(interaction.getMessageId())) { - return false; - } - mMessages.put(interaction.getMessageId(), interaction); - mRoots.remove(interaction.getMessageId()); - for (String parent : interaction.getParentIds()) - if (!mMessages.containsKey(parent)) { - mRoots.add(parent); - // Log.w(TAG, "@@@ Found new root for " + getUri() + " " + parent + " -> " + mRoots); - } - if (lastRead != null && lastRead.equals(interaction.getMessageId())) - interaction.read(); - boolean newLeaf = false; - boolean added = false; - if (mAggregateHistory.isEmpty() || interaction.getParentIds().contains(mAggregateHistory.get(mAggregateHistory.size()-1).getMessageId())) { - // New leaf - // Log.w(TAG, "@@@ New end LEAF"); - added = true; - newLeaf = true; - mAggregateHistory.add(interaction); - updatedElementSubject.onNext(new Tuple<>(interaction, ElementStatus.ADD)); - } else { - // New root or normal node - for (int i = 0; i < mAggregateHistory.size(); i++) { - if (mAggregateHistory.get(i).getParentIds() != null && mAggregateHistory.get(i).getParentIds().contains(interaction.getMessageId())) { - //Log.w(TAG, "@@@ New root node at " + i); - mAggregateHistory.add(i, interaction); - updatedElementSubject.onNext(new Tuple<>(interaction, ElementStatus.ADD)); - added = true; - break; - } - } - if (!added) { - for (int i = mAggregateHistory.size()-1; i >= 0; i--) { - if (interaction.getParentIds().contains(mAggregateHistory.get(i).getMessageId())) { - //Log.w(TAG, "@@@ New leaf at " + (i+1)); - added = true; - newLeaf = true; - mAggregateHistory.add(i+1, interaction); - updatedElementSubject.onNext(new Tuple<>(interaction, ElementStatus.ADD)); - break; - } - } - - } - } - if (newLeaf) { - if (isVisible()) { - interaction.read(); - setLastMessageRead(interaction.getMessageId()); - } - } - if (!added) { - Log.e(TAG, "Can't attach interaction " + interaction.getMessageId() + " with parents " + interaction.getParentIds()); - } - return newLeaf; - } - - public boolean isLoaded() { - return !mMessages.isEmpty() && mRoots.isEmpty(); - } - - public Collection<String> getSwarmRoot() { - return mRoots; - } - - public void updateFileTransfer(DataTransfer transfer, Interaction.InteractionStatus eventCode) { - DataTransfer dataTransfer = (DataTransfer) (isSwarm() ? transfer : findConversationElement(transfer.getId())); - if (dataTransfer != null) { - dataTransfer.setStatus(eventCode); - updatedElementSubject.onNext(new Tuple<>(dataTransfer, ElementStatus.UPDATE)); - } - } - - public void removeInteraction(Interaction interaction) { - if (isSwarm()) { - if (removeSwarmInteraction(interaction.getMessageId())) - updatedElementSubject.onNext(new Tuple<>(interaction, ElementStatus.REMOVE)); - } else { - if (removeInteraction(interaction.getId())) - updatedElementSubject.onNext(new Tuple<>(interaction, ElementStatus.REMOVE)); - } - } - - public void removeAll() { - mAggregateHistory.clear(); - mCurrentCalls.clear(); - mHistory.clear(); - mDirty = true; - } - - public void setColor(int c) { - color.onNext(c); - } - - public void setSymbol(CharSequence s) { - symbol.onNext(s); - } - - public Observable<Integer> getColor() { - return color; - } - public Observable<CharSequence> getSymbol() { - return symbol; - } - - - public String getAccountId() { - return mAccountId; - } - - public enum Mode { - OneToOne, - AdminInvitesOnly, - InvitesOnly, - Public - } - - public interface ConversationActionCallback { - - void removeConversation(Uri callContact); - - void clearConversation(Uri callContact); - - void copyContactNumberToClipboard(String contactNumber); - - } - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Conversation.kt b/ring-android/libringclient/src/main/java/net/jami/model/Conversation.kt new file mode 100644 index 000000000..6ebbbc7a2 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/Conversation.kt @@ -0,0 +1,660 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.model + +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.PublishSubject +import io.reactivex.rxjava3.subjects.SingleSubject +import io.reactivex.rxjava3.subjects.Subject +import kotlin.jvm.Synchronized +import net.jami.utils.Log +import net.jami.utils.StringUtils +import net.jami.utils.Tuple +import java.lang.IllegalStateException +import java.lang.StringBuilder +import java.util.* + +class Conversation : ConversationHistory { + val accountId: String + val uri: Uri + val contacts: MutableList<Contact> + val rawHistory: NavigableMap<Long, Interaction> = TreeMap() + val currentCalls = ArrayList<Conference>() + val aggregateHistory = ArrayList<Interaction>(32) + private var lastDisplayed: Interaction? = null + private val updatedElementSubject: Subject<Tuple<Interaction, ElementStatus>> = PublishSubject.create() + private val lastDisplayedSubject: Subject<Interaction> = BehaviorSubject.create() + private val clearedSubject: Subject<List<Interaction>> = PublishSubject.create() + private val callsSubject: Subject<List<Conference>> = BehaviorSubject.create() + private val composingStatusSubject: Subject<Account.ComposingStatus> = BehaviorSubject.createDefault(Account.ComposingStatus.Idle) + private val color: Subject<Int> = BehaviorSubject.create() + private val symbol: Subject<CharSequence> = BehaviorSubject.create() + private val mContactSubject: Subject<List<Contact>> = BehaviorSubject.create() + var loaded: Single<Conversation>? = null + var lastElementLoaded: Completable? = null + private val mRoots: MutableSet<String> = HashSet(2) + private val mMessages: MutableMap<String, Interaction> = HashMap(16) + var lastRead: String? = null + private set + private val mMode: Subject<Mode> + + // runtime flag set to true if the user is currently viewing this conversation + private var mVisible = false + private val mVisibleSubject: Subject<Boolean> = BehaviorSubject.createDefault(mVisible) + + // indicate the list needs sorting + private var mDirty = false + private var mLoadingSubject: SingleSubject<Conversation>? = null + + constructor(accountId: String, contact: Contact) { + this.accountId = accountId + contacts = mutableListOf(contact) + uri = contact.uri + mParticipant = contact.uri.uri + mContactSubject.onNext(contacts) + mMode = BehaviorSubject.createDefault(Mode.Legacy) + } + + constructor(accountId: String, uri: Uri, mode: Mode) { + this.accountId = accountId + this.uri = uri + contacts = ArrayList(3) + mMode = BehaviorSubject.createDefault(mode) + } + + fun getConference(id: String?): Conference? { + for (c in currentCalls) if (c.id.contentEquals(id) || c.getCallById(id) != null) { + return c + } + return null + } + + fun composingStatusChanged(contact: Contact?, composing: Account.ComposingStatus) { + composingStatusSubject.onNext(composing) + } + + val mode: Observable<Mode> + get() = mMode + val isSwarm: Boolean + get() = Uri.SWARM_SCHEME == uri.scheme + + fun matches(query: String?): Boolean { + for (contact in contacts) { + if (contact.matches(query!!)) return true + } + return false + } + + val displayName: String? + get() = contacts[0].displayName + + fun addContact(contact: Contact) { + contacts.add(contact) + mContactSubject.onNext(contacts) + } + + fun removeContact(contact: Contact) { + contacts.remove(contact) + mContactSubject.onNext(contacts) + } + + val title: String? + get() { + if (contacts.isEmpty()) { + return if (mMode.blockingFirst() == Mode.Syncing) { "(Syncing)" } else null + } else if (contacts.size == 1) { + return contacts[0].displayName + } + val names = ArrayList<String>(contacts.size) + var target = contacts.size + for (c in contacts) { + if (c.isUser) { + target-- + continue + } + val displayName = c.displayName + if (displayName.isNotEmpty()) { + names.add(displayName) + if (names.size == 3) break + } + } + val ret = StringBuilder() + ret.append(StringUtils.join(", ", names)) + if (names.isNotEmpty() && names.size < target) { + ret.append(" + ").append(contacts.size - names.size) + } + val result = ret.toString() + return if (result.isEmpty()) uri.rawUriString else result + } + val uriTitle: String? + get() { + if (contacts.isEmpty()) { + return null + } else if (contacts.size == 1) { + return contacts[0].ringUsername + } + val names = ArrayList<String>(contacts.size) + for (c in contacts) { + if (c.isUser) continue + c.ringUsername.let { names.add(it) } + } + return StringUtils.join(", ", names) + } + val contactUpdates: Observable<List<Contact>> + get() = mContactSubject + + @Synchronized + fun readMessages(): String? { + var interaction: Interaction? = null + if (aggregateHistory.isNotEmpty()) { + val i = aggregateHistory[aggregateHistory.size - 1] + if (!i.isRead) { + i.read() + interaction = i + lastRead = i.messageId + } + } + return interaction?.messageId + } + + @Synchronized + fun getMessage(messageId: String): Interaction? { + return mMessages[messageId] + } + + fun setLastMessageRead(lastMessageRead: String?) { + lastRead = lastMessageRead + } + + var loading: SingleSubject<Conversation>? + get() = mLoadingSubject + set(l) { + if (mLoadingSubject != null) { + if (!mLoadingSubject!!.hasValue() && !mLoadingSubject!!.hasThrowable()) mLoadingSubject!!.onError( + IllegalStateException() + ) + } + mLoadingSubject = l + } + + fun stopLoading(): Boolean { + val ret = mLoadingSubject + mLoadingSubject = null + if (ret != null) { + ret.onSuccess(this) + return true + } + return false + } + + fun setMode(mode: Mode) { + mMode.onNext(mode) + } + + enum class ElementStatus { + UPDATE, REMOVE, ADD + } + + val updatedElements: Observable<Tuple<Interaction, ElementStatus>> + get() = updatedElementSubject + + fun getLastDisplayed(): Observable<Interaction> { + return lastDisplayedSubject + } + + val cleared: Observable<List<Interaction>> + get() = clearedSubject + val calls: Observable<List<Conference>> + get() = callsSubject + val composingStatus: Observable<Account.ComposingStatus> + get() = composingStatusSubject + + fun addConference(conference: Conference?) { + if (conference == null) { + return + } + for (i in currentCalls.indices) { + val currentConference = currentCalls[i] + if (currentConference === conference) { + return + } + if (currentConference.id == conference.id) { + currentCalls[i] = conference + return + } + } + currentCalls.add(conference) + callsSubject.onNext(currentCalls) + } + + fun removeConference(c: Conference) { + currentCalls.remove(c) + callsSubject.onNext(currentCalls) + } + + var isVisible: Boolean + get() = mVisible + set(visible) { + mVisible = visible + mVisibleSubject.onNext(mVisible) + } + + fun getVisible(): Observable<Boolean> { + return mVisibleSubject + } + + val contact: Contact? + get() { + if (contacts.size == 1) return contacts[0] + if (isSwarm) { + check(contacts.size <= 2) { "getContact() called for group conversation of size " + contacts.size } + } + for (contact in contacts) { + if (!contact.isUser) return contact + } + return null + } + + fun addCall(call: Call) { + if (!isSwarm && callHistory.contains(call)) { + return + } + mDirty = true + aggregateHistory.add(call) + updatedElementSubject.onNext(Tuple(call, ElementStatus.ADD)) + } + + private fun setInteractionProperties(interaction: Interaction) { + interaction.account = accountId + if (interaction.contact == null) { + if (contacts.size == 1) interaction.contact = contacts[0] else { + if (interaction.author == null) { + Log.e(TAG, "Can't set interaction properties: no author for type:" + interaction.type + " id:" + interaction.id + " status:" + interaction.mStatus) + } else { + interaction.contact = findContact(Uri.fromString(interaction.author!!)) + } + } + } + } + + fun findContact(uri: Uri): Contact? { + for (contact in contacts) { + if (contact.uri == uri) { + return contact + } + } + return null + } + + fun addTextMessage(txt: TextMessage) { + if (mVisible) { + txt.read() + } + if (txt.conversation == null) { + Log.e( + TAG, + "Error in conversation class... No conversation is attached to this interaction" + ) + } + setInteractionProperties(txt) + rawHistory[txt.timestamp] = txt + mDirty = true + aggregateHistory.add(txt) + updatedElementSubject.onNext(Tuple(txt, ElementStatus.ADD)) + } + + fun addRequestEvent(request: TrustRequest, contact: Contact) { + if (isSwarm) return + val event = ContactEvent(contact, request) + mDirty = true + aggregateHistory.add(event) + updatedElementSubject.onNext(Tuple(event, ElementStatus.ADD)) + } + + fun addContactEvent(contact: Contact) { + val event = ContactEvent(contact) + mDirty = true + aggregateHistory.add(event) + updatedElementSubject.onNext(Tuple(event, ElementStatus.ADD)) + } + + fun addContactEvent(contactEvent: ContactEvent) { + mDirty = true + aggregateHistory.add(contactEvent) + updatedElementSubject.onNext(Tuple(contactEvent, ElementStatus.ADD)) + } + + fun addFileTransfer(dataTransfer: DataTransfer) { + if (aggregateHistory.contains(dataTransfer)) { + return + } + mDirty = true + aggregateHistory.add(dataTransfer) + updatedElementSubject.onNext(Tuple(dataTransfer, ElementStatus.ADD)) + } + + private fun isAfter(previous: Interaction, query: Interaction?): Boolean { + var query = query + return if (isSwarm) { + while (query != null && query.parentId != null) { + if (query.parentId == previous.messageId) return true + query = mMessages[query.parentId] + } + false + } else { + previous.timestamp < query!!.timestamp + } + } + + fun updateInteraction(element: Interaction) { + Log.e(TAG, "updateInteraction: " + element.messageId + " " + element.status) + if (isSwarm) { + val e = mMessages[element.messageId] + if (e != null) { + e.status = element.status + updatedElementSubject.onNext(Tuple(e, ElementStatus.UPDATE)) + if (e.status == Interaction.InteractionStatus.DISPLAYED) { + if (lastDisplayed == null || isAfter(lastDisplayed!!, e)) { + lastDisplayed = e + lastDisplayedSubject.onNext(e) + } + } + } else { + Log.e(TAG, "Can't find swarm message to update: " + element.messageId) + } + } else { + setInteractionProperties(element) + val time = element.timestamp + val msgs = rawHistory.subMap(time, true, time, true) + for (txt in msgs.values) { + if (txt.id == element.id) { + txt.status = element.status + updatedElementSubject.onNext(Tuple(txt, ElementStatus.UPDATE)) + if (element.status == Interaction.InteractionStatus.DISPLAYED) { + if (lastDisplayed == null || isAfter(lastDisplayed!!, element)) { + lastDisplayed = element + lastDisplayedSubject.onNext(element) + } + } + return + } + } + Log.e(TAG, "Can't find message to update: " + element.id) + } + } + + val sortedHistory: Single<List<Interaction>> = Single.fromCallable { + sortHistory() + aggregateHistory + } + + fun sortHistory() { + if (mDirty) { + Log.w(TAG, "sortHistory()") + synchronized(aggregateHistory) { + aggregateHistory.sortWith { c1: Interaction, c2: Interaction -> + java.lang.Long.compare(c1.timestamp, c2.timestamp) + } + } + mDirty = false + } + } + + val lastEvent: Interaction? + get() { + sortHistory() + return if (aggregateHistory.isEmpty()) null else aggregateHistory[aggregateHistory.size - 1] + } + val currentCall: Conference? + get() = if (currentCalls.isEmpty()) null else currentCalls[0] + private val callHistory: Collection<Call> + get() { + val result: MutableList<Call> = ArrayList() + for (interaction in aggregateHistory) { + if (interaction.type == Interaction.InteractionType.CALL) { + result.add(interaction as Call) + } + } + return result + } + val unreadTextMessages: TreeMap<Long, TextMessage> + get() { + val texts = TreeMap<Long, TextMessage>() + if (isSwarm) { + for (j in aggregateHistory.indices.reversed()) { + val i = aggregateHistory[j] + if (i.isRead) break + if (i is TextMessage) texts[i.timestamp] = i + } + } else { + for ((key, value) in rawHistory.descendingMap()) { + if (value.type == Interaction.InteractionType.TEXT) { + val message = value as TextMessage + if (message.isRead) break + texts[key] = message + } + } + } + return texts + } + + private fun findConversationElement(transferId: Int): Interaction? { + for (interaction in aggregateHistory) { + if (interaction.type == Interaction.InteractionType.DATA_TRANSFER) { + if (transferId == interaction.id) { + return interaction + } + } + } + return null + } + + private fun removeSwarmInteraction(messageId: String): Boolean { + val i = mMessages.remove(messageId) + if (i != null) { + aggregateHistory.remove(i) + return true + } + return false + } + + private fun removeInteraction(interactionId: Long): Boolean { + val it = aggregateHistory.iterator() + while (it.hasNext()) { + val interaction = it.next() + if (interactionId == interaction.id.toLong()) { + it.remove() + return true + } + } + return false + } + + /** + * Clears the conversation cache. + * @param delete true if you do not want to re-add contact events + */ + fun clearHistory(delete: Boolean) { + aggregateHistory.clear() + rawHistory.clear() + mDirty = false + if (!delete && contacts.size == 1) aggregateHistory.add(ContactEvent(contacts[0])) + clearedSubject.onNext(aggregateHistory) + } + + fun setHistory(loadedConversation: List<Interaction>) { + aggregateHistory.ensureCapacity(loadedConversation.size) + var last: Interaction? = null + for (i in loadedConversation) { + val interaction = getTypedInteraction(i) + setInteractionProperties(interaction) + aggregateHistory.add(interaction) + rawHistory[interaction.timestamp] = interaction + if (!i.isIncoming && i.status == Interaction.InteractionStatus.DISPLAYED) last = i + } + if (last != null) { + lastDisplayed = last + lastDisplayedSubject.onNext(last) + } + mDirty = false + } + + fun addElement(interaction: Interaction) { + setInteractionProperties(interaction) + when (interaction.type) { + Interaction.InteractionType.TEXT -> addTextMessage(TextMessage(interaction)) + Interaction.InteractionType.CALL -> addCall(Call(interaction)) + Interaction.InteractionType.CONTACT -> addContactEvent(ContactEvent(interaction)) + Interaction.InteractionType.DATA_TRANSFER -> addFileTransfer(DataTransfer(interaction)) + } + } + + fun addSwarmElement(interaction: Interaction): Boolean { + if (mMessages.containsKey(interaction.messageId)) { + return false + } + mMessages[interaction.messageId!!] = interaction + mRoots.remove(interaction.messageId) + if (interaction.parentId != null && !mMessages.containsKey(interaction.parentId)) { + mRoots.add(interaction.parentId!!) + // Log.w(TAG, "@@@ Found new root for " + getUri() + " " + parent + " -> " + mRoots); + } + if (lastRead != null && lastRead == interaction.messageId) interaction.read() + var newLeaf = false + var added = false + if (aggregateHistory.isEmpty() || aggregateHistory[aggregateHistory.size - 1].messageId == interaction.parentId) { + // New leaf + // Log.w(TAG, "@@@ New end LEAF"); + added = true + newLeaf = true + aggregateHistory.add(interaction) + updatedElementSubject.onNext(Tuple(interaction, ElementStatus.ADD)) + } else { + // New root or normal node + for (i in aggregateHistory.indices) { + if (interaction.messageId == aggregateHistory[i].parentId) { + //Log.w(TAG, "@@@ New root node at " + i); + aggregateHistory.add(i, interaction) + updatedElementSubject.onNext(Tuple(interaction, ElementStatus.ADD)) + added = true + break + } + } + if (!added) { + for (i in aggregateHistory.indices.reversed()) { + if (aggregateHistory[i].messageId == interaction.parentId) { + //Log.w(TAG, "@@@ New leaf at " + (i+1)); + added = true + newLeaf = true + aggregateHistory.add(i + 1, interaction) + updatedElementSubject.onNext(Tuple(interaction, ElementStatus.ADD)) + break + } + } + } + } + if (newLeaf) { + if (isVisible) { + interaction.read() + setLastMessageRead(interaction.messageId) + } + } + if (!added) { + Log.e( + TAG, + "Can't attach interaction " + interaction.messageId + " with parent " + interaction.parentId + ) + } + return newLeaf + } + + fun isLoaded(): Boolean { + return mMessages.isNotEmpty() && mRoots.isEmpty() + } + + val swarmRoot: Collection<String> + get() = mRoots + + fun updateFileTransfer(transfer: DataTransfer, eventCode: Interaction.InteractionStatus) { + val dataTransfer = (if (isSwarm) transfer else findConversationElement(transfer.id)) as DataTransfer? + if (dataTransfer != null) { + dataTransfer.status = eventCode + updatedElementSubject.onNext(Tuple(dataTransfer, ElementStatus.UPDATE)) + } + } + + fun removeInteraction(interaction: Interaction) { + if (isSwarm) { + if (removeSwarmInteraction(interaction.messageId!!)) updatedElementSubject.onNext(Tuple(interaction, ElementStatus.REMOVE)) + } else { + if (removeInteraction(interaction.id.toLong())) updatedElementSubject.onNext(Tuple(interaction, ElementStatus.REMOVE)) + } + } + + fun removeAll() { + aggregateHistory.clear() + currentCalls.clear() + rawHistory.clear() + mDirty = true + } + + fun setColor(c: Int) { + color.onNext(c) + } + + fun setSymbol(s: CharSequence) { + symbol.onNext(s) + } + + fun getColor(): Observable<Int> { + return color + } + + fun getSymbol(): Observable<CharSequence> { + return symbol + } + + enum class Mode { + OneToOne, AdminInvitesOnly, InvitesOnly, // Non-daemon modes + Syncing, Public, Legacy + } + + interface ConversationActionCallback { + fun removeConversation(callContact: Uri) + fun clearConversation(callContact: Uri) + fun copyContactNumberToClipboard(contactNumber: String) + } + + companion object { + private val TAG = Conversation::class.simpleName!! + private fun getTypedInteraction(interaction: Interaction): Interaction { + return when (interaction.type) { + Interaction.InteractionType.TEXT -> TextMessage(interaction) + Interaction.InteractionType.CALL -> Call(interaction) + Interaction.InteractionType.CONTACT -> ContactEvent(interaction) + Interaction.InteractionType.DATA_TRANSFER -> DataTransfer(interaction) + else -> interaction + } + } + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/DataTransfer.java b/ring-android/libringclient/src/main/java/net/jami/model/DataTransfer.java deleted file mode 100644 index 31a8ceaeb..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/DataTransfer.java +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.model; - -import net.jami.utils.HashUtils; -import net.jami.utils.StringUtils; - -import java.io.File; -import java.io.IOException; -import java.util.Set; - -public class DataTransfer extends Interaction { - - private long mTotalSize; - private long mBytesProgress; - //private final String mPeerId; - private String mExtension; - private String mFileId; - public File destination; - private File mDaemonPath; - - private static final Set<String> IMAGE_EXTENSIONS = HashUtils.asSet("jpg", "jpeg", "png", "gif"); - private static final Set<String> AUDIO_EXTENSIONS = HashUtils.asSet("ogg", "mp3", "aac", "flac", "m4a"); - private static final Set<String> VIDEO_EXTENSIONS = HashUtils.asSet("webm", "mp4", "mkv"); - private static final int MAX_SIZE = 32 * 1024 * 1024; - private static final int UNLIMITED_SIZE = 256 * 1024 * 1024; - - /* Legacy constructor */ - public DataTransfer(ConversationHistory conversation, String peer, String account, String displayName, boolean isOutgoing, long totalSize, long bytesProgress, String fileId) { - mAuthor = isOutgoing ? null : peer; - mAccount = account; - mConversation = conversation; - mTotalSize = totalSize; - mBytesProgress = bytesProgress; - mBody = displayName; - mStatus = InteractionStatus.TRANSFER_CREATED.toString(); - mType = InteractionType.DATA_TRANSFER.toString(); - mTimestamp = System.currentTimeMillis(); - mIsRead = 1; - mIsIncoming = !isOutgoing; - if (fileId != null) { - mFileId = fileId; - try { - mDaemonId = Long.parseUnsignedLong(fileId); - } catch (Exception e) { - - } - } - } - - public DataTransfer(Interaction interaction) { - mId = interaction.getId(); - mDaemonId = interaction.getDaemonId(); - mAuthor = interaction.getAuthor(); - mConversation = interaction.getConversation(); - // mPeerId = interaction.getConversation().getParticipant(); - mBody = interaction.getBody(); - mStatus = interaction.getStatus().toString(); - mType = interaction.getType().toString(); - mTimestamp = interaction.getTimestamp(); - mAccount = interaction.getAccount(); - mContact = interaction.getContact(); - mIsRead = 1; - mIsIncoming = interaction.mIsIncoming;//mAuthor != null; - } - - public DataTransfer(String fileId, String accountId, String peerUri, String displayName, boolean isOutgoing, long timestamp, long totalSize, long bytesProgress) { - mAccount = accountId; - mFileId = fileId; - mBody = displayName; - mAuthor = peerUri; - mIsIncoming = !isOutgoing; - mTotalSize = totalSize; - mBytesProgress = bytesProgress; - mTimestamp = timestamp; - mType = InteractionType.DATA_TRANSFER.toString(); - } - - public String getExtension() { - if (mBody == null) - return null; - if (mExtension == null) - mExtension = StringUtils.getFileExtension(mBody).toLowerCase(); - return mExtension; - } - - - public boolean isPicture() { - return IMAGE_EXTENSIONS.contains(getExtension()); - } - public boolean isAudio() { - return AUDIO_EXTENSIONS.contains(getExtension()); - } - public boolean isVideo() { - return VIDEO_EXTENSIONS.contains(getExtension()); - } - - public boolean isComplete() { - return isOutgoing() || InteractionStatus.TRANSFER_FINISHED.toString().equals(mStatus); - } - public boolean showPicture() { - return isPicture() && isComplete(); - } - - public String getStoragePath() { - if (StringUtils.isEmpty(mBody)) { - return getFileId(); - } else { - String ext = StringUtils.getFileExtension(mBody); - if (ext.length() > 8) - ext = ext.substring(0, 8); - if (mDaemonId == null || mDaemonId == 0) { - return Long.toString(mId) + '_' + HashUtils.sha1(mBody) + '.' + ext; - } else { - return Long.toString(mDaemonId) + '_' + HashUtils.sha1(mBody) + '.' + ext; - } - } - } - - public void setSize(long size) { - mTotalSize = size; - } - - public String getDisplayName() { - return mBody; - } - - public boolean isOutgoing() { - return !mIsIncoming; - } - - public long getTotalSize() { - return mTotalSize; - } - - public long getBytesProgress() { - return mBytesProgress; - } - - public void setBytesProgress(long bytesProgress) { - mBytesProgress = bytesProgress; - } - - public boolean isError() { - return getStatus().isError(); - } - - public boolean canAutoAccept(int maxSize) { - return maxSize == UNLIMITED_SIZE || getTotalSize() <= maxSize; - } - - public String getFileId() { - return mFileId; - } - - public void setDaemonPath(File file) { - mDaemonPath = file; - } - - public File getDaemonPath() { - return mDaemonPath; - } - - public File getPublicPath() { - if (mDaemonPath == null) { - return null; - } - try { - return mDaemonPath.getCanonicalFile(); - } catch (IOException e) { - return null; - } - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/DataTransfer.kt b/ring-android/libringclient/src/main/java/net/jami/model/DataTransfer.kt new file mode 100644 index 000000000..7cefd83ea --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/DataTransfer.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.model + +import net.jami.utils.HashUtils +import net.jami.utils.StringUtils +import java.io.File +import java.io.IOException +import java.lang.Exception + +class DataTransfer : Interaction { + var totalSize: Long = 0 + private set + var bytesProgress: Long = 0 + + //private final String mPeerId; + private var mExtension: String? = null + var fileId: String? = null + private set + var destination: File? = null + var daemonPath: File? = null + + /* Legacy constructor */ + constructor( + conversation: ConversationHistory?, + peer: String?, + account: String?, + displayName: String, + isOutgoing: Boolean, + totalSize: Long, + bytesProgress: Long, + fileId: String? + ) { + author = if (isOutgoing) null else peer + this.account = account + this.conversation = conversation + this.totalSize = totalSize + this.bytesProgress = bytesProgress + body = displayName + mStatus = InteractionStatus.TRANSFER_CREATED.toString() + mType = InteractionType.DATA_TRANSFER.toString() + timestamp = System.currentTimeMillis() + mIsRead = 1 + isIncoming = !isOutgoing + if (fileId != null) { + this.fileId = fileId + try { + daemonId = fileId.toULong().toLong() + } catch (e: Exception) { + } + } + } + + constructor(interaction: Interaction) { + id = interaction.id + daemonId = interaction.daemonId + author = interaction.author + conversation = interaction.conversation + // mPeerId = interaction.getConversation().getParticipant(); + body = interaction.body + mStatus = interaction.status.toString() + mType = interaction.type.toString() + timestamp = interaction.timestamp + account = interaction.account + contact = interaction.contact + mIsRead = 1 + isIncoming = interaction.isIncoming //mAuthor != null; + } + + constructor( + fileId: String?, + accountId: String, + peerUri: String, + displayName: String, + isOutgoing: Boolean, + timestamp: Long, + totalSize: Long, + bytesProgress: Long + ) { + account = accountId + this.fileId = fileId + body = displayName + author = peerUri + isIncoming = !isOutgoing + this.totalSize = totalSize + this.bytesProgress = bytesProgress + this.timestamp = timestamp + mType = InteractionType.DATA_TRANSFER.toString() + } + + val extension: String? + get() { + if (body == null) return null + if (mExtension == null) mExtension = StringUtils.getFileExtension(body!!).lowercase() + return mExtension + } + val isPicture: Boolean + get() = IMAGE_EXTENSIONS.contains(extension) + val isAudio: Boolean + get() = AUDIO_EXTENSIONS.contains(extension) + val isVideo: Boolean + get() = VIDEO_EXTENSIONS.contains(extension) + val isComplete: Boolean + get() = conversationId == null && isOutgoing || InteractionStatus.TRANSFER_FINISHED.toString() == mStatus + + fun showPicture(): Boolean { + return isPicture && isComplete + } + + val storagePath: String + get() { + val b = body + return if (b == null) { + if (StringUtils.isEmpty(fileId)) { "Error" } else fileId!! + } else { + var ext = StringUtils.getFileExtension(b) + if (ext.length > 8) ext = ext.substring(0, 8) + val dId = daemonId + if (dId == null || dId == 0L) { + id.toLong().toString() + '_' + HashUtils.sha1(b) + '.' + ext + } else { + dId.toString() + '_' + HashUtils.sha1(b) + '.' + ext + } + } + } + + fun setSize(size: Long) { + totalSize = size + } + + val displayName: String + get() = body!! + val isOutgoing: Boolean + get() = !isIncoming + val isError: Boolean + get() = status.isError + + fun canAutoAccept(maxSize: Int): Boolean { + return maxSize == UNLIMITED_SIZE || totalSize <= maxSize + } + + val publicPath: File? + get() = if (daemonPath == null) { + null + } else try { + daemonPath!!.canonicalFile + } catch (e: IOException) { + null + } + + companion object { + private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif") + private val AUDIO_EXTENSIONS = setOf("ogg", "mp3", "aac", "flac", "m4a") + private val VIDEO_EXTENSIONS = setOf("webm", "mp4", "mkv") + private const val MAX_SIZE = 32 * 1024 * 1024 + private const val UNLIMITED_SIZE = 256 * 1024 * 1024 + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Interaction.java b/ring-android/libringclient/src/main/java/net/jami/model/Interaction.java deleted file mode 100644 index ddfa85cd5..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/Interaction.java +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.model; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.j256.ormlite.field.DatabaseField; -import com.j256.ormlite.table.DatabaseTable; - -import java.util.List; - -@DatabaseTable(tableName = Interaction.TABLE_NAME) -public class Interaction { - - public static final String TABLE_NAME = "interactions"; - public static final String COLUMN_ID = "id"; - public static final String COLUMN_AUTHOR = "author"; - public static final String COLUMN_CONVERSATION = "conversation"; - public static final String COLUMN_TIMESTAMP = "timestamp"; - public static final String COLUMN_BODY = "body"; - public static final String COLUMN_TYPE = "type"; - public static final String COLUMN_STATUS = "status"; - public static final String COLUMN_DAEMON_ID = "daemon_id"; - public static final String COLUMN_IS_READ = "is_read"; - public static final String COLUMN_EXTRA_FLAG = "extra_data"; - protected String mAccount; - boolean mIsIncoming; - Contact mContact = null; - - @DatabaseField(generatedId = true, columnName = COLUMN_ID, index = true) - int mId; - @DatabaseField(columnName = COLUMN_AUTHOR, index = true) - String mAuthor; - @DatabaseField(columnName = COLUMN_CONVERSATION, foreignColumnName = ConversationHistory.COLUMN_CONVERSATION_ID, foreign = true) - ConversationHistory mConversation; - @DatabaseField(columnName = COLUMN_TIMESTAMP, index = true) - long mTimestamp; - @DatabaseField(columnName = COLUMN_BODY) - String mBody; - @DatabaseField(columnName = COLUMN_TYPE) - String mType; - @DatabaseField(columnName = COLUMN_STATUS) - String mStatus = InteractionStatus.UNKNOWN.toString(); - @DatabaseField(columnName = COLUMN_DAEMON_ID) - Long mDaemonId = null; - @DatabaseField(columnName = COLUMN_IS_READ) - int mIsRead = 0; - @DatabaseField(columnName = COLUMN_EXTRA_FLAG) - String mExtraFlag = new JsonObject().toString(); - - // Swarm - private String mConversationId = null; - private String mMessageId = null; - private List<String> mParentIds = null; - - /* Needed by ORMLite */ - public Interaction() { - } - public Interaction(String accountId) { - mAccount = accountId; - setType(InteractionType.INVALID); - } - - public Interaction(Conversation conversation, InteractionType type) { - mConversation = conversation; - mAccount = conversation.getAccountId(); - mType = type.toString(); - } - - public Interaction(String id, String author, ConversationHistory conversation, String timestamp, String body, String type, String status, String daemonId, String isRead, String extraFlag) { - mId = Integer.parseInt(id); - mAuthor = author; - mConversation = conversation; - mTimestamp = Long.parseLong(timestamp); - mBody = body; - mType = type; - mStatus = status; - try { - mDaemonId = daemonId == null ? null : Long.parseLong(daemonId); - } - catch (NumberFormatException e) { - mDaemonId = 0L; - } - mIsRead = Integer.parseInt(isRead); - mExtraFlag = extraFlag; - } - - static int compare(Interaction a, Interaction b) { - if (a == null) - return b == null ? 0 : -1; - if (b == null) return 1; - return Long.compare(a.getTimestamp(), b.getTimestamp()); - } - - public String getAccount() { - return mAccount; - } - - public void setAccount(String account) { - mAccount = account; - } - - public int getId() { - return mId; - } - - public void read() { - mIsRead = 1; - } - - public String getAuthor() { - return mAuthor; - } - - public void setAuthor(String author) { - mAuthor = author; - } - - public ConversationHistory getConversation() { - return mConversation; - } - - public void setConversation(ConversationHistory conversation) { - mConversation = conversation; - } - - public Long getTimestamp() { - return mTimestamp; - } - - public String getBody() { - return mBody; - } - - public InteractionType getType() { - return InteractionType.fromString(mType); - } - - public void setType(InteractionType type) { - mType = type.toString(); - } - - public InteractionStatus getStatus() { - return InteractionStatus.fromString(mStatus); - } - - public void setStatus(InteractionStatus status) { - if (status == InteractionStatus.DISPLAYED) - mIsRead = 1; - mStatus = status.toString(); - } - - JsonObject getExtraFlag() { - return toJson(mExtraFlag); - } - - JsonObject toJson(String value) { - return JsonParser.parseString(value).getAsJsonObject(); - } - - String fromJson(JsonObject json) { - return json.toString(); - } - - public Long getDaemonId() { - return mDaemonId; - } - - public String getDaemonIdString() { - return mDaemonId == null ? null : Long.toString(mDaemonId); - } - - public void setDaemonId(long daemonId) { - mDaemonId = daemonId; - } - - public String getMessageId() { - return mMessageId; - } - - public String getConversationId() { - return mConversationId; - } - - public List<String> getParentIds() { - return mParentIds; - } - - public boolean isIncoming() { - return mIsIncoming; - } - - public boolean isRead() { - return mIsRead == 1; - } - - public Contact getContact() { - return mContact; - } - - public void setContact(Contact contact) { - mContact = contact; - } - - public void setSwarmInfo(String conversationId) { - mConversationId = conversationId; - mMessageId = null; - mParentIds = null; - } - public void setSwarmInfo(String conversationId, String messageId, List<String> parents) { - mConversationId = conversationId; - mMessageId = messageId; - mParentIds = parents; - } - - public enum InteractionStatus { - UNKNOWN, SENDING, SUCCESS, DISPLAYED, INVALID, FAILURE, - - TRANSFER_CREATED, - TRANSFER_ACCEPTED, - TRANSFER_CANCELED, - TRANSFER_ERROR, - TRANSFER_UNJOINABLE_PEER, - TRANSFER_ONGOING, - TRANSFER_AWAITING_PEER, - TRANSFER_AWAITING_HOST, - TRANSFER_TIMEOUT_EXPIRED, - TRANSFER_FINISHED, - FILE_AVAILABLE; - - static InteractionStatus fromString(String str) { - for (InteractionStatus s : values()) { - if (s.name().equals(str)) { - return s; - } - } - return INVALID; - } - - public static InteractionStatus fromIntTextMessage(int n) { - try { - return values()[n]; - } catch (ArrayIndexOutOfBoundsException e) { - return INVALID; - } - } - - public static InteractionStatus fromIntFile(int n) { - switch (n) { - case 0: - return INVALID; - case 1: - return TRANSFER_CREATED; - case 2: - case 9: - return TRANSFER_ERROR; - case 3: - return TRANSFER_AWAITING_PEER; - case 4: - return TRANSFER_AWAITING_HOST; - case 5: - return TRANSFER_ONGOING; - case 6: - return TRANSFER_FINISHED; - case 7: - case 8: - case 10: - return TRANSFER_UNJOINABLE_PEER; - case 11: - return TRANSFER_TIMEOUT_EXPIRED; - default: - return UNKNOWN; - } - } - - public boolean isError() { - return this == TRANSFER_ERROR || this == TRANSFER_UNJOINABLE_PEER || this == TRANSFER_CANCELED || this == TRANSFER_TIMEOUT_EXPIRED || this == FAILURE; - } - - public boolean isOver() { - return isError() || this == TRANSFER_FINISHED; - } - - } - - public enum InteractionType { - INVALID, - TEXT, - CALL, - CONTACT, - DATA_TRANSFER; - - static InteractionType fromString(String str) { - for (InteractionType type : values()) { - if (type.name().equals(str)) { - return type; - } - } - return INVALID; - } - - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Interaction.kt b/ring-android/libringclient/src/main/java/net/jami/model/Interaction.kt new file mode 100644 index 000000000..a7d36eb20 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/Interaction.kt @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.model + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.j256.ormlite.field.DatabaseField +import com.j256.ormlite.table.DatabaseTable + +@DatabaseTable(tableName = Interaction.TABLE_NAME) +open class Interaction { + var account: String? = null + var isIncoming = false + var contact: Contact? = null + + @DatabaseField(generatedId = true, columnName = COLUMN_ID, index = true) + var id = 0 + + @DatabaseField(columnName = COLUMN_AUTHOR, index = true) + var author: String? = null + + @DatabaseField(columnName = COLUMN_CONVERSATION, foreignColumnName = ConversationHistory.COLUMN_CONVERSATION_ID, foreign = true) + var conversation: ConversationHistory? = null + + @DatabaseField(columnName = COLUMN_TIMESTAMP, index = true) + var timestamp: Long = 0 + + @DatabaseField(columnName = COLUMN_BODY) + var body: String? = null + + @DatabaseField(columnName = COLUMN_TYPE) + var mType: String? = null + + @DatabaseField(columnName = COLUMN_STATUS) + var mStatus = InteractionStatus.UNKNOWN.toString() + + @DatabaseField(columnName = COLUMN_DAEMON_ID) + var daemonId: Long? = null + + @DatabaseField(columnName = COLUMN_IS_READ) + var mIsRead = 0 + + @DatabaseField(columnName = COLUMN_EXTRA_FLAG) + var mExtraFlag = JsonObject().toString() + + // Swarm + var conversationId: String? = null + private set + var messageId: String? = null + private set + var parentId: String? = null + private set + + /* Needed by ORMLite */ + constructor() + constructor(accountId: String) { + account = accountId + type = InteractionType.INVALID + } + + constructor(conversation: Conversation, type: InteractionType) { + this.conversation = conversation + account = conversation.accountId + mType = type.toString() + } + + constructor( + id: String, + author: String?, + conversation: ConversationHistory?, + timestamp: String, + body: String?, + type: String?, + status: String, + daemonId: String?, + isRead: String, + extraFlag: String + ) { + this.id = id.toInt() + this.author = author + this.conversation = conversation + this.timestamp = timestamp.toLong() + this.body = body + mType = type + mStatus = status + try { + this.daemonId = daemonId?.toLong() + } catch (e: NumberFormatException) { + this.daemonId = 0L + } + mIsRead = isRead.toInt() + mExtraFlag = extraFlag + } + + fun read() { + mIsRead = 1 + } + + var type: InteractionType + get() = InteractionType.fromString(mType) + set(type) { + mType = type.toString() + } + var status: InteractionStatus + get() = InteractionStatus.fromString(mStatus) + set(status) { + if (status == InteractionStatus.DISPLAYED) mIsRead = 1 + mStatus = status.toString() + } + val extraFlag: JsonObject + get() = toJson(mExtraFlag) + + fun toJson(value: String?): JsonObject { + return JsonParser.parseString(value).asJsonObject + } + + fun fromJson(json: JsonObject): String { + return json.toString() + } + + open val daemonIdString: String? + get() = daemonId?.toString() + + val isRead: Boolean + get() = mIsRead == 1 + + fun setSwarmInfo(conversationId: String) { + this.conversationId = conversationId + messageId = null + parentId = null + } + + fun setSwarmInfo(conversationId: String, messageId: String, parent: String?) { + this.conversationId = conversationId + this.messageId = messageId + parentId = parent + } + + enum class InteractionStatus { + UNKNOWN, SENDING, SUCCESS, DISPLAYED, INVALID, FAILURE, TRANSFER_CREATED, TRANSFER_ACCEPTED, TRANSFER_CANCELED, TRANSFER_ERROR, TRANSFER_UNJOINABLE_PEER, TRANSFER_ONGOING, TRANSFER_AWAITING_PEER, TRANSFER_AWAITING_HOST, TRANSFER_TIMEOUT_EXPIRED, TRANSFER_FINISHED, FILE_AVAILABLE; + + val isError: Boolean + get() = this == TRANSFER_ERROR || this == TRANSFER_UNJOINABLE_PEER || this == TRANSFER_CANCELED || this == TRANSFER_TIMEOUT_EXPIRED || this == FAILURE + val isOver: Boolean + get() = isError || this == TRANSFER_FINISHED + + companion object { + fun fromString(str: String): InteractionStatus { + for (s in values()) { + if (s.name == str) { + return s + } + } + return INVALID + } + + fun fromIntTextMessage(n: Int): InteractionStatus { + return try { + values()[n] + } catch (e: ArrayIndexOutOfBoundsException) { + INVALID + } + } + + fun fromIntFile(n: Int): InteractionStatus { + return when (n) { + 0 -> INVALID + 1 -> TRANSFER_CREATED + 2, 9 -> TRANSFER_ERROR + 3 -> TRANSFER_AWAITING_PEER + 4 -> TRANSFER_AWAITING_HOST + 5 -> TRANSFER_ONGOING + 6 -> TRANSFER_FINISHED + 7, 8, 10 -> TRANSFER_UNJOINABLE_PEER + 11 -> TRANSFER_TIMEOUT_EXPIRED + else -> UNKNOWN + } + } + } + } + + enum class InteractionType { + INVALID, TEXT, CALL, CONTACT, DATA_TRANSFER; + + companion object { + fun fromString(str: String?): InteractionType { + for (type in values()) { + if (type.name == str) { + return type + } + } + return INVALID + } + } + } + + companion object { + const val TABLE_NAME = "interactions" + const val COLUMN_ID = "id" + const val COLUMN_AUTHOR = "author" + const val COLUMN_CONVERSATION = "conversation" + const val COLUMN_TIMESTAMP = "timestamp" + const val COLUMN_BODY = "body" + const val COLUMN_TYPE = "type" + const val COLUMN_STATUS = "status" + const val COLUMN_DAEMON_ID = "daemon_id" + const val COLUMN_IS_READ = "is_read" + const val COLUMN_EXTRA_FLAG = "extra_data" + + fun compare(a: Interaction?, b: Interaction?): Int { + if (a == null) return if (b == null) 0 else -1 + return if (b == null) 1 else java.lang.Long.compare(a.timestamp, b.timestamp) + } + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/TextMessage.java b/ring-android/libringclient/src/main/java/net/jami/model/TextMessage.java deleted file mode 100644 index e9a4d87cd..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/TextMessage.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.model; - -public class TextMessage extends Interaction { - - private boolean mNotified; - - public TextMessage(String author, String account, String daemonId, ConversationHistory conversation, String message) { - mAuthor = author; - mAccount = account; - if (daemonId != null) { - try { - mDaemonId = Long.parseLong(daemonId); - } catch (NumberFormatException e) { - try { - mDaemonId = Long.parseLong(daemonId, 16); - } catch (NumberFormatException e2) { - mDaemonId = 0L; - } - } - } - mTimestamp = System.currentTimeMillis(); - mType = InteractionType.TEXT.toString(); - mConversation = conversation; - mIsIncoming = author != null; - mBody = message; - } - - public TextMessage(String author, String account, long timestamp, ConversationHistory conversation, String message, boolean isIncoming) { - mAuthor = author; - mAccount = account; - mTimestamp = timestamp; - mType = InteractionType.TEXT.toString(); - mConversation = conversation; - mIsIncoming = isIncoming; - mBody = message; - } - - public TextMessage(Interaction interaction) { - mId = interaction.getId(); - mAuthor = interaction.getAuthor(); - mTimestamp = interaction.getTimestamp(); - mType = interaction.getType().toString(); - mStatus = interaction.getStatus().toString(); - mConversation = interaction.getConversation(); - mIsIncoming = mAuthor != null; - mDaemonId = interaction.getDaemonId(); - mBody = interaction.getBody(); - mIsRead = interaction.isRead() ? 1 : 0; - mAccount = interaction.getAccount(); - mContact = interaction.getContact(); - } - - public boolean isNotified() { - return mNotified; - } - - public void setNotified(boolean notified) { - mNotified = notified; - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/TextMessage.kt b/ring-android/libringclient/src/main/java/net/jami/model/TextMessage.kt new file mode 100644 index 000000000..2eb1f4f87 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/TextMessage.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Rayan Osseiran <rayan.osseiran@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.model + +import java.lang.NumberFormatException + +class TextMessage : Interaction { + var isNotified = false + + constructor(author: String?, account: String, daemonId: String?, conversation: ConversationHistory?, message: String) { + this.author = author + this.account = account + if (daemonId != null) { + try { + this.daemonId = daemonId.toLong() + } catch (e: NumberFormatException) { + try { + this.daemonId = daemonId.toLong(16) + } catch (e2: NumberFormatException) { + this.daemonId = 0L + } + } + } + timestamp = System.currentTimeMillis() + mType = InteractionType.TEXT.toString() + this.conversation = conversation + isIncoming = author != null + body = message + } + + constructor(author: String?, account: String, timestamp: Long, conversation: ConversationHistory?, message: String, isIncoming: Boolean) { + this.author = author + this.account = account + this.timestamp = timestamp + mType = InteractionType.TEXT.toString() + this.conversation = conversation + this.isIncoming = isIncoming + body = message + } + + constructor(interaction: Interaction) { + id = interaction.id + author = interaction.author + timestamp = interaction.timestamp + mType = interaction.type.toString() + mStatus = interaction.status.toString() + conversation = interaction.conversation + isIncoming = author != null + daemonId = interaction.daemonId + body = interaction.body + mIsRead = if (interaction.isRead) 1 else 0 + account = interaction.account + contact = interaction.contact + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/TrustRequest.java b/ring-android/libringclient/src/main/java/net/jami/model/TrustRequest.java deleted file mode 100644 index 0c807adf8..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/TrustRequest.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -package net.jami.model; - -import net.jami.utils.StringUtils; - -import java.util.Map; - -import ezvcard.Ezvcard; -import ezvcard.VCard; - -public class TrustRequest { - private static final String TAG = TrustRequest.class.getSimpleName(); - - private final String mAccountId; - private String mContactUsername = null; - private final Uri mRequestUri; - private String mConversationId; - private VCard mVcard; - private String mMessage; - private final long mTimestamp; - private boolean mUsernameResolved = false; - - public TrustRequest(String accountId, Uri uri, long received, String payload, String conversationId) { - mAccountId = accountId; - mRequestUri = uri; - mConversationId = StringUtils.isEmpty(conversationId) ? null : conversationId; - mTimestamp = received; - mVcard = Ezvcard.parse(payload).first(); - mMessage = null; - } - - public TrustRequest(String accountId, Map<String, String> info) { - this(accountId, Uri.fromId(info.get("from")), Long.decode(info.get("received")) * 1000L, info.get("payload"), info.get("conversationId")); - } - - public TrustRequest(String accountId, Uri contactUri, String conversationId) { - mAccountId = accountId; - mRequestUri = contactUri; - mConversationId = conversationId; - mTimestamp = 0; - } - - public String getAccountId() { - return mAccountId; - } - - public Uri getUri() { - return mRequestUri; - } - - public String getConversationId() { - return mConversationId; - } - - public String getFullname() { - String fullname = ""; - if (mVcard != null && mVcard.getFormattedName() != null) { - fullname = mVcard.getFormattedName().getValue(); - } - return fullname; - } - - public String getDisplayname() { - boolean hasUsername = mContactUsername != null && !mContactUsername.isEmpty(); - return hasUsername ? mContactUsername : mRequestUri.toString(); - } - - public boolean isNameResolved() { - return mUsernameResolved; - } - - public void setUsername(String username) { - mContactUsername = username; - mUsernameResolved = true; - } - - public long getTimestamp() { - return mTimestamp; - } - - public VCard getVCard() { - return mVcard; - } - - public void setVCard(VCard vcard) { - mVcard = vcard; - } - - public String getMessage() { - return mMessage; - } - - public void setMessage(String message) { - mMessage = message; - } - - public void setConversationId(String conversationId) { - mConversationId = conversationId; - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/TrustRequest.kt b/ring-android/libringclient/src/main/java/net/jami/model/TrustRequest.kt new file mode 100644 index 000000000..d49b25d6c --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/TrustRequest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Aline Bonnet <aline.bonnet@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package net.jami.model + +import ezvcard.Ezvcard +import ezvcard.VCard +import net.jami.utils.StringUtils + +class TrustRequest { + val accountId: String + private var mContactUsername: String? = null + val uri: Uri + var conversationId: String? + var vCard: VCard? = null + var message: String? = null + val timestamp: Long + var isNameResolved = false + private set + + constructor(accountId: String, uri: Uri, received: Long, payload: String?, conversationId: String?) { + this.accountId = accountId + this.uri = uri + this.conversationId = if (StringUtils.isEmpty(conversationId)) null else conversationId + timestamp = received + vCard = if (payload == null) null else Ezvcard.parse(payload).first() + message = null + } + + constructor(accountId: String, info: Map<String, String>) : this(accountId, Uri.fromId(info["from"]!!), + java.lang.Long.decode(info["received"]) * 1000L, info["payload"], info["conversationId"]) + + constructor(accountId: String, contactUri: Uri, conversationId: String?) { + this.accountId = accountId + uri = contactUri + this.conversationId = conversationId + timestamp = 0 + } + + val fullname: String + get() { + var fullname = "" + if (vCard != null && vCard!!.formattedName != null) { + fullname = vCard!!.formattedName.value + } + return fullname + } + val displayname: String + get() { + val username = mContactUsername + return if (username != null && username.isNotEmpty()) username else uri.toString() + } + + fun setUsername(username: String?) { + mContactUsername = username + isNameResolved = true + } + + companion object { + private val TAG = TrustRequest::class.simpleName!! + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Uri.java b/ring-android/libringclient/src/main/java/net/jami/model/Uri.java deleted file mode 100644 index 87babe7be..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/model/Uri.java +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.model; - -import net.jami.utils.StringUtils; -import net.jami.utils.Tuple; - -import java.io.Serializable; -import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class Uri implements Serializable { - - private final String mScheme; - private final String mUsername; - private final String mHost; - private final String mPort; - - private static final Pattern ANGLE_BRACKETS_PATTERN = Pattern.compile("^\\s*([^<>]+)?\\s*<([^<>]+)>\\s*$"); - private static final Pattern HEX_ID_PATTERN = Pattern.compile("^\\p{XDigit}{40}$", Pattern.CASE_INSENSITIVE); - private static final Pattern RING_URI_PATTERN = Pattern.compile("^\\s*(?:ring(?:[\\s\\:]+))?(\\p{XDigit}{40})(?:@ring\\.dht)?\\s*$", Pattern.CASE_INSENSITIVE); - private static final Pattern URI_PATTERN = Pattern.compile("^\\s*(\\w+:)?(?:([\\w.]+)@)?(?:([\\d\\w\\.\\-]+)(?::(\\d+))?)\\s*$", Pattern.CASE_INSENSITIVE); - public static final String RING_URI_SCHEME = "ring:"; - public static final String JAMI_URI_SCHEME = "jami:"; - public static final String SWARM_SCHEME = "swarm:"; - - private static final String ipv4Pattern = "(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])"; - private static final String ipv6Pattern = "([0-9a-f]{1,4}:){7}([0-9a-f]){1,4}"; - private static final Pattern VALID_IPV4_PATTERN = Pattern.compile(ipv4Pattern, Pattern.CASE_INSENSITIVE); - private static final Pattern VALID_IPV6_PATTERN = Pattern.compile(ipv6Pattern, Pattern.CASE_INSENSITIVE); - - public Uri(String scheme, String user, String host, String port) { - mScheme = scheme; - mUsername = user; - mHost = host; - mPort = port; - } - - public Uri(String scheme, String host) { - mScheme = scheme; - mUsername = null; - mHost = host; - mPort = null; - } - - static public Uri fromString(String uri) { - Matcher m = URI_PATTERN.matcher(uri); - if (m.find()) { - return new Uri(m.group(1), m.group(2), m.group(3), m.group(4)); - } else { - return new Uri(null, null, uri, null); - } - } - - static public net.jami.utils.Tuple<Uri, String> fromStringWithName(String uriString) { - Matcher m = ANGLE_BRACKETS_PATTERN.matcher(uriString); - if (m.find()) { - return new Tuple<>(fromString(m.group(2)), m.group(1)); - } else { - return new Tuple<>(fromString(uriString), null); - } - } - - public static Uri fromId(String conversationId) { - return new Uri(null, null, conversationId, null); - } - - public String getRawRingId() { - if (getUsername() != null) { - return getUsername(); - } else { - return getHost(); - } - } - - public String getUri() { - if (isSwarm()) - return getScheme() + getRawRingId(); - if (isHexId()) - return getRawRingId(); - return toString(); - } - - public String getRawUriString() { - if (isSwarm()) - return getScheme() + getRawRingId(); - if (isHexId()) { - return RING_URI_SCHEME + getRawRingId(); - } - return toString(); - } - - @Override - public String toString() { - StringBuilder builder = new StringBuilder(64); - if (!net.jami.utils.StringUtils.isEmpty(mScheme)) { - builder.append(mScheme); - } - if (!net.jami.utils.StringUtils.isEmpty(mUsername)) { - builder.append(mUsername).append('@'); - } - if (!net.jami.utils.StringUtils.isEmpty(mHost)) { - builder.append(mHost); - } - if (!net.jami.utils.StringUtils.isEmpty(mPort)) { - builder.append(':').append(mPort); - } - return builder.toString(); - } - - public boolean isSingleIp() { - return (getUsername() == null || getUsername().isEmpty()) && isIpAddress(getHost()); - } - - public boolean isHexId() { - return (getHost() != null && HEX_ID_PATTERN.matcher(getHost()).find()) - || (getUsername() != null && HEX_ID_PATTERN.matcher(getUsername()).find()); - } - public boolean isSwarm() { - return SWARM_SCHEME.equals(getScheme()); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof Uri)) { - return false; - } - Uri uo = (Uri) o; - return Objects.equals(getUsername(), uo.getUsername()) - && Objects.equals(getHost(), uo.getHost()); - } - - public boolean isEmpty() { - return net.jami.utils.StringUtils.isEmpty(getUsername()) && StringUtils.isEmpty(getHost()); - } - - /** - * Determine if the given string is a valid IPv4 or IPv6 address. This method - * uses pattern matching to see if the given string could be a valid IP address. - * - * @param ipAddress A string that is to be examined to verify whether or not - * it could be a valid IP address. - * @return <code>true</code> if the string is a value that is a valid IP address, - * <code>false</code> otherwise. - */ - public static boolean isIpAddress(String ipAddress) { - Matcher m1 = VALID_IPV4_PATTERN.matcher(ipAddress); - if (m1.matches()) { - return true; - } - Matcher m2 = VALID_IPV6_PATTERN.matcher(ipAddress); - return m2.matches(); - } - - public String getScheme() { - return mScheme; - } - - public String getUsername() { - return mUsername; - } - - public String getHost() { - return mHost; - } - - public String getPort() { - return mPort; - } - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/model/Uri.kt b/ring-android/libringclient/src/main/java/net/jami/model/Uri.kt new file mode 100644 index 000000000..6af5d1303 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/model/Uri.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.model + +import net.jami.utils.StringUtils +import net.jami.utils.Tuple +import java.io.Serializable +import java.lang.StringBuilder +import java.util.regex.Pattern + +class Uri : Serializable { + val scheme: String? + val username: String? + private val mHost: String + val port: String? + + constructor() { + scheme = null + username = null + mHost = "" + port = null + } + constructor(scheme: String?, user: String?, host: String, port: String?) { + this.scheme = scheme + username = user + mHost = host + this.port = port + } + + constructor(scheme: String?, host: String) { + this.scheme = scheme + username = null + mHost = host + port = null + } + + val rawRingId: String + get() = username ?: host + + val uri: String + get() { + if (isSwarm) return scheme + rawRingId + return if (isHexId) rawRingId else toString() + } + val rawUriString: String + get() { + if (isSwarm) return scheme + rawRingId + return if (isHexId) { + RING_URI_SCHEME + rawRingId + } else toString() + } + + override fun toString(): String { + val builder = StringBuilder(64) + if (!StringUtils.isEmpty(scheme)) { + builder.append(scheme) + } + if (!StringUtils.isEmpty(username)) { + builder.append(username).append('@') + } + if (!StringUtils.isEmpty(mHost)) { + builder.append(mHost) + } + if (!StringUtils.isEmpty(port)) { + builder.append(':').append(port) + } + return builder.toString() + } + + val isSingleIp: Boolean + get() = (username == null || username.isEmpty()) && isIpAddress(host) + val isHexId: Boolean + get() = HEX_ID_PATTERN.matcher(host).find() || username != null && HEX_ID_PATTERN.matcher(username).find() + val isSwarm: Boolean + get() = SWARM_SCHEME == scheme + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other !is Uri) { + return false + } + return (username == other.username + && host == other.host) + } + + val isEmpty: Boolean + get() = StringUtils.isEmpty(username) && StringUtils.isEmpty(host) + val host: String + get() = mHost + + companion object { + private val ANGLE_BRACKETS_PATTERN = Pattern.compile("^\\s*([^<>]+)?\\s*<([^<>]+)>\\s*$") + private val HEX_ID_PATTERN = Pattern.compile("^\\p{XDigit}{40}$", Pattern.CASE_INSENSITIVE) + private val RING_URI_PATTERN = Pattern.compile("^\\s*(?:ring(?:[\\s:]+))?(\\p{XDigit}{40})(?:@ring\\.dht)?\\s*$", Pattern.CASE_INSENSITIVE) + private val URI_PATTERN = Pattern.compile("^\\s*(\\w+:)?(?:([\\w.]+)@)?(?:([\\d\\w.\\-]+)(?::(\\d+))?)\\s*$", Pattern.CASE_INSENSITIVE) + const val RING_URI_SCHEME = "ring:" + const val JAMI_URI_SCHEME = "jami:" + const val SWARM_SCHEME = "swarm:" + private const val ipv4Pattern = "(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])" + private const val ipv6Pattern = "([0-9a-f]{1,4}:){7}([0-9a-f]){1,4}" + private val VALID_IPV4_PATTERN = Pattern.compile(ipv4Pattern, Pattern.CASE_INSENSITIVE) + private val VALID_IPV6_PATTERN = Pattern.compile(ipv6Pattern, Pattern.CASE_INSENSITIVE) + + @JvmStatic + fun fromString(uri: String): Uri { + val m = URI_PATTERN.matcher(uri) + return if (m.find()) { + Uri(m.group(1), m.group(2), m.group(3), m.group(4)) + } else { + Uri(null, null, uri, null) + } + } + + @JvmStatic + fun fromStringWithName(uriString: String): Tuple<Uri, String?> { + val m = ANGLE_BRACKETS_PATTERN.matcher(uriString) + return if (m.find()) { + Tuple(fromString(m.group(2)), m.group(1)) + } else { + Tuple(fromString(uriString), null) + } + } + + @JvmStatic + fun fromId(conversationId: String): Uri { + return Uri(null, null, conversationId, null) + } + + /** + * Determine if the given string is a valid IPv4 or IPv6 address. This method + * uses pattern matching to see if the given string could be a valid IP address. + * + * @param ipAddress A string that is to be examined to verify whether or not + * it could be a valid IP address. + * @return `true` if the string is a value that is a valid IP address, + * `false` otherwise. + */ + @JvmStatic + fun isIpAddress(ipAddress: String): Boolean { + val m1 = VALID_IPV4_PATTERN.matcher(ipAddress) + if (m1.matches()) { + return true + } + val m2 = VALID_IPV6_PATTERN.matcher(ipAddress) + return m2.matches() + } + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/mvp/GenericView.java b/ring-android/libringclient/src/main/java/net/jami/mvp/GenericView.kt similarity index 89% rename from ring-android/libringclient/src/main/java/net/jami/mvp/GenericView.java rename to ring-android/libringclient/src/main/java/net/jami/mvp/GenericView.kt index f7bc29bb2..d23aa4f2c 100644 --- a/ring-android/libringclient/src/main/java/net/jami/mvp/GenericView.java +++ b/ring-android/libringclient/src/main/java/net/jami/mvp/GenericView.kt @@ -17,10 +17,8 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ -package net.jami.mvp; +package net.jami.mvp -public interface GenericView<VM> { - - void showViewModel(VM viewModel); - -} +interface GenericView<VM> { + fun showViewModel(viewModel: VM) +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/mvp/RootPresenter.java b/ring-android/libringclient/src/main/java/net/jami/mvp/RootPresenter.java index 58b57c7b5..8b75cbe6e 100644 --- a/ring-android/libringclient/src/main/java/net/jami/mvp/RootPresenter.java +++ b/ring-android/libringclient/src/main/java/net/jami/mvp/RootPresenter.java @@ -27,9 +27,7 @@ public abstract class RootPresenter<T> { protected CompositeDisposable mCompositeDisposable = new CompositeDisposable(); - public RootPresenter() { - - } + public RootPresenter() { } private WeakReference<T> mView; @@ -40,9 +38,12 @@ public abstract class RootPresenter<T> { public void unbindView() { if (mView != null) { mView.clear(); + mView = null; } + mCompositeDisposable.clear(); + } - mView = null; + public void onDestroy() { mCompositeDisposable.dispose(); } @@ -50,7 +51,6 @@ public abstract class RootPresenter<T> { if (mView != null) { return mView.get(); } - return null; } diff --git a/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationPresenter.java b/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationPresenter.java index 8640f713a..92d31e72d 100644 --- a/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationPresenter.java +++ b/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationPresenter.java @@ -67,12 +67,13 @@ public class HomeNavigationPresenter extends RootPresenter<HomeNavigationView> { public void bindView(HomeNavigationView view) { super.bindView(view); mCompositeDisposable.add(mAccountService.getCurrentProfileAccountSubject() - .switchMapSingle(account -> account.getAccountAlias().map(alias -> new Tuple<>(account, alias))) + .switchMap(account -> account.getLoadedProfileObservable() + .map(alias -> new Tuple<>(account, alias))) .observeOn(mUiScheduler) .subscribe(alias -> { HomeNavigationView v = getView(); if (v != null) - v.showViewModel(new HomeNavigationViewModel(alias.first, alias.second)); + v.showViewModel(new HomeNavigationViewModel(alias.first, alias.second.first)); }, e -> Log.e(TAG, "Error loading account list !", e))); mCompositeDisposable.add(mAccountService.getObservableAccounts() .observeOn(mUiScheduler) @@ -94,10 +95,8 @@ public class HomeNavigationPresenter extends RootPresenter<HomeNavigationView> { File filesDir = mDeviceRuntimeService.provideFilesDir(); mCompositeDisposable.add(Single.zip( - VCardUtils.loadLocalProfileFromDiskWithDefault(filesDir, accountId) - .subscribeOn(Schedulers.io()), - photo - .subscribeOn(Schedulers.io()), + VCardUtils.loadLocalProfileFromDiskWithDefault(filesDir, accountId).subscribeOn(Schedulers.io()), + photo.subscribeOn(Schedulers.io()), (vcard, pic) -> { vcard.setUid(new Uid(ringId)); vcard.removeProperties(Photo.class); @@ -110,6 +109,7 @@ public class HomeNavigationPresenter extends RootPresenter<HomeNavigationView> { .subscribe(vcard -> { account.resetProfile(); mAccountService.refreshAccounts(); + getView().setPhoto(account); }, e -> Log.e(TAG, "Error saving vCard !", e))); } @@ -129,6 +129,7 @@ public class HomeNavigationPresenter extends RootPresenter<HomeNavigationView> { .subscribe(vcard -> { account.resetProfile(); mAccountService.refreshAccounts(); + bindView(getView()); }, e -> Log.e(TAG, "Error saving vCard !", e))); } diff --git a/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationView.java b/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationView.java index 1a6daa774..fc87c0309 100644 --- a/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationView.java +++ b/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationView.java @@ -35,4 +35,6 @@ public interface HomeNavigationView { void askGalleryPermission(); + void setPhoto(Account account); + } diff --git a/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationViewModel.java b/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationViewModel.kt similarity index 67% rename from ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationViewModel.java rename to ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationViewModel.kt index 49108f62b..6589fd507 100644 --- a/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationViewModel.java +++ b/ring-android/libringclient/src/main/java/net/jami/navigation/HomeNavigationViewModel.kt @@ -18,25 +18,8 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ +package net.jami.navigation -package net.jami.navigation; +import net.jami.model.Account -import net.jami.model.Account; - -public class HomeNavigationViewModel { - final private Account mAccount; - final private String mAlias; - - public HomeNavigationViewModel(Account account, String alias) { - mAccount = account; - mAlias = alias; - } - - public Account getAccount() { - return mAccount; - } - - public String getAlias() { - return mAlias; - } -} +class HomeNavigationViewModel(val account: Account, val alias: String?) \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/services/AccountService.java b/ring-android/libringclient/src/main/java/net/jami/services/AccountService.java deleted file mode 100644 index a5b4d6a4e..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/services/AccountService.java +++ /dev/null @@ -1,1918 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.services; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import net.jami.daemon.Blob; -import net.jami.daemon.DataTransferInfo; -import net.jami.daemon.JamiService; -import net.jami.daemon.StringMap; -import net.jami.daemon.UintVect; -import net.jami.model.Account; -import net.jami.model.AccountConfig; -import net.jami.model.Call; -import net.jami.model.Codec; -import net.jami.model.ConfigKey; -import net.jami.model.Contact; -import net.jami.model.ContactEvent; -import net.jami.model.Conversation; -import net.jami.model.DataTransfer; -import net.jami.model.DataTransferError; -import net.jami.model.Interaction; -import net.jami.model.Interaction.InteractionStatus; -import net.jami.model.TextMessage; -import net.jami.model.TrustRequest; -import net.jami.model.Uri; -import net.jami.smartlist.SmartListViewModel; -import net.jami.utils.FileUtils; -import net.jami.utils.Log; -import net.jami.utils.StringUtils; -import net.jami.utils.SwigNativeConverter; -import net.jami.utils.VCardUtils; - -import java.io.File; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.SocketException; -import java.net.URLEncoder; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; -import javax.inject.Named; - -import ezvcard.Ezvcard; -import ezvcard.VCard; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.PublishSubject; -import io.reactivex.rxjava3.subjects.SingleSubject; -import io.reactivex.rxjava3.subjects.Subject; - -/** - * This service handles the accounts - * - Load and manage the accounts stored in the daemon - * - Keep a local cache of the accounts - * - handle the callbacks that are send by the daemon - */ -public class AccountService { - - private static final String TAG = AccountService.class.getSimpleName(); - - private static final int VCARD_CHUNK_SIZE = 1000; - private static final long DATA_TRANSFER_REFRESH_PERIOD = 500; - - private static final int PIN_GENERATION_SUCCESS = 0; - private static final int PIN_GENERATION_WRONG_PASSWORD = 1; - private static final int PIN_GENERATION_NETWORK_ERROR = 2; - - @Inject - @Named("DaemonExecutor") - ScheduledExecutorService mExecutor; - - @Inject - HistoryService mHistoryService; - - @Inject - DeviceRuntimeService mDeviceRuntimeService; - - @Inject - VCardService mVCardService; - - private Account mCurrentAccount; - private List<Account> mAccountList = new ArrayList<>(); - private boolean mHasSipAccount; - private boolean mHasRingAccount; - - private DataTransfer mStartingTransfer = null; - - private final BehaviorSubject<List<Account>> accountsSubject = BehaviorSubject.create(); - private final Subject<Account> accountSubject = PublishSubject.create(); - private final Observable<Account> currentAccountSubject = accountsSubject - .filter(l -> !l.isEmpty()) - .map(l -> l.get(0)) - .distinctUntilChanged(); - - public static class Message { - String accountId; - String messageId; - String callId; - String author; - Map<String, String> messages; - } - public static class Location { - public enum Type { - position, - stop - } - Type type; - String accountId; - String callId; - Uri peer; - long time; - double latitude; - double longitude; - - public Type getType() { - return type; - } - - public String getAccount() { - return accountId; - } - - public Uri getPeer() { - return peer; - } - - public long getDate() { - return time; - } - - public double getLatitude() { - return latitude; - } - - public double getLongitude() { - return longitude; - } - } - - private final Subject<Message> incomingMessageSubject = PublishSubject.create(); - private final Subject<Interaction> incomingSwarmMessageSubject = PublishSubject.create(); - - private final Observable<TextMessage> incomingTextMessageSubject = incomingMessageSubject - .flatMapMaybe(msg -> { - String message = msg.messages.get(CallService.MIME_TEXT_PLAIN); - if (message != null) { - return mHistoryService - .incomingMessage(msg.accountId, msg.messageId, msg.author, message) - .toMaybe(); - } - return Maybe.empty(); - }) - .share(); - - private final Observable<Location> incomingLocationSubject = incomingMessageSubject - .flatMapMaybe(msg -> { - try { - String loc = msg.messages.get(CallService.MIME_GEOLOCATION); - if (loc == null) - return Maybe.empty(); - - JsonObject obj = JsonParser.parseString(loc).getAsJsonObject(); - if (obj.size() < 2) - return Maybe.empty(); - Location l = new Location(); - - JsonElement type = obj.get("type"); - if (type == null || type.getAsString().equals(Location.Type.position.toString())) { - l.type = Location.Type.position; - l.latitude = obj.get("lat").getAsDouble(); - l.longitude = obj.get("long").getAsDouble(); - } else if (type.getAsString().equals(Location.Type.stop.toString())) { - l.type = Location.Type.stop; - } - l.time = obj.get("time").getAsLong(); - l.accountId = msg.accountId; - l.callId = msg.callId; - l.peer = Uri.fromId(msg.author); - return Maybe.just(l); - } catch (Exception e) { - Log.w(TAG, "Failed to receive geolocation", e); - return Maybe.empty(); - } - }) - .share(); - - private final Subject<Interaction> messageSubject = PublishSubject.create(); - private final Subject<DataTransfer> dataTransferSubject = PublishSubject.create(); - private final Subject<TrustRequest> incomingRequestsSubject = PublishSubject.create(); - - public void refreshAccounts() { - accountsSubject.onNext(mAccountList); - } - - public static class RegisteredName { - public String accountId; - public String name; - public String address; - public int state; - } - public static class UserSearchResult { - private final String accountId; - private final String query; - - public int state; - public List<Contact> results; - - public UserSearchResult(String account, String query) { - accountId = account; - this.query = query; - } - - public String getAccountId() { - return accountId; - } - - public String getQuery() { - return query; - } - - public List<Observable<SmartListViewModel>> getResultsViewModels() { - List<Observable<SmartListViewModel>> vms = new ArrayList<>(results.size()); - for (Contact user : results) { - vms.add(Observable.just(new SmartListViewModel(accountId, user, null))); - } - return vms; - } - } - - private final Subject<RegisteredName> registeredNameSubject = PublishSubject.create(); - private final Subject<UserSearchResult> searchResultSubject = PublishSubject.create(); - - private static class ExportOnRingResult { - String accountId; - int code; - String pin; - } - - private static class DeviceRevocationResult { - String accountId; - String deviceId; - int code; - } - - private static class MigrationResult { - String accountId; - String state; - } - - private final Subject<ExportOnRingResult> mExportSubject = PublishSubject.create(); - private final Subject<DeviceRevocationResult> mDeviceRevocationSubject = PublishSubject.create(); - private final Subject<MigrationResult> mMigrationSubject = PublishSubject.create(); - - public Observable<RegisteredName> getRegisteredNames() { - return registeredNameSubject; - } - public Observable<UserSearchResult> getSearchResults() { - return searchResultSubject; - } - - public Observable<TextMessage> getIncomingMessages() { - return incomingTextMessageSubject; - } - - public Observable<TextMessage> getIncomingSwarmMessages() { - return incomingSwarmMessageSubject - .filter(i -> i instanceof TextMessage) - .map(i -> (TextMessage) i); - } - - public Observable<Location> getLocationUpdates() { - return incomingLocationSubject; - } - - public Observable<Interaction> getMessageStateChanges() { - return messageSubject; - } - - public Observable<TrustRequest> getIncomingRequests() { - return incomingRequestsSubject; - } - - /** - * @return true if at least one of the loaded accounts is a SIP one - */ - public boolean hasSipAccount() { - return mHasSipAccount; - } - - /** - * @return true if at least one of the loaded accounts is a Ring one - */ - public boolean hasRingAccount() { - return mHasRingAccount; - } - - /** - * Loads the accounts from the daemon and then builds the local cache (also sends ACCOUNTS_CHANGED event) - * - * @param isConnected sets the initial connection state of the accounts - */ - public void loadAccountsFromDaemon(final boolean isConnected) { - mExecutor.execute(() -> { - refreshAccountsCacheFromDaemon(); - setAccountsActive(isConnected); - }); - } - - private void refreshAccountsCacheFromDaemon() { - Log.w(TAG, "refreshAccountsCacheFromDaemon"); - boolean hasSip = false, hasJami = false; - List<Account> curList = mAccountList; - List<String> accountIds = new ArrayList<>(JamiService.getAccountList()); - List<Account> newAccounts = new ArrayList<>(accountIds.size()); - for (String id : accountIds) { - for (Account acc : curList) - if (acc.getAccountID().equals(id)) { - newAccounts.add(acc); - break; - } - } - - // Cleanup removed accounts - for (Account acc : curList) - if (!newAccounts.contains(acc)) - acc.cleanup(); - - mAccountList = newAccounts; - - for (String accountId : accountIds) { - Account account = getAccount(accountId); - Map<String, String> details = JamiService.getAccountDetails(accountId).toNative(); - List<Map<String, String>> credentials = JamiService.getCredentials(accountId).toNative(); - Map<String, String> volatileAccountDetails = JamiService.getVolatileAccountDetails(accountId).toNative(); - if (account == null) { - account = new Account(accountId, details, credentials, volatileAccountDetails); - newAccounts.add(account); - } else { - account.setDetails(details); - account.setCredentials(credentials); - account.setVolatileDetails(volatileAccountDetails); - } - - if (account.isSip()) { - hasSip = true; - } else if (account.isJami()) { - hasJami = true; - boolean enabled = account.isEnabled(); - - account.setDevices(JamiService.getKnownRingDevices(accountId).toNative()); - account.setContacts(JamiService.getContacts(accountId).toNative()); - List<Map<String, String>> requests = JamiService.getTrustRequests(accountId).toNative(); - for (Map<String, String> requestInfo : requests) { - TrustRequest request = new TrustRequest(accountId, requestInfo); - account.addRequest(request); - } - Log.w(TAG, accountId + " loading conversations"); - List<String> conversations = JamiService.getConversations(account.getAccountID()); - for (String conversationId : conversations) { - Map<String, String> info = JamiService.conversationInfos(accountId, conversationId); - /*for (Map.Entry<String, String> i : info.entrySet()) { - Log.w(TAG, "conversation info: " + i.getKey() + " " + i.getValue()); - }*/ - Conversation.Mode mode = Conversation.Mode.values()[Integer.parseInt(info.get("mode"))]; - Conversation conversation = account.newSwarm(conversationId, mode); - conversation.setLastMessageRead(mHistoryService.getLastMessageRead(accountId, conversation.getUri())); - for (Map<String, String> member : JamiService.getConversationMembers(accountId, conversationId)) { - Uri uri = Uri.fromId(member.get("uri")); - Contact contact = conversation.findContact(uri); - if (contact == null) { - contact = account.getContactFromCache(uri); - conversation.addContact(contact); - } - } - conversation.setLastElementLoaded(Completable.defer(() -> loadMore(conversation, 2).ignoreElement()).cache()); - account.conversationStarted(conversation); - //account.addSwarmConversation(conversationId, members); - } - for (Map<String, String> requestData : JamiService.getConversationRequests(account.getAccountID()).toNative()) { - /*for (Map.Entry<String, String> e : requestData.entrySet()) { - Log.e(TAG, "Request: " + e.getKey() + " " + e.getValue()); - }*/ - String conversationId = requestData.get("id"); - Uri from = Uri.fromString(requestData.get("from")); - TrustRequest request = account.getRequest(from); - if (request != null) { - request.setConversationId(conversationId); - } else { - account.addRequest(new TrustRequest(account.getAccountID(), from, conversationId)); - } - } - - if (enabled) { - for (Contact contact : account.getContacts().values()) { - if (!contact.isUsernameLoaded()) - JamiService.lookupAddress(accountId, "", contact.getUri().getRawRingId()); - } - } - } - - } - - mHasSipAccount = hasSip; - mHasRingAccount = hasJami; - if (!newAccounts.isEmpty()) { - Account newAccount = newAccounts.get(0); - if (mCurrentAccount != newAccount) { - mCurrentAccount = newAccount; - } - } - - accountsSubject.onNext(newAccounts); - } - - private Account getAccountByName(final String name) { - for (Account acc : mAccountList) { - if (acc.getAlias().equals(name)) - return acc; - } - return null; - } - - public String getNewAccountName(final String prefix) { - String name = String.format(prefix, "").trim(); - if (getAccountByName(name) == null) { - return name; - } - int num = 1; - do { - num++; - name = String.format(prefix, num).trim(); - } while (getAccountByName(name) != null); - return name; - } - - /** - * Adds a new Account in the Daemon (also sends an ACCOUNT_ADDED event) - * Sets the new account as the current one - * - * @param map the account details - * @return the created Account - */ - public Observable<Account> addAccount(final Map<String, String> map) { - return Observable.fromCallable(() -> { - String accountId = JamiService.addAccount(StringMap.toSwig(map)); - if (StringUtils.isEmpty(accountId)) { - throw new RuntimeException("Can't create account."); - } - Account account = getAccount(accountId); - if (account == null) { - Map<String, String> accountDetails = JamiService.getAccountDetails(accountId).toNative(); - List<Map<String, String>> accountCredentials = JamiService.getCredentials(accountId).toNative(); - Map<String, String> accountVolatileDetails = JamiService.getVolatileAccountDetails(accountId).toNative(); - Map<String, String> accountDevices = JamiService.getKnownRingDevices(accountId).toNative(); - account = new Account(accountId, accountDetails, accountCredentials, accountVolatileDetails); - account.setDevices(accountDevices); - if (account.isSip()) { - account.setRegistrationState(AccountConfig.STATE_READY, -1); - } - mAccountList.add(account); - accountsSubject.onNext(mAccountList); - } - return account; - }) - .flatMap(account -> accountSubject - .filter(acc -> acc.getAccountID().equals(account.getAccountID())) - .startWithItem(account)) - .subscribeOn(Schedulers.from(mExecutor)); - } - - /** - * @return the current Account from the local cache - */ - public Account getCurrentAccount() { - return mCurrentAccount; - } - public int getCurrentAccountIndex() { - return mAccountList.indexOf(mCurrentAccount); - } - - /** - * Sets the current Account in the local cache (also sends a ACCOUNTS_CHANGED event) - */ - public void setCurrentAccount(Account currentAccount) { - if (mCurrentAccount == currentAccount) - return; - mCurrentAccount = currentAccount; - - // the account order is changed - // the current Account is now on the top of the list - final List<Account> accounts = getAccounts(); - List<String> orderedAccountIdList = new ArrayList<>(accounts.size()); - String selectedID = mCurrentAccount.getAccountID(); - orderedAccountIdList.add(selectedID); - for (Account account : accounts) { - if (account.getAccountID().contentEquals(selectedID)) { - continue; - } - orderedAccountIdList.add(account.getAccountID()); - } - - setAccountOrder(orderedAccountIdList); - } - - /** - * @return the Account from the local cache that matches the accountId - */ - public Account getAccount(String accountId) { - if (!StringUtils.isEmpty(accountId)) { - for (Account account : mAccountList) - if (accountId.equals(account.getAccountID())) - return account; - } - return null; - } - - public Single<Account> getAccountSingle(final String accountId) { - return accountsSubject - .firstOrError() - .map(accounts -> { - for (Account account : accounts) { - if (account.getAccountID().equals(accountId)) { - return account; - } - } - Log.d(TAG, "getAccountSingle() can't find account " + accountId); - throw new IllegalArgumentException(); - }); - } - - /** - * @return Accounts list from the local cache - */ - public List<Account> getAccounts() { - return mAccountList; - } - - public Observable<List<Account>> getObservableAccountList() { - return accountsSubject; - } - - public Subject<Account> getObservableAccounts() { - return accountSubject; - } - - public Observable<Account> getObservableAccountUpdates(String accountId) { - return accountSubject.filter(acc -> acc.getAccountID().equals(accountId)); - } - - public Observable<Account> getObservableAccount(String accountId) { - return Observable.fromCallable(() -> getAccount(accountId)) - .concatWith(getObservableAccountUpdates(accountId)); - } - public Observable<Account> getObservableAccount(Account account) { - return Observable.just(account) - .concatWith(accountSubject.filter(acc -> acc == account)); - } - - public Observable<Account> getCurrentAccountSubject() { - return currentAccountSubject; - } - - public Observable<Account> getCurrentProfileAccountSubject() { - return currentAccountSubject.flatMapSingle(a -> mVCardService.loadProfile(a).map(p -> a)); - } - - public void subscribeBuddy(final String accountID, final String uri, final boolean flag) { - mExecutor.execute(() -> JamiService.subscribeBuddy(accountID, uri, flag)); - } - - /** - * Send profile through SIP - */ - public void sendProfile(final String callId, final String accountId) { - mVCardService.loadSmallVCard(accountId, VCardService.MAX_SIZE_SIP) - .subscribeOn(Schedulers.computation()) - .observeOn(Schedulers.from(mExecutor)) - .subscribe(vcard -> { - String stringVCard = VCardUtils.vcardToString(vcard); - int nbTotal = stringVCard.length() / VCARD_CHUNK_SIZE + (stringVCard.length() % VCARD_CHUNK_SIZE != 0 ? 1 : 0); - int i = 1; - Random r = new Random(System.currentTimeMillis()); - int key = Math.abs(r.nextInt()); - - Log.d(TAG, "sendProfile, vcard " + callId); - - while (i <= nbTotal) { - HashMap<String, String> chunk = new HashMap<>(); - Log.d(TAG, "length vcard " + stringVCard.length() + " id " + key + " part " + i + " nbTotal " + nbTotal); - String keyHashMap = VCardUtils.MIME_PROFILE_VCARD + "; id=" + key + ",part=" + i + ",of=" + nbTotal; - String message = stringVCard.substring(0, Math.min(VCARD_CHUNK_SIZE, stringVCard.length())); - chunk.put(keyHashMap, message); - JamiService.sendTextMessage(callId, StringMap.toSwig(chunk), "Me", false); - if (stringVCard.length() > VCARD_CHUNK_SIZE) { - stringVCard = stringVCard.substring(VCARD_CHUNK_SIZE); - } - i++; - } - }, e -> Log.w(TAG, "Not sending empty profile", e)); - } - - public void setMessageDisplayed(String accountId, Uri conversationUri, String messageId) { - mExecutor.execute(() -> JamiService.setMessageDisplayed(accountId, conversationUri.getUri(), messageId, 3)); - } - - public Single<Conversation> startConversation(String accountId, Collection<String> initialMembers) { - Account account = getAccount(accountId); - return Single.fromCallable(() -> { - Log.w(TAG, "startConversation"); - String id = JamiService.startConversation(accountId); - Conversation conversation = account.getSwarm(id);//new Conversation(accountId, new Uri(id)); - for (String member : initialMembers) { - Log.w(TAG, "addConversationMember " + member); - JamiService.addConversationMember(accountId, id, member); - conversation.addContact(account.getContactFromCache(member)); - } - account.conversationStarted(conversation); - Log.w(TAG, "loadConversationMessages"); - //loadMore(conversation); - //JamiService.loadConversationMessages(accountId, id, id, 2); - return conversation; - }).subscribeOn(Schedulers.from(mExecutor)); - } - - public Completable removeConversation(String accountId, Uri conversationUri) { - return Completable.fromAction(() -> JamiService.removeConversation(accountId, conversationUri.getRawRingId())) - .subscribeOn(Schedulers.from(mExecutor)); - } - - public void loadConversationHistory(String accountId, Uri conversationUri, String root, long n) { - JamiService.loadConversationMessages(accountId, conversationUri.getRawRingId(), root, n); - } - - public Single<Conversation> loadMore(Conversation conversation) { - return loadMore(conversation, 16); - } - public Single<Conversation> loadMore(Conversation conversation, int n) { - synchronized (conversation) { - if (conversation.isLoaded()) { - Log.w(TAG, "loadMore: conversation already fully loaded"); - return Single.just(conversation); - } - - SingleSubject<Conversation> ret = conversation.getLoading(); - if (ret != null) - return ret; - ret = SingleSubject.create(); - Collection<String> roots = conversation.getSwarmRoot(); - Log.w(TAG, "loadMore " + conversation.getUri() + " " + roots); - - conversation.setLoading(ret); - if (roots.isEmpty()) - loadConversationHistory(conversation.getAccountId(), conversation.getUri(), "", n); - else { - for (String root : roots) - loadConversationHistory(conversation.getAccountId(), conversation.getUri(), root, n); - } - return ret; - } - } - - public void sendConversationMessage(String accountId, Uri conversationUri, String txt) { - mExecutor.execute(() -> { - Log.w(TAG, "sendConversationMessages " + conversationUri.getRawRingId() + " : " + txt); - JamiService.sendMessage(accountId, conversationUri.getRawRingId(), txt, ""); - }); - } - - /** - * @return Account Ids list from Daemon - */ - public Single<List<String>> getAccountList() { - return Single.fromCallable(() -> (List<String>)new ArrayList<>(JamiService.getAccountList())) - .subscribeOn(Schedulers.from(mExecutor)); - } - - /** - * Sets the order of the accounts in the Daemon - * - * @param accountOrder The ordered list of account ids - */ - public void setAccountOrder(final List<String> accountOrder) { - mExecutor.execute(() -> { - final StringBuilder order = new StringBuilder(); - for (String accountId : accountOrder) { - order.append(accountId); - order.append(File.separator); - } - JamiService.setAccountsOrder(order.toString()); - }); - } - - /** - * @return the account details from the Daemon - */ - public Map<String, String> getAccountDetails(final String accountId) { - try { - return mExecutor.submit(() -> JamiService.getAccountDetails(accountId).toNative()).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getAccountDetails()", e); - } - return null; - } - - /** - * Sets the account details in the Daemon - */ - public void setAccountDetails(final String accountId, final Map<String, String> map) { - Log.i(TAG, "setAccountDetails() " + accountId); - mExecutor.execute(() -> JamiService.setAccountDetails(accountId, StringMap.toSwig(map))); - } - - public Single<String> migrateAccount(String accountId, String password) { - return mMigrationSubject - .filter(r -> r.accountId.equals(accountId)) - .map(r -> r.state) - .firstOrError() - .doOnSubscribe(s -> { - final Account account = getAccount(accountId); - HashMap<String, String> details = account.getDetails(); - details.put(ConfigKey.ARCHIVE_PASSWORD.key(), password); - mExecutor.execute(() -> JamiService.setAccountDetails(accountId, StringMap.toSwig(details))); - }) - .subscribeOn(Schedulers.from(mExecutor)); - } - - public void setAccountEnabled(final String accountId, final boolean active) { - mExecutor.execute(() -> JamiService.sendRegister(accountId, active)); - } - - /** - * Sets the activation state of the account in the Daemon - */ - public void setAccountActive(final String accountId, final boolean active) { - mExecutor.execute(() -> JamiService.setAccountActive(accountId, active)); - } - - /** - * Sets the activation state of all the accounts in the Daemon - */ - public void setAccountsActive(final boolean active) { - mExecutor.execute(() -> { - Log.i(TAG, "setAccountsActive() running... " + active); - for (Account a : mAccountList) { - // If the proxy is enabled we can considered the account - // as always active - if (a.isDhtProxyEnabled()) { - JamiService.setAccountActive(a.getAccountID(), true); - } else { - JamiService.setAccountActive(a.getAccountID(), active); - } - } - }); - } - - /** - * Sets the video activation state of all the accounts in the local cache - */ - public void setAccountsVideoEnabled(boolean isEnabled) { - for (Account account : mAccountList) { - account.setDetail(ConfigKey.VIDEO_ENABLED, isEnabled); - } - } - - /** - * @return the account volatile details from the Daemon - */ - public Map<String, String> getVolatileAccountDetails(final String accountId) { - try { - return mExecutor.submit(() -> JamiService.getVolatileAccountDetails(accountId).toNative()).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getVolatileAccountDetails()", e); - } - return null; - } - - /** - * @return the default template (account details) for a type of account - */ - public Single<HashMap<String, String>> getAccountTemplate(final String accountType) { - Log.i(TAG, "getAccountTemplate() " + accountType); - return Single.fromCallable(() -> JamiService.getAccountTemplate(accountType).toNative()) - .subscribeOn(Schedulers.from(mExecutor)); - } - - /** - * Removes the account in the Daemon as well as local history - */ - public void removeAccount(final String accountId) { - Log.i(TAG, "removeAccount() " + accountId); - mExecutor.execute(() -> JamiService.removeAccount(accountId)); - mHistoryService.clearHistory(accountId).subscribe(); - } - - /** - * Exports the account on the DHT (used for multi-devices feature) - */ - public Single<String> exportOnRing(final String accountId, final String password) { - return mExportSubject - .filter(r -> r.accountId.equals(accountId)) - .firstOrError() - .map(result -> { - switch (result.code) { - case PIN_GENERATION_SUCCESS: - return result.pin; - case PIN_GENERATION_WRONG_PASSWORD: - throw new IllegalArgumentException(); - case PIN_GENERATION_NETWORK_ERROR: - throw new SocketException(); - default: - throw new UnsupportedOperationException(); - } - }) - .doOnSubscribe(l -> { - Log.i(TAG, "exportOnRing() " + accountId); - mExecutor.execute(() -> JamiService.exportOnRing(accountId, password)); - }) - .subscribeOn(Schedulers.io()); - } - - /** - * @return the list of the account's devices from the Daemon - */ - public Map<String, String> getKnownRingDevices(final String accountId) { - Log.i(TAG, "getKnownRingDevices() " + accountId); - try { - return mExecutor.submit(() -> JamiService.getKnownRingDevices(accountId).toNative()).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getKnownRingDevices()", e); - } - return null; - } - - /** - * @param accountId id of the account used with the device - * @param deviceId id of the device to revoke - * @param password password of the account - */ - public Single<Integer> revokeDevice(final String accountId, final String password, final String deviceId) { - return mDeviceRevocationSubject - .filter(r -> r.accountId.equals(accountId) && r.deviceId.equals(deviceId)) - .firstOrError() - .map(r -> r.code) - .doOnSubscribe(l -> mExecutor.execute(() -> JamiService.revokeDevice(accountId, password, deviceId))) - .subscribeOn(Schedulers.io()); - } - - /** - * @param accountId id of the account used with the device - * @param newName new device name - */ - public void renameDevice(final String accountId, final String newName) { - final Account account = getAccount(accountId); - mExecutor.execute(() -> { - Log.i(TAG, "renameDevice() thread running... " + newName); - StringMap details = JamiService.getAccountDetails(accountId); - details.put(ConfigKey.ACCOUNT_DEVICE_NAME.key(), newName); - JamiService.setAccountDetails(accountId, details); - account.setDetail(ConfigKey.ACCOUNT_DEVICE_NAME, newName); - account.setDevices(JamiService.getKnownRingDevices(accountId).toNative()); - }); - } - - public Completable exportToFile(String accountId, String absolutePath, String password) { - return Completable.fromAction(() -> { - if (!JamiService.exportToFile(accountId, absolutePath, password)) - throw new IllegalArgumentException("Can't export archive"); - }).subscribeOn(Schedulers.from(mExecutor)); - } - - /** - * @param accountId id of the account - * @param oldPassword old account password - */ - public Completable setAccountPassword(final String accountId, final String oldPassword, final String newPassword) { - return Completable.fromAction(() -> { - if (!JamiService.changeAccountPassword(accountId, oldPassword, newPassword)) - throw new IllegalArgumentException("Can't change password"); - }).subscribeOn(Schedulers.from(mExecutor)); - } - - /** - * Sets the active codecs list of the account in the Daemon - */ - public void setActiveCodecList(final String accountId, final List<Long> codecs) { - mExecutor.execute(() -> { - UintVect list = new UintVect(); - list.reserve(codecs.size()); - list.addAll(codecs); - JamiService.setActiveCodecList(accountId, list); - accountSubject.onNext(getAccount(accountId)); - }); - } - - /** - * @return The account's codecs list from the Daemon - */ - public Single<List<Codec>> getCodecList(final String accountId) { - return Single.fromCallable(() -> { - List<Codec> results = new ArrayList<>(); - UintVect payloads = JamiService.getCodecList(); - UintVect activePayloads = JamiService.getActiveCodecList(accountId); - for (int i = 0; i < payloads.size(); ++i) { - StringMap details = JamiService.getCodecDetails(accountId, payloads.get(i)); - if (details.size() > 1) { - results.add(new Codec(payloads.get(i), details.toNative(), activePayloads.contains(payloads.get(i)))); - } else { - Log.i(TAG, "Error loading codec " + i); - } - } - return results; - }).subscribeOn(Schedulers.from(mExecutor)); - } - - public Map<String, String> validateCertificatePath(final String accountID, final String certificatePath, final String privateKeyPath, final String privateKeyPass) { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "validateCertificatePath() running..."); - return JamiService.validateCertificatePath(accountID, certificatePath, privateKeyPath, privateKeyPass, "").toNative(); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running validateCertificatePath()", e); - } - return null; - } - - public Map<String, String> validateCertificate(final String accountId, final String certificate) { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "validateCertificate() running..."); - return JamiService.validateCertificate(accountId, certificate).toNative(); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running validateCertificate()", e); - } - return null; - } - - public Map<String, String> getCertificateDetailsPath(final String certificatePath) { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "getCertificateDetailsPath() running..."); - return JamiService.getCertificateDetails(certificatePath).toNative(); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getCertificateDetailsPath()", e); - } - return null; - } - - public Map<String, String> getCertificateDetails(final String certificateRaw) { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "getCertificateDetails() running..."); - return JamiService.getCertificateDetails(certificateRaw).toNative(); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getCertificateDetails()", e); - } - return null; - } - - /** - * @return the supported TLS methods from the Daemon - */ - public List<String> getTlsSupportedMethods() { - Log.i(TAG, "getTlsSupportedMethods()"); - return SwigNativeConverter.toJava(JamiService.getSupportedTlsMethod()); - } - - /** - * @return the account's credentials from the Daemon - */ - public List<Map<String, String>> getCredentials(final String accountId) { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "getCredentials() running..."); - return JamiService.getCredentials(accountId).toNative(); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getCredentials()", e); - } - return null; - } - - /** - * Sets the account's credentials in the Daemon - */ - public void setCredentials(final String accountId, final List<Map<String, String>> credentials) { - Log.i(TAG, "setCredentials() " + accountId); - mExecutor.execute(() -> JamiService.setCredentials(accountId, SwigNativeConverter.toSwig(credentials))); - } - - /** - * Sets the registration state to true for all the accounts in the Daemon - */ - public void registerAllAccounts() { - Log.i(TAG, "registerAllAccounts()"); - mExecutor.execute(this::registerAllAccounts); - } - - /** - * Registers a new name on the blockchain for the account - */ - public void registerName(final Account account, final String password, final String name) { - - if (account.registeringUsername) { - Log.w(TAG, "Already trying to register username"); - return; - } - - account.registeringUsername = true; - registerName(account.getAccountID(), password, name); - } - - /** - * Register a new name on the blockchain for the account Id - */ - public void registerName(final String account, final String password, final String name) { - Log.i(TAG, "registerName()"); - mExecutor.execute(() -> JamiService.registerName(account, password, name)); - } - - /* contact requests */ - - /** - * @return all trust requests from the daemon for the account Id - */ - public List<Map<String, String>> getTrustRequests(final String accountId) { - try { - return mExecutor.submit(() -> JamiService.getTrustRequests(accountId).toNative()).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getTrustRequests()", e); - } - return null; - } - - /** - * Accepts a pending trust request - */ - public void acceptTrustRequest(final String accountId, final Uri from) { - Log.i(TAG, "acceptRequest() " + accountId + " " + from); - Account account = getAccount(accountId); - if (account != null) { - TrustRequest request = account.getRequest(from); - if (request != null) { - VCard vCard = request.getVCard(); - if (vCard != null) { - VCardUtils.savePeerProfileToDisk(vCard, accountId, from.getRawRingId() + ".vcf", mDeviceRuntimeService.provideFilesDir()); - } - } - account.removeRequest(from); - //handleTrustRequest(accountId, from, null, ContactType.INVITATION_ACCEPTED); - } - mExecutor.execute(() -> JamiService.acceptTrustRequest(accountId, from.getRawRingId())); - } - - - /** - * Handles adding contacts and is the initial point of conversation creation - * - * @param conversation the user's account - * @param contactUri the contacts raw string uri - */ - private void handleTrustRequest(Conversation conversation, Uri contactUri, TrustRequest request, ContactType type) { - ContactEvent event = new ContactEvent(); - switch (type) { - case ADDED: - break; - case INVITATION_RECEIVED: - event.setStatus(Interaction.InteractionStatus.UNKNOWN); - event.setAuthor(contactUri.getRawRingId()); - event.setTimestamp(request.getTimestamp()); - break; - case INVITATION_ACCEPTED: - event.setStatus(Interaction.InteractionStatus.SUCCESS); - event.setAuthor(contactUri.getRawRingId()); - break; - case INVITATION_DISCARDED: - mHistoryService.clearHistory(contactUri.getRawRingId(), conversation.getAccountId(), true).subscribe(); - return; - default: - return; - } - mHistoryService.insertInteraction(conversation.getAccountId(), conversation, event).subscribe(); - } - - private enum ContactType { - ADDED, INVITATION_RECEIVED, INVITATION_ACCEPTED, INVITATION_DISCARDED - } - - /** - * Refuses and blocks a pending trust request - */ - public boolean discardTrustRequest(final String accountId, final Uri contactUri) { - Account account = getAccount(accountId); - boolean removed = false; - if (account != null) { - removed = account.removeRequest(contactUri); - mHistoryService.clearHistory(contactUri.getRawRingId(), accountId, true).subscribe(); - } - mExecutor.execute(() -> JamiService.discardTrustRequest(accountId, contactUri.getRawRingId())); - return removed; - } - - /** - * Sends a new trust request - */ - public void sendTrustRequest(Conversation conversation, final Uri to, final Blob message) { - Log.i(TAG, "sendTrustRequest() " + conversation.getAccountId() + " " + to); - handleTrustRequest(conversation, to, null, ContactType.ADDED); - mExecutor.execute(() -> JamiService.sendTrustRequest(conversation.getAccountId(), to.getRawRingId(), message == null ? new Blob() : message)); - } - - /** - * Add a new contact for the account Id on the Daemon - */ - public void addContact(final String accountId, final String uri) { - Log.i(TAG, "addContact() " + accountId + " " + uri); - //handleTrustRequest(accountId, Uri.fromString(uri), null, ContactType.ADDED); - mExecutor.execute(() -> JamiService.addContact(accountId, uri)); - } - - /** - * Remove an existing contact for the account Id on the Daemon - */ - public void removeContact(final String accountId, final String uri, final boolean ban) { - Log.i(TAG, "removeContact() " + accountId + " " + uri + " ban:" + ban); - mExecutor.execute(() -> JamiService.removeContact(accountId, uri, ban)); - } - - /** - * @return the contacts list from the daemon - */ - public List<Map<String, String>> getContacts(final String accountId) { - try { - return mExecutor.submit(() -> JamiService.getContacts(accountId).toNative()).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getContacts()", e); - } - return null; - } - - /** - * Looks up for the availability of the name on the blockchain - */ - public void lookupName(final String account, final String nameserver, final String name) { - Log.i(TAG, "lookupName() " + account + " " + nameserver + " " + name); - mExecutor.execute(() -> JamiService.lookupName(account, nameserver, name)); - } - - public Single<RegisteredName> findRegistrationByName(final String account, final String nameserver, final String name) { - if (StringUtils.isEmpty(name)) { - return Single.just(new RegisteredName()); - } - return getRegisteredNames() - .filter(r -> account.equals(r.accountId) && name.equals(r.name)) - .firstOrError() - .doOnSubscribe(s -> mExecutor.execute(() -> JamiService.lookupName(account, nameserver, name))) - .subscribeOn(Schedulers.from(mExecutor)); - } - - public Single<UserSearchResult> searchUser(final String account, final String query) { - if (StringUtils.isEmpty(query)) { - return Single.just(new UserSearchResult(account, query)); - } - String encodedUrl; - try { - encodedUrl = URLEncoder.encode(query, "UTF-8"); - } catch (UnsupportedEncodingException e) { - return Single.error(e); - } - return getSearchResults() - .filter(r -> account.equals(r.accountId) && encodedUrl.equals(r.query)) - .firstOrError() - .doOnSubscribe(s -> mExecutor.execute(() -> JamiService.searchUser(account, encodedUrl))) - .subscribeOn(Schedulers.from(mExecutor)); - } - - /** - * Reverse looks up the address in the blockchain to find the name - */ - public void lookupAddress(final String account, final String nameserver, final String address) { - mExecutor.execute(() -> JamiService.lookupAddress(account, nameserver, address)); - } - - public void pushNotificationReceived(final String from, final Map<String, String> data) { - // Log.i(TAG, "pushNotificationReceived()"); - mExecutor.execute(() -> JamiService.pushNotificationReceived(from, StringMap.toSwig(data))); - } - - public void setPushNotificationToken(final String pushNotificationToken) { - //Log.i(TAG, "setPushNotificationToken()"); - mExecutor.execute(() -> JamiService.setPushNotificationToken(pushNotificationToken)); - } - - void volumeChanged(String device, int value) { - Log.w(TAG, "volumeChanged " + device + " " + value); - } - - void accountsChanged() { - // Accounts have changed in Daemon, we have to update our local cache - refreshAccountsCacheFromDaemon(); - } - - void stunStatusFailure(String accountId) { - Log.d(TAG, "stun status failure: " + accountId); - } - - void registrationStateChanged(String accountId, String newState, int code, String detailString) { - //Log.d(TAG, "registrationStateChanged: " + accountId + ", " + newState + ", " + code + ", " + detailString); - - Account account = getAccount(accountId); - if (account == null) { - return; - } - String oldState = account.getRegistrationState(); - if (oldState.contentEquals(AccountConfig.STATE_INITIALIZING) && - !newState.contentEquals(AccountConfig.STATE_INITIALIZING)) { - account.setDetails(JamiService.getAccountDetails(account.getAccountID()).toNative()); - account.setCredentials(JamiService.getCredentials(account.getAccountID()).toNative()); - account.setDevices(JamiService.getKnownRingDevices(account.getAccountID()).toNative()); - account.setVolatileDetails(JamiService.getVolatileAccountDetails(account.getAccountID()).toNative()); - } else { - account.setRegistrationState(newState, code); - } - - if (!oldState.equals(newState)) { - accountSubject.onNext(account); - } - } - - void accountDetailsChanged(String accountId, Map<String, String> details) { - Account account = getAccount(accountId); - if (account == null) { - return; - } - Log.d(TAG, "accountDetailsChanged: " + accountId + " " + details.size()); - account.setDetails(details); - accountSubject.onNext(account); - } - - void volatileAccountDetailsChanged(String accountId, Map<String, String> details) { - Account account = getAccount(accountId); - if (account == null) { - return; - } - //Log.d(TAG, "volatileAccountDetailsChanged: " + accountId + " " + details.size()); - account.setVolatileDetails(details); - accountSubject.onNext(account); - } - - public void accountProfileReceived(String accountId, String name, String photo) { - Account account = getAccount(accountId); - if (account == null) - return; - mVCardService.saveVCardProfile(accountId, account.getUri(), name, photo) - .subscribeOn(Schedulers.io()) - .subscribe(vcard -> account.resetProfile(), e -> Log.e(TAG, "Error saving profile", e)); - } - - void profileReceived(String accountId, String peerId, String vcardPath) { - Account account = getAccount(accountId); - if (account == null) - return; - Log.w(TAG, "profileReceived: " + accountId + ", " + peerId + ", " + vcardPath); - Contact contact = account.getContactFromCache(peerId); - mVCardService.peerProfileReceived(accountId, peerId, new File(vcardPath)) - .subscribe(profile -> contact.setProfile(profile.first, profile.second), e -> Log.e(TAG, "Error saving contact profile", e)); - } - - void incomingAccountMessage(String accountId, String messageId, String callId, String from, Map<String, String> messages) { - Log.d(TAG, "incomingAccountMessage: " + accountId + " " + messages.size()); - Message message = new Message(); - message.accountId = accountId; - message.messageId = messageId; - message.callId = callId; - message.author = from; - message.messages = messages; - incomingMessageSubject.onNext(message); - } - - void accountMessageStatusChanged(String accountId, String conversationId, String messageId, String peer, int status) { - InteractionStatus newStatus = InteractionStatus.fromIntTextMessage(status); - Log.d(TAG, "accountMessageStatusChanged: " + accountId + ", " + conversationId + ", " + messageId + ", " + peer + ", " + newStatus); - if (StringUtils.isEmpty(conversationId)) { - mHistoryService - .accountMessageStatusChanged(accountId, messageId, peer, newStatus) - .subscribe(messageSubject::onNext, e -> Log.e(TAG, "Error updating message: " + e.getLocalizedMessage())); - } else { - Interaction msg = new Interaction(accountId); - msg.setStatus(newStatus); - msg.setSwarmInfo(conversationId, messageId, null); - messageSubject.onNext(msg); - } - } - - public void composingStatusChanged(String accountId, String conversationId, String contactUri, int status) { - Log.d(TAG, "composingStatusChanged: " + accountId + ", " + contactUri + ", " + conversationId + ", " + status); - getAccountSingle(accountId) - .subscribe(account -> account.composingStatusChanged(conversationId, Uri.fromId(contactUri), Account.ComposingStatus.fromInt(status))); - } - - void errorAlert(int alert) { - Log.d(TAG, "errorAlert : " + alert); - } - - void knownDevicesChanged(String accountId, Map<String, String> devices) { - Account accountChanged = getAccount(accountId); - if (accountChanged != null) { - accountChanged.setDevices(devices); - accountSubject.onNext(accountChanged); - } - } - - void exportOnRingEnded(String accountId, int code, String pin) { - Log.d(TAG, "exportOnRingEnded: " + accountId + ", " + code + ", " + pin); - ExportOnRingResult result = new ExportOnRingResult(); - result.accountId = accountId; - result.code = code; - result.pin = pin; - mExportSubject.onNext(result); - } - - void nameRegistrationEnded(String accountId, int state, String name) { - Log.d(TAG, "nameRegistrationEnded: " + accountId + ", " + state + ", " + name); - - Account acc = getAccount(accountId); - if (acc == null) { - Log.w(TAG, "Can't find account for name registration callback"); - return; - } - - acc.registeringUsername = false; - acc.setVolatileDetails(JamiService.getVolatileAccountDetails(acc.getAccountID()).toNative()); - if (state == 0) { - acc.setDetail(ConfigKey.ACCOUNT_REGISTERED_NAME, name); - } - - accountSubject.onNext(acc); - } - - void migrationEnded(String accountId, String state) { - Log.d(TAG, "migrationEnded: " + accountId + ", " + state); - MigrationResult result = new MigrationResult(); - result.accountId = accountId; - result.state = state; - mMigrationSubject.onNext(result); - } - - void deviceRevocationEnded(String accountId, String device, int state) { - Log.d(TAG, "deviceRevocationEnded: " + accountId + ", " + device + ", " + state); - DeviceRevocationResult result = new DeviceRevocationResult(); - result.accountId = accountId; - result.deviceId = device; - result.code = state; - if (state == 0) { - Account account = getAccount(accountId); - if (account != null) { - Map<String, String> devices = account.getDevices(); - devices.remove(device); - account.setDevices(devices); - accountSubject.onNext(account); - } - } - mDeviceRevocationSubject.onNext(result); - } - - void incomingTrustRequest(String accountId, String conversationId, String from, String message, long received) { - Log.d(TAG, "incomingTrustRequest: " + accountId + ", " + conversationId + ", " + from + ", " + received); - - Account account = getAccount(accountId); - if (account != null) { - Uri fromUri = Uri.fromString(from); - TrustRequest request = account.getRequest(fromUri); - if (request == null) - request = new TrustRequest(accountId, fromUri, received * 1000L, message, conversationId); - else - request.setVCard(Ezvcard.parse(message).first()); - - VCard vcard = request.getVCard(); - if (vcard != null) { - Contact contact = account.getContactFromCache(fromUri); - if (!contact.detailsLoaded) { - // VCardUtils.savePeerProfileToDisk(vcard, accountId, from + ".vcf", mDeviceRuntimeService.provideFilesDir()); - mVCardService.loadVCardProfile(vcard) - .subscribeOn(Schedulers.computation()) - .subscribe(profile -> contact.setProfile(profile.first, profile.second)); - } - } - account.addRequest(request); - // handleTrustRequest(account, Uri.fromString(from), request, ContactType.INVITATION_RECEIVED); - if (account.isEnabled()) - lookupAddress(accountId, "", from); - incomingRequestsSubject.onNext(request); - } - } - - void contactAdded(String accountId, String uri, boolean confirmed) { - Account account = getAccount(accountId); - if (account != null) { - account.addContact(uri, confirmed); - if (account.isEnabled()) - lookupAddress(accountId, "", uri); - } - } - - void contactRemoved(String accountId, String uri, boolean banned) { - Account account = getAccount(accountId); - Log.d(TAG, "Contact removed: " + uri + " User is banned: " + banned); - if (account != null) { - mHistoryService.clearHistory(uri, accountId, true).subscribe(); - account.removeContact(uri, banned); - } - } - - void registeredNameFound(String accountId, int state, String address, String name) { - try { - //Log.d(TAG, "registeredNameFound: " + accountId + ", " + state + ", " + name + ", " + address); - if (!StringUtils.isEmpty(address)) { - Account account = getAccount(accountId); - if (account != null) { - account.registeredNameFound(state, address, name); - } - } - - RegisteredName r = new RegisteredName(); - r.accountId = accountId; - r.address = address; - r.name = name; - r.state = state; - registeredNameSubject.onNext(r); - } catch (Exception e) { - Log.w(TAG, "registeredNameFound exception", e); - } - } - - public void userSearchEnded(String accountId, int state, String query, ArrayList<Map<String, String>> results) { - Account account = getAccount(accountId); - UserSearchResult r = new UserSearchResult(accountId, query); - r.state = state; - r.results = new ArrayList<>(results.size()); - for (Map<String, String> m : results) { - String uri = m.get("id"); - String username = m.get("username"); - String firstName = m.get("firstName"); - String lastName = m.get("lastName"); - String picture_b64 = m.get("profilePicture"); - Contact contact = account.getContactFromCache(uri); - if (contact != null) { - contact.setUsername(username); - contact.setProfile(firstName + " " + lastName, mVCardService.base64ToBitmap(picture_b64)); - r.results.add(contact); - } - } - searchResultSubject.onNext(r); - } - - private Interaction addMessage(Account account, Conversation conversation, Map<String, String> message) { - /* for (Map.Entry<String, String> e : message.entrySet()) { - Log.w(TAG, e.getKey() + " -> " + e.getValue()); - } */ - String id = message.get("id"); - String type = message.get("type"); - String author = message.get("author"); - String parent = message.get("linearizedParent"); - List<String> parents = StringUtils.isEmpty(parent) ? Collections.emptyList() : Collections.singletonList(parent); - Uri authorUri = Uri.fromId(author); - - long timestamp = Long.parseLong(message.get("timestamp")) * 1000; - Contact contact = conversation.findContact(authorUri); - if (contact == null) { - contact = account.getContactFromCache(authorUri); - } - Interaction interaction; - switch (type) { - case "member": - contact.setAddedDate(new Date(timestamp)); - interaction = new ContactEvent(contact); - break; - case "text/plain": - interaction = new TextMessage(author, account.getAccountID(), timestamp, conversation, message.get("body"), !contact.isUser()); - break; - case "application/data-transfer+json": { - try { - String fileName = message.get("displayName"); - String fileId = message.get("fileId"); - //interaction = account.getDataTransfer(fileId); - //if (interaction == null) { - String[] paths = new String[1]; - long[] progressA = new long[1]; - long[] totalA = new long[1]; - JamiService.fileTransferInfo(account.getAccountID(), conversation.getUri().getRawRingId(), fileId, paths, totalA, progressA); - if (totalA[0] == 0) { - totalA[0] = Long.parseLong(message.get("totalSize")); - } - File path = new File(paths[0]); - interaction = new DataTransfer(fileId, account.getAccountID(), author, fileName, contact.isUser(), timestamp, totalA[0], progressA[0]); - ((DataTransfer)interaction).setDaemonPath(path); - boolean isComplete = path.exists() && progressA[0] == totalA[0]; - Log.w(TAG, "add DataTransfer at " + paths[0] + " with progress " + progressA[0] + "/" + totalA[0]); - interaction.setStatus(isComplete ? InteractionStatus.TRANSFER_FINISHED : InteractionStatus.FILE_AVAILABLE); - //} - } catch (Exception e) { - interaction = new Interaction(conversation, Interaction.InteractionType.INVALID); - } - break; - } - case "application/call-history+json": - interaction = new Call(null, account.getAccountID(), authorUri.getRawUriString(), contact.isUser() ? Call.Direction.OUTGOING : Call.Direction.INCOMING, timestamp); - ((Call) interaction).setDuration(Long.parseLong(message.get("duration"))); - break; - case "merge": - default: - interaction = new Interaction(conversation, Interaction.InteractionType.INVALID); - break; - } - interaction.setContact(contact); - interaction.setSwarmInfo(conversation.getUri().getRawRingId(), id, parents); - interaction.setConversation(conversation); - if (conversation.addSwarmElement(interaction)) { - if (conversation.isVisible()) - mHistoryService.setMessageRead(account.getAccountID(), conversation.getUri(), interaction.getMessageId()); - } - return interaction; - } - - public void conversationLoaded(String accountId, String conversationId, List<Map<String, String>> messages) { - try { - // Log.w(TAG, "ConversationCallback: conversationLoaded " + accountId + "/" + conversationId + " " + messages.size()); - Account account = getAccount(accountId); - if (account == null) { - Log.w(TAG, "conversationLoaded: can't find account"); - return; - } - Conversation conversation = account.getSwarm(conversationId); - synchronized (conversation) { - for (Map<String, String> message : messages) { - addMessage(account, conversation, message); - } - conversation.stopLoading(); - } - account.conversationChanged(); - } catch (Exception e) { - Log.e(TAG, "Exception loading message", e); - } - } - - private enum ConversationMemberEvent { - Add, Join, Remove, Ban - } - - public void conversationMemberEvent(String accountId, String conversationId, String peerUri, int event) { - Log.w(TAG, "ConversationCallback: conversationMemberEvent " + accountId + "/" + conversationId); - Account account = getAccount(accountId); - if (account == null) { - Log.w(TAG, "conversationMemberEvent: can't find account"); - return; - } - Conversation conversation = account.getSwarm(conversationId); - Uri uri = Uri.fromId(peerUri); - switch (ConversationMemberEvent.values()[event]) { - case Add: - case Join: { - Contact contact = conversation.findContact(uri); - if (contact == null) { - conversation.addContact(account.getContactFromCache(uri)); - } - break; - } - case Remove: - case Ban: { - Contact contact = conversation.findContact(uri); - if (contact != null) { - conversation.removeContact(contact); - } - break; - } - } - } - - public void conversationReady(String accountId, String conversationId) { - Log.w(TAG, "ConversationCallback: conversationReady " + accountId + "/" + conversationId); - Account account = getAccount(accountId); - if (account == null) { - Log.w(TAG, "conversationReady: can't find account"); - return; - } - StringMap info = JamiService.conversationInfos(accountId, conversationId); - /*for (Map.Entry<String, String> i : info.entrySet()) { - Log.w(TAG, "conversation info: " + i.getKey() + " " + i.getValue()); - }*/ - int modeInt = Integer.parseInt(info.get("mode")); - Conversation.Mode mode = Conversation.Mode.values()[modeInt]; - Conversation conversation = account.newSwarm(conversationId, mode); - - for (Map<String, String> member : JamiService.getConversationMembers(accountId, conversationId)) { - Uri uri = Uri.fromId(member.get("uri")); - Contact contact = conversation.findContact(uri); - if (contact == null) { - contact = account.getContactFromCache(uri); - conversation.addContact(contact); - } - } - account.conversationStarted(conversation); - loadMore(conversation, 2); - } - - public void conversationRemoved(String accountId, String conversationId) { - Account account = getAccount(accountId); - if (account == null) { - Log.w(TAG, "conversationRemoved: can't find account"); - return; - } - account.removeSwarm(conversationId); - } - - public void conversationRequestReceived(String accountId, String conversationId, Map<String, String> metadata) { - Log.w(TAG, "ConversationCallback: conversationRequestReceived " + accountId + "/" + conversationId + " " + metadata.size()); - Account account = getAccount(accountId); - if (account == null) { - Log.w(TAG, "conversationRequestReceived: can't find account"); - return; - } - Uri contactUri = Uri.fromId(metadata.get("from")); - account.addRequest(new TrustRequest(account.getAccountID(), contactUri, conversationId)); - } - - public void messageReceived(String accountId, String conversationId, Map<String, String> message) { - Log.w(TAG, "ConversationCallback: messageReceived " + accountId + "/" + conversationId + " " + message.size()); - Account account = getAccount(accountId); - Conversation conversation = account.getSwarm(conversationId); - synchronized (conversation) { - Interaction interaction = addMessage(account, conversation, message); - account.conversationUpdated(conversation); - boolean isIncoming = !interaction.getContact().isUser(); - if (isIncoming) { - incomingSwarmMessageSubject.onNext(interaction); - if (interaction instanceof DataTransfer) - dataTransferSubject.onNext((DataTransfer)interaction); - } - } - } - - public Single<DataTransfer> sendFile(final File file, final DataTransfer dataTransfer) { - return Single.fromCallable(() -> { - mStartingTransfer = dataTransfer; - - DataTransferInfo dataTransferInfo = new DataTransferInfo(); - dataTransferInfo.setAccountId(dataTransfer.getAccount()); - - String conversationId = dataTransfer.getConversationId(); - if (!StringUtils.isEmpty(conversationId)) - dataTransferInfo.setConversationId(conversationId); - else - dataTransferInfo.setPeer(dataTransfer.getConversation().getParticipant()); - - dataTransferInfo.setPath(file.getAbsolutePath()); - dataTransferInfo.setDisplayName(dataTransfer.getDisplayName()); - - Log.i(TAG, "sendFile() id=" + dataTransfer.getId() + " accountId=" + dataTransferInfo.getAccountId() + ", peer=" + dataTransferInfo.getPeer() + ", filePath=" + dataTransferInfo.getPath()); - long[] id = new long[1]; - DataTransferError err = getDataTransferError(JamiService.sendFileLegacy(dataTransferInfo, id)); - if (err != DataTransferError.SUCCESS) { - throw new IOException(err.name()); - } else { - Log.e(TAG, "sendFile: got ID " + id[0]); - dataTransfer.setDaemonId(id[0]); - } - return dataTransfer; - }).subscribeOn(Schedulers.from(mExecutor)); - } - - public void sendFile(Conversation conversation, final File file) { - mExecutor.execute(() -> JamiService.sendFile(conversation.getAccountId(), conversation.getUri().getRawRingId(), file.getAbsolutePath(), file.getName(), "")); - } - - public List<net.jami.daemon.Message> getLastMessages(String accountId, long baseTime) { - try { - return mExecutor.submit(() -> SwigNativeConverter.toJava(JamiService.getLastMessages(accountId, baseTime))).get(); - } catch (Exception e) { - e.printStackTrace(); - } - return new ArrayList<>(); - } - - public void acceptFileTransfer(final String accountId, final Uri conversationUri, String messageId, String fileId) { - Account account = getAccount(accountId); - if (account != null) { - Conversation conversation = account.getByUri(conversationUri); - acceptFileTransfer(conversation, fileId, conversation.isSwarm() ? (DataTransfer)conversation.getMessage(messageId) : account.getDataTransfer(fileId)); - } - } - - public void acceptFileTransfer(Conversation conversation, String fileId, DataTransfer transfer) { - if (conversation.isSwarm()) { - String conversationId = conversation.getUri().getRawRingId(); - File newPath = mDeviceRuntimeService.getNewConversationPath(conversation.getAccountId(), conversationId, transfer.getDisplayName()); - Log.i(TAG, "downloadFile() id=" + conversation.getAccountId() + ", path=" + conversationId + " " + fileId + " to -> " + newPath.getAbsolutePath()); - JamiService.downloadFile(conversation.getAccountId(), conversationId, transfer.getMessageId(), fileId, newPath.getAbsolutePath()); - } else { - if (transfer == null) { - return; - } - File path = mDeviceRuntimeService.getTemporaryPath(conversation.getUri().getRawRingId(), transfer.getStoragePath()); - Log.i(TAG, "acceptFileTransfer() id=" + fileId + ", path=" + path.getAbsolutePath()); - JamiService.acceptFileTransfer(conversation.getAccountId(), fileId, path.getAbsolutePath()); - } - } - - public void cancelDataTransfer(final String accountId, final String conversationId, final String messageId, final String fileId) { - Log.i(TAG, "cancelDataTransfer() id=" + fileId); - mExecutor.execute(() -> JamiService.cancelDataTransfer(accountId, conversationId, fileId)); - } - - private class DataTransferRefreshTask implements Runnable { - private final Account mAccount; - private final Conversation mConversation; - private final DataTransfer mToUpdate; - public ScheduledFuture<?> scheduledTask; - - DataTransferRefreshTask(Account account, Conversation conversation, DataTransfer t) { - mAccount = account; - mConversation = conversation; - mToUpdate = t; - } - - @Override - public void run() { - synchronized (mToUpdate) { - if (mToUpdate.getStatus() == Interaction.InteractionStatus.TRANSFER_ONGOING) { - dataTransferEvent(mAccount, mConversation, mToUpdate.getMessageId(), mToUpdate.getFileId(), 5); - } else { - scheduledTask.cancel(false); - scheduledTask = null; - } - } - } - } - - void dataTransferEvent(String accountId, String conversationId, String interactionId, final String fileId, int eventCode) { - Account account = getAccount(accountId); - if (account != null) { - Conversation conversation = StringUtils.isEmpty(conversationId) ? null : account.getSwarm(conversationId); - dataTransferEvent(account, conversation, interactionId, fileId, eventCode); - } - } - void dataTransferEvent(Account account, Conversation conversation, final String interactionId, final String fileId, int eventCode) { - Interaction.InteractionStatus transferStatus = getDataTransferEventCode(eventCode); - Log.d(TAG, "Data Transfer " + interactionId + " " + fileId + " " + transferStatus); - - String from; - long total, progress; - String displayName; - DataTransfer transfer = account.getDataTransfer(fileId); - boolean outgoing = false; - if (conversation == null) { - DataTransferInfo info = new DataTransferInfo(); - DataTransferError err = getDataTransferError(JamiService.dataTransferInfo(account.getAccountID(), fileId, info)); - if (err != DataTransferError.SUCCESS) { - Log.d(TAG, "Data Transfer error getting details " + err); - return; - } - from = info.getPeer(); - total = info.getTotalSize(); - progress = info.getBytesProgress(); - conversation = account.getByUri(from); - outgoing = info.getFlags() == 0; - displayName = info.getDisplayName(); - } else { - String[] paths = new String[1]; - long[] progressA = new long[1]; - long[] totalA = new long[1]; - JamiService.fileTransferInfo(account.getAccountID(), conversation.getUri().getRawRingId(), fileId, paths, totalA, progressA); - progress = progressA[0]; - total = totalA[0]; - if (transfer == null && !StringUtils.isEmpty(interactionId)) { - transfer = (DataTransfer) conversation.getMessage(interactionId); - } - if (transfer == null) - return; - transfer.setConversation(conversation); - transfer.setDaemonPath(new File(paths[0])); - from = transfer.getAuthor(); - displayName = transfer.getDisplayName(); - } - - if (transfer == null) { - if (outgoing && mStartingTransfer != null) { - Log.d(TAG, "Data Transfer mStartingTransfer"); - transfer = mStartingTransfer; - mStartingTransfer = null; - } else { - transfer = new DataTransfer(conversation, from, account.getAccountID(), displayName, - outgoing, total, - progress, fileId); - if (conversation.isSwarm()) { - transfer.setSwarmInfo(conversation.getUri().getRawRingId(), interactionId, null); - } else { - mHistoryService.insertInteraction(account.getAccountID(), conversation, transfer).blockingAwait(); - } - } - account.putDataTransfer(fileId, transfer); - } else synchronized (transfer) { - InteractionStatus oldState = transfer.getStatus(); - if (oldState != transferStatus) { - if (transferStatus == Interaction.InteractionStatus.TRANSFER_ONGOING) { - DataTransferRefreshTask task = new DataTransferRefreshTask(account, conversation, transfer); - task.scheduledTask = mExecutor.scheduleAtFixedRate(task, - DATA_TRANSFER_REFRESH_PERIOD, - DATA_TRANSFER_REFRESH_PERIOD, TimeUnit.MILLISECONDS); - } else if (transferStatus.isError()) { - if (!transfer.isOutgoing()) { - File tmpPath = mDeviceRuntimeService.getTemporaryPath(conversation.getUri().getRawRingId(), transfer.getStoragePath()); - tmpPath.delete(); - } - } else if (transferStatus == (Interaction.InteractionStatus.TRANSFER_FINISHED)) { - if (!conversation.isSwarm() && !transfer.isOutgoing()) { - File tmpPath = mDeviceRuntimeService.getTemporaryPath(conversation.getUri().getRawRingId(), transfer.getStoragePath()); - File path = mDeviceRuntimeService.getConversationPath(conversation.getUri().getRawRingId(), transfer.getStoragePath()); - FileUtils.moveFile(tmpPath, path); - } - } - } - transfer.setStatus(transferStatus); - transfer.setBytesProgress(progress); - if (!conversation.isSwarm()) { - mHistoryService.updateInteraction(transfer, account.getAccountID()).subscribe(); - } - } - - Log.d(TAG, "Data Transfer dataTransferSubject.onNext"); - dataTransferSubject.onNext(transfer); - } - - private static Interaction.InteractionStatus getDataTransferEventCode(int eventCode) { - Interaction.InteractionStatus dataTransferEventCode = Interaction.InteractionStatus.INVALID; - try { - dataTransferEventCode = InteractionStatus.fromIntFile(eventCode); - } catch (ArrayIndexOutOfBoundsException ignored) { - Log.e(TAG, "getEventCode: invalid data transfer status from daemon"); - } - return dataTransferEventCode; - } - - private static DataTransferError getDataTransferError(Long errorCode) { - if (errorCode == null) { - Log.e(TAG, "getDataTransferError: invalid error code"); - } else { - try { - return DataTransferError.values()[errorCode.intValue()]; - } catch (ArrayIndexOutOfBoundsException ignored) { - Log.e(TAG, "getDataTransferError: invalid data transfer error from daemon"); - } - } - return DataTransferError.UNKNOWN; - } - - public Subject<DataTransfer> getDataTransfers() { - return dataTransferSubject; - } - - public Observable<DataTransfer> observeDataTransfer(DataTransfer transfer) { - return dataTransferSubject - .filter(t -> t == transfer) - .startWithItem(transfer); - } - - public void setProxyEnabled(boolean enabled) { - mExecutor.execute(() -> { - for (Account acc : mAccountList) { - if (acc.isJami() && (acc.isDhtProxyEnabled() != enabled)) { - Log.d(TAG, (enabled ? "Enabling" : "Disabling") + " proxy for account " + acc.getAccountID()); - acc.setDhtProxyEnabled(enabled); - StringMap details = JamiService.getAccountDetails(acc.getAccountID()); - details.put(ConfigKey.PROXY_ENABLED.key(), enabled ? "true" : "false"); - JamiService.setAccountDetails(acc.getAccountID(), details); - } - } - }); - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/services/AccountService.kt b/ring-android/libringclient/src/main/java/net/jami/services/AccountService.kt new file mode 100644 index 000000000..f6363963d --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/services/AccountService.kt @@ -0,0 +1,1785 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.services + +import com.google.gson.JsonParser +import ezvcard.Ezvcard +import ezvcard.VCard +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.PublishSubject +import io.reactivex.rxjava3.subjects.SingleSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.daemon.* +import net.jami.model.* +import net.jami.model.Interaction.InteractionStatus +import net.jami.smartlist.SmartListViewModel +import net.jami.utils.* +import java.io.File +import java.io.IOException +import java.io.UnsupportedEncodingException +import java.net.SocketException +import java.net.URLEncoder +import java.util.* +import java.util.concurrent.Callable +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import kotlin.collections.HashMap +import kotlin.math.min + +/** + * This service handles the accounts + * - Load and manage the accounts stored in the daemon + * - Keep a local cache of the accounts + * - handle the callbacks that are send by the daemon + */ +class AccountService( + private val mExecutor: ScheduledExecutorService, + private val mHistoryService: HistoryService, + private val mDeviceRuntimeService: DeviceRuntimeService, + private val mVCardService: VCardService +) { + /** + * @return the current Account from the local cache + */ + var currentAccount: Account? = null + set(account) { + if (field === account) return + field = account!! + + // the account order is changed + // the current Account is now on the top of the list + val accounts: List<Account> = mAccountList + val orderedAccountIdList: MutableList<String> = ArrayList(accounts.size) + val selectedID = account.accountID + orderedAccountIdList.add(selectedID) + for (a in accounts) { + if (a.accountID.contentEquals(selectedID)) { + continue + } + orderedAccountIdList.add(a.accountID) + } + setAccountOrder(orderedAccountIdList) + } + + private var mAccountList: MutableList<Account> = ArrayList() + private var mHasSipAccount = false + private var mHasRingAccount = false + private var mStartingTransfer: DataTransfer? = null + private val accountsSubject = BehaviorSubject.create<List<Account>>() + val observableAccounts: Subject<Account> = PublishSubject.create() + val currentAccountSubject: Observable<Account> = accountsSubject + .filter { l -> l.isNotEmpty() } + .map { l -> l[0] } + .distinctUntilChanged() + + class Message constructor ( + val accountId: String, + val messageId: String?, + val callId: String?, + val author: String, + val messages: Map<String, String> + ) + + class Location( + val account: String, + val callId: String?, + val peer: Uri, + var date: Long) { + enum class Type { + Position, Stop + } + + lateinit var type: Type + var latitude = 0.0 + var longitude = 0.0 + } + + private val incomingMessageSubject: Subject<Message> = PublishSubject.create() + private val incomingSwarmMessageSubject: Subject<Interaction> = PublishSubject.create() + val incomingMessages: Observable<TextMessage> = incomingMessageSubject + .flatMapMaybe { msg: Message -> + val message = msg.messages[CallService.MIME_TEXT_PLAIN] + if (message != null) { + return@flatMapMaybe mHistoryService + .incomingMessage(msg.accountId, msg.messageId, msg.author, message) + .toMaybe() + } + Maybe.empty() + } + .share() + val locationUpdates: Observable<Location> = incomingMessageSubject + .flatMapMaybe { msg: Message -> + try { + val loc = msg.messages[CallService.MIME_GEOLOCATION] ?: return@flatMapMaybe Maybe.empty<Location>() + val obj = JsonParser.parseString(loc).asJsonObject + if (obj.size() < 2) return@flatMapMaybe Maybe.empty<Location>() + return@flatMapMaybe Maybe.just(Location(msg.accountId, msg.callId, Uri.fromId(msg.author), obj["time"].asLong).apply { + val t = obj["type"] + if (t == null || t.asString == Location.Type.Position.toString()) { + type = Location.Type.Position + latitude = obj["lat"].asDouble + longitude = obj["long"].asDouble + } else if (t.asString == Location.Type.Stop.toString()) { + type = Location.Type.Stop + } + }) + } catch (e: Exception) { + Log.w(TAG, "Failed to receive geolocation", e) + return@flatMapMaybe Maybe.empty<Location>() + } + } + .share() + private val messageSubject: Subject<Interaction> = PublishSubject.create() + val dataTransfers: Subject<DataTransfer> = PublishSubject.create() + private val incomingRequestsSubject: Subject<TrustRequest> = PublishSubject.create() + fun refreshAccounts() { + accountsSubject.onNext(mAccountList) + } + + class RegisteredName( + val accountId: String, + val name: String, + val address: String? = null, + val state: Int = 0 + ) + + class UserSearchResult(val accountId: String, val query: String, var state: Int = 0) { + var results: MutableList<Contact>? = null + val resultsViewModels: List<Observable<SmartListViewModel>> + get() { + val vms: MutableList<Observable<SmartListViewModel>> = ArrayList(results!!.size) + for (user in results!!) { + vms.add(Observable.just(SmartListViewModel(accountId, user, null))) + } + return vms + } + } + + private val registeredNameSubject: Subject<RegisteredName> = PublishSubject.create() + private val searchResultSubject: Subject<UserSearchResult> = PublishSubject.create() + + private class ExportOnRingResult ( + var accountId: String, + var code: Int, + var pin: String? + ) + + private class DeviceRevocationResult ( + var accountId: String, + var deviceId: String, + var code: Int + ) + + private class MigrationResult ( + var accountId: String, + var state: String + ) + + private val mExportSubject: Subject<ExportOnRingResult> = PublishSubject.create() + private val mDeviceRevocationSubject: Subject<DeviceRevocationResult> = PublishSubject.create() + private val mMigrationSubject: Subject<MigrationResult> = PublishSubject.create() + val registeredNames: Observable<RegisteredName> + get() = registeredNameSubject + val searchResults: Observable<UserSearchResult> + get() = searchResultSubject + val incomingSwarmMessages: Observable<TextMessage> + get() = incomingSwarmMessageSubject + .filter { i: Interaction -> i is TextMessage } + .map { i: Interaction -> i as TextMessage } + val messageStateChanges: Observable<Interaction> + get() = messageSubject + val incomingRequests: Observable<TrustRequest> + get() = incomingRequestsSubject + + /** + * @return true if at least one of the loaded accounts is a SIP one + */ + fun hasSipAccount(): Boolean { + return mHasSipAccount + } + + /** + * @return true if at least one of the loaded accounts is a Ring one + */ + fun hasRingAccount(): Boolean { + return mHasRingAccount + } + + /** + * Loads the accounts from the daemon and then builds the local cache (also sends ACCOUNTS_CHANGED event) + * + * @param isConnected sets the initial connection state of the accounts + */ + fun loadAccountsFromDaemon(isConnected: Boolean) { + mExecutor.execute { + refreshAccountsCacheFromDaemon() + setAccountsActive(isConnected) + } + } + + private fun refreshAccountsCacheFromDaemon() { + Log.w(TAG, "refreshAccountsCacheFromDaemon") + var hasSip = false + var hasJami = false + val curList: List<Account> = mAccountList + val accountIds: List<String> = ArrayList(JamiService.getAccountList()) + val newAccounts: MutableList<Account> = ArrayList(accountIds.size) + for (id in accountIds) { + for (acc in curList) if (acc.accountID == id) { + newAccounts.add(acc) + break + } + } + + // Cleanup removed accounts + for (acc in curList) if (!newAccounts.contains(acc)) acc.cleanup() + for (accountId in accountIds) { + var account = findAccount(newAccounts, accountId) + val details: Map<String, String> = JamiService.getAccountDetails(accountId).toNative() + val credentials: List<Map<String, String>> = JamiService.getCredentials(accountId).toNative() + val volatileAccountDetails: Map<String, String> = JamiService.getVolatileAccountDetails(accountId).toNative() + if (account == null) { + account = Account(accountId, details, credentials, volatileAccountDetails) + newAccounts.add(account) + } else { + account.setDetails(details) + account.setCredentials(credentials) + account.setVolatileDetails(volatileAccountDetails) + } + } + mAccountList = newAccounts + synchronized(newAccounts) { + for (account in newAccounts) { + val accountId = account.accountID + if (account.isSip) { + hasSip = true + } else if (account.isJami) { + hasJami = true + val enabled = account.isEnabled + account.devices = JamiService.getKnownRingDevices(accountId).toNative() + account.setContacts(JamiService.getContacts(accountId).toNative()) + val requests: List<Map<String, String>> = JamiService.getTrustRequests(accountId).toNative() + for (requestInfo in requests) { + val request = TrustRequest(accountId, requestInfo) + account.addRequest(request) + } + val conversations: List<String> = JamiService.getConversations(account.accountID) + Log.w(TAG, accountId + " loading conversations: " + conversations.size) + for (conversationId in conversations) { + try { + val info: Map<String, String> = JamiService.conversationInfos(accountId, conversationId).toNative() + /*for (Map.Entry<String, String> i : info.entrySet()) { + Log.w(TAG, "conversation info: " + i.getKey() + " " + i.getValue()); + }*/ + val mode = if ("true" == info["syncing"]) Conversation.Mode.Syncing else Conversation.Mode.values()[info["mode"]!!.toInt()] + val conversation = account.newSwarm(conversationId, mode) + if (mode != Conversation.Mode.Syncing) { + for (member in JamiService.getConversationMembers(accountId, conversationId)) { + /*for (Map.Entry<String, String> i : member.entrySet()) { + Log.w(TAG, "conversation member: " + i.getKey() + " " + i.getValue()); + }*/ + val uri = Uri.fromId(member["uri"]!!) + //String role = member.get("role"); + val lastDisplayed = member["lastDisplayed"] + var contact = conversation.findContact(uri) + if (contact == null) { + contact = account.getContactFromCache(uri) + conversation.addContact(contact) + } + if (!StringUtils.isEmpty(lastDisplayed) && contact.isUser) { + conversation.setLastMessageRead(lastDisplayed) + } + } + } + conversation.lastElementLoaded = Completable.defer { loadMore(conversation, 2).ignoreElement() }.cache() + account.conversationStarted(conversation) + } catch (e: Exception) { + Log.w(TAG, "Error loading conversation", e) + } + } + for (requestData in JamiService.getConversationRequests(account.accountID).toNative()) { + /*for (Map.Entry<String, String> e : requestData.entrySet()) { + Log.e(TAG, "Request: " + e.getKey() + " " + e.getValue()); + }*/ + val conversationId = requestData["id"] + val from = Uri.fromString(requestData["from"]!!) + val request = account.getRequest(from) + if (request == null || conversationId != request.conversationId) { + val received = requestData["received"] + account.addRequest(TrustRequest(account.accountID, from, java.lang.Long.decode(received) * 1000L, null, conversationId)) + } + } + if (enabled) { + for (contact in account.contacts.values) { + if (!contact.isUsernameLoaded) JamiService.lookupAddress( + accountId, + "", + contact.uri.rawRingId + ) + } + } + } + } + mHasSipAccount = hasSip + mHasRingAccount = hasJami + if (!newAccounts.isEmpty()) { + val newAccount = newAccounts[0] + if (currentAccount !== newAccount) { + currentAccount = newAccount + } + } + } + accountsSubject.onNext(newAccounts) + } + + private fun getAccountByName(name: String): Account? { + synchronized(mAccountList) { + for (acc in mAccountList) { + if (acc.alias == name) return acc + } + } + return null + } + + fun getNewAccountName(prefix: String?): String { + var name = String.format(prefix!!, "").trim { it <= ' ' } + if (getAccountByName(name) == null) { + return name + } + var num = 1 + do { + num++ + name = String.format(prefix, num).trim { it <= ' ' } + } while (getAccountByName(name) != null) + return name + } + + /** + * Adds a new Account in the Daemon (also sends an ACCOUNT_ADDED event) + * Sets the new account as the current one + * + * @param map the account details + * @return the created Account + */ + fun addAccount(map: Map<String?, String?>?): Observable<Account?> { + return Observable.fromCallable { + val accountId = JamiService.addAccount(StringMap.toSwig(map)) + if (StringUtils.isEmpty(accountId)) { + throw RuntimeException("Can't create account.") + } + var account = getAccount(accountId) + if (account == null) { + val accountDetails: Map<String, String> = JamiService.getAccountDetails(accountId).toNative() + val accountCredentials: List<Map<String, String>> = JamiService.getCredentials(accountId).toNative() + val accountVolatileDetails: Map<String, String> = JamiService.getVolatileAccountDetails(accountId).toNative() + val accountDevices: Map<String, String> = JamiService.getKnownRingDevices(accountId).toNative() + account = Account(accountId, accountDetails, accountCredentials, accountVolatileDetails) + account.devices = accountDevices + if (account.isSip) { + account.setRegistrationState(AccountConfig.STATE_READY, -1) + } + mAccountList.add(account) + accountsSubject.onNext(mAccountList) + } + account + } + .flatMap { account: Account -> + observableAccounts + .filter { acc: Account? -> acc!!.accountID == account.accountID } + .startWithItem(account) + } + .subscribeOn(Schedulers.from(mExecutor)) + } + + val currentAccountIndex: Int + get() = mAccountList.indexOf(currentAccount) + + /** + * @return the Account from the local cache that matches the accountId + */ + fun getAccount(accountId: String): Account? { + if (!StringUtils.isEmpty(accountId)) { + synchronized(mAccountList) { for (account in mAccountList) if (accountId == account.accountID) return account } + } + return null + } + + fun getAccountSingle(accountId: String): Single<Account> { + return accountsSubject + .firstOrError() + .map { accounts: List<Account> -> + for (account in accounts) { + if (account.accountID == accountId) { + return@map account + } + } + Log.d(TAG, "getAccountSingle() can't find account $accountId") + throw IllegalArgumentException() + } + } + + val observableAccountList: Observable<List<Account>> + get() = accountsSubject + + fun getObservableAccountUpdates(accountId: String): Observable<Account> { + return observableAccounts.filter { acc -> acc.accountID == accountId } + } + + fun getObservableAccount(accountId: String): Observable<Account> { + return Observable.fromCallable<Account> { getAccount(accountId) } + .concatWith(getObservableAccountUpdates(accountId)) + } + + fun getObservableAccount(account: Account): Observable<Account> { + return Observable.just(account) + .concatWith(observableAccounts.filter { acc -> acc === account }) + } + + val currentProfileAccountSubject: Observable<Account> + get() = currentAccountSubject.flatMapSingle { a: Account -> + mVCardService.loadProfile(a).firstOrError().map { p: Tuple<String?, Any?>? -> a } + } + + fun subscribeBuddy(accountID: String?, uri: String?, flag: Boolean) { + mExecutor.execute { JamiService.subscribeBuddy(accountID, uri, flag) } + } + + /** + * Send profile through SIP + */ + fun sendProfile(callId: String, accountId: String) { + mVCardService.loadSmallVCard(accountId, VCardService.MAX_SIZE_SIP) + .subscribeOn(Schedulers.computation()) + .observeOn(Schedulers.from(mExecutor)) + .subscribe({ vcard: VCard -> + var stringVCard = VCardUtils.vcardToString(vcard)!! + val nbTotal = stringVCard.length / VCARD_CHUNK_SIZE + if (stringVCard.length % VCARD_CHUNK_SIZE != 0) 1 else 0 + var i = 1 + val r = Random(System.currentTimeMillis()) + val key = Math.abs(r.nextInt()) + Log.d(TAG, "sendProfile, vcard $callId") + while (i <= nbTotal) { + val chunk = HashMap<String, String>() + Log.d(TAG, "length vcard " + stringVCard.length + " id " + key + " part " + i + " nbTotal " + nbTotal) + val keyHashMap = VCardUtils.MIME_PROFILE_VCARD + "; id=" + key + ",part=" + i + ",of=" + nbTotal + val message = stringVCard.substring(0, min(VCARD_CHUNK_SIZE, stringVCard.length)) + chunk[keyHashMap] = message + JamiService.sendTextMessage(callId, StringMap.toSwig(chunk), "Me", false) + if (stringVCard.length > VCARD_CHUNK_SIZE) { + stringVCard = stringVCard.substring(VCARD_CHUNK_SIZE) + } + i++ + } + }) { e: Throwable -> Log.w(TAG, "Not sending empty profile", e) } + } + + fun setMessageDisplayed(accountId: String?, conversationUri: Uri, messageId: String?) { + mExecutor.execute { JamiService.setMessageDisplayed(accountId, conversationUri.uri, messageId, 3) } + } + + fun startConversation(accountId: String, initialMembers: Collection<String>): Single<Conversation> { + return getAccountSingle(accountId).map { account -> + Log.w(TAG, "startConversation") + val id = JamiService.startConversation(accountId) + val conversation = account.getSwarm(id)!! //new Conversation(accountId, new Uri(id)); + for (member in initialMembers) { + Log.w(TAG, "addConversationMember $member") + JamiService.addConversationMember(accountId, id, member) + conversation.addContact(account.getContactFromCache(member)) + } + account.conversationStarted(conversation) + Log.w(TAG, "loadConversationMessages") + conversation + }.subscribeOn(Schedulers.from(mExecutor)) + } + + fun removeConversation(accountId: String, conversationUri: Uri): Completable { + return Completable.fromAction { JamiService.removeConversation(accountId, conversationUri.rawRingId) } + .subscribeOn(Schedulers.from(mExecutor)) + } + + fun loadConversationHistory(accountId: String, conversationUri: Uri, root: String, n: Long) { + JamiService.loadConversationMessages(accountId, conversationUri.rawRingId, root, n) + } + + @JvmOverloads + fun loadMore(conversation: Conversation, n: Int = 16): Single<Conversation> { + synchronized(conversation) { + if (conversation.isLoaded()) { + Log.w(TAG, "loadMore: conversation already fully loaded") + return Single.just(conversation) + } + if (conversation.mode.blockingFirst() == Conversation.Mode.Syncing) { + Log.w(TAG, "loadMore: conversation is syncing") + return Single.just(conversation) + } + conversation.loading?.let { return it } + val ret = SingleSubject.create<Conversation>() + val roots = conversation.swarmRoot + Log.w(TAG, "loadMore " + conversation.uri + " " + roots) + conversation.loading = ret + if (roots.isEmpty()) + loadConversationHistory(conversation.accountId, conversation.uri, "", n.toLong() + ) else { + for (root in roots) + loadConversationHistory(conversation.accountId, conversation.uri, root, n.toLong()) + } + return ret + } + } + + fun sendConversationMessage(accountId: String, conversationUri: Uri, txt: String) { + mExecutor.execute { + Log.w(TAG, "sendConversationMessages " + conversationUri.rawRingId + " : " + txt) + JamiService.sendMessage(accountId, conversationUri.rawRingId, txt, "") + } + } + /** + * @return Account Ids list from Daemon + */ + /*public Single<List<String>> getAccountList() { + return Single.fromCallable(() -> (List<String>)new ArrayList<>(JamiService.getAccountList())) + .subscribeOn(Schedulers.from(mExecutor)); + }*/ + /** + * Sets the order of the accounts in the Daemon + * + * @param accountOrder The ordered list of account ids + */ + fun setAccountOrder(accountOrder: List<String>) { + mExecutor.execute { + val order = StringBuilder() + for (accountId in accountOrder) { + order.append(accountId) + order.append(File.separator) + } + JamiService.setAccountsOrder(order.toString()) + } + } + + /** + * Sets the account details in the Daemon + */ + fun setAccountDetails(accountId: String, map: Map<String, String>) { + Log.i(TAG, "setAccountDetails() $accountId") + mExecutor.execute { JamiService.setAccountDetails(accountId, StringMap.toSwig(map)) } + } + + fun migrateAccount(accountId: String, password: String): Single<String?> { + return mMigrationSubject + .filter { r: MigrationResult -> r.accountId == accountId } + .map { r: MigrationResult -> r.state } + .firstOrError() + .doOnSubscribe { + val details = getAccount(accountId)!!.details + details[ConfigKey.ARCHIVE_PASSWORD.key()] = password + mExecutor.execute { JamiService.setAccountDetails(accountId, StringMap.toSwig(details)) } + } + .subscribeOn(Schedulers.from(mExecutor)) + } + + fun setAccountEnabled(accountId: String?, active: Boolean) { + mExecutor.execute { JamiService.sendRegister(accountId, active) } + } + + /** + * Sets the activation state of the account in the Daemon + */ + fun setAccountActive(accountId: String?, active: Boolean) { + mExecutor.execute { JamiService.setAccountActive(accountId, active) } + } + + /** + * Sets the activation state of all the accounts in the Daemon + */ + fun setAccountsActive(active: Boolean) { + mExecutor.execute { + Log.i(TAG, "setAccountsActive() running... $active") + synchronized(mAccountList) { + for (a in mAccountList) { + // If the proxy is enabled we can considered the account + // as always active + if (a.isDhtProxyEnabled) { + JamiService.setAccountActive(a.accountID, true) + } else { + JamiService.setAccountActive(a.accountID, active) + } + } + } + } + } + + /** + * Sets the video activation state of all the accounts in the local cache + */ + fun setAccountsVideoEnabled(isEnabled: Boolean) { + synchronized(mAccountList) { + for (account in mAccountList) { + account.setDetail(ConfigKey.VIDEO_ENABLED, isEnabled) + } + } + } + + /** + * @return the default template (account details) for a type of account + */ + fun getAccountTemplate(accountType: String): Single<HashMap<String, String>> { + Log.i(TAG, "getAccountTemplate() $accountType") + return Single.fromCallable { JamiService.getAccountTemplate(accountType).toNative() } + .subscribeOn(Schedulers.from(mExecutor)) + } + + /** + * Removes the account in the Daemon as well as local history + */ + fun removeAccount(accountId: String) { + Log.i(TAG, "removeAccount() $accountId") + mExecutor.execute { JamiService.removeAccount(accountId) } + mHistoryService.clearHistory(accountId).subscribe() + } + + /** + * Exports the account on the DHT (used for multi-devices feature) + */ + fun exportOnRing(accountId: String, password: String): Single<String> { + return mExportSubject + .filter { r: ExportOnRingResult -> r.accountId == accountId } + .firstOrError() + .map { result: ExportOnRingResult -> + when (result.code) { + PIN_GENERATION_SUCCESS -> return@map result.pin!! + PIN_GENERATION_WRONG_PASSWORD -> throw IllegalArgumentException() + PIN_GENERATION_NETWORK_ERROR -> throw SocketException() + else -> throw UnsupportedOperationException() + } + } + .doOnSubscribe { + Log.i(TAG, "exportOnRing() $accountId") + mExecutor.execute { JamiService.exportOnRing(accountId, password) } + } + .subscribeOn(Schedulers.io()) + } + + /** + * @return the list of the account's devices from the Daemon + */ + fun getKnownRingDevices(accountId: String): Map<String, String> { + Log.i(TAG, "getKnownRingDevices() $accountId") + return try { + mExecutor.submit<HashMap<String, String>> { + JamiService.getKnownRingDevices(accountId).toNative() + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running getKnownRingDevices()", e) + return HashMap() + } + } + + /** + * @param accountId id of the account used with the device + * @param deviceId id of the device to revoke + * @param password password of the account + */ + fun revokeDevice(accountId: String, password: String, deviceId: String): Single<Int> { + return mDeviceRevocationSubject + .filter { r: DeviceRevocationResult -> r.accountId == accountId && r.deviceId == deviceId } + .firstOrError() + .map { r: DeviceRevocationResult -> r.code } + .doOnSubscribe { mExecutor.execute { + JamiService.revokeDevice(accountId, password, deviceId) + }} + .subscribeOn(Schedulers.io()) + } + + /** + * @param accountId id of the account used with the device + * @param newName new device name + */ + fun renameDevice(accountId: String, newName: String) { + val account = getAccount(accountId) + mExecutor.execute { + Log.i(TAG, "renameDevice() thread running... $newName") + val details = JamiService.getAccountDetails(accountId) + details[ConfigKey.ACCOUNT_DEVICE_NAME.key()] = newName + JamiService.setAccountDetails(accountId, details) + account!!.setDetail(ConfigKey.ACCOUNT_DEVICE_NAME, newName) + account.devices = JamiService.getKnownRingDevices(accountId).toNative() + } + } + + fun exportToFile(accountId: String, absolutePath: String, password: String): Completable { + return Completable.fromAction { + require(JamiService.exportToFile(accountId, absolutePath, password)) { "Can't export archive" } + }.subscribeOn(Schedulers.from(mExecutor)) + } + + /** + * @param accountId id of the account + * @param oldPassword old account password + */ + fun setAccountPassword(accountId: String, oldPassword: String, newPassword: String): Completable { + return Completable.fromAction { + require(JamiService.changeAccountPassword(accountId, oldPassword, newPassword)) { "Can't change password" } + }.subscribeOn(Schedulers.from(mExecutor)) + } + + /** + * Sets the active codecs list of the account in the Daemon + */ + fun setActiveCodecList(accountId: String, codecs: List<Long>) { + mExecutor.execute { + val list = UintVect() + list.reserve(codecs.size.toLong()) + list.addAll(codecs) + JamiService.setActiveCodecList(accountId, list) + observableAccounts.onNext(getAccount(accountId)) + } + } + + /** + * @return The account's codecs list from the Daemon + */ + fun getCodecList(accountId: String): Single<List<Codec>> { + return Single.fromCallable<List<Codec>> { + val results: MutableList<Codec> = ArrayList() + val payloads = JamiService.getCodecList() + val activePayloads = JamiService.getActiveCodecList(accountId) + for (i in payloads.indices) { + val details = JamiService.getCodecDetails(accountId, payloads[i]) + if (details.size > 1) { + results.add(Codec(payloads[i], details.toNative(), activePayloads.contains(payloads[i]))) + } else { + Log.i(TAG, "Error loading codec $i") + } + } + results + }.subscribeOn(Schedulers.from(mExecutor)) + } + + fun validateCertificatePath( + accountID: String?, + certificatePath: String?, + privateKeyPath: String?, + privateKeyPass: String? + ): Map<String, String>? { + try { + return mExecutor.submit<HashMap<String, String>> { + Log.i(TAG, "validateCertificatePath() running...") + JamiService.validateCertificatePath( + accountID, + certificatePath, + privateKeyPath, + privateKeyPass, + "" + ).toNative() + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running validateCertificatePath()", e) + } + return null + } + + fun validateCertificate(accountId: String?, certificate: String?): Map<String, String>? { + try { + return mExecutor.submit<HashMap<String, String>> { + Log.i(TAG, "validateCertificate() running...") + JamiService.validateCertificate(accountId, certificate).toNative() + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running validateCertificate()", e) + } + return null + } + + fun getCertificateDetailsPath(certificatePath: String?): Map<String, String>? { + try { + return mExecutor.submit<HashMap<String, String>> { + Log.i(TAG, "getCertificateDetailsPath() running...") + JamiService.getCertificateDetails(certificatePath).toNative() + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running getCertificateDetailsPath()", e) + } + return null + } + + fun getCertificateDetails(certificateRaw: String?): Map<String, String>? { + try { + return mExecutor.submit<HashMap<String, String>> { + Log.i(TAG, "getCertificateDetails() running...") + JamiService.getCertificateDetails(certificateRaw).toNative() + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running getCertificateDetails()", e) + } + return null + } + + /** + * @return the supported TLS methods from the Daemon + */ + val tlsSupportedMethods: List<String> + get() { + Log.i(TAG, "getTlsSupportedMethods()") + return SwigNativeConverter.toJava(JamiService.getSupportedTlsMethod()) + } + + /** + * @return the account's credentials from the Daemon + */ + fun getCredentials(accountId: String?): List<Map<String, String>>? { + try { + return mExecutor.submit<ArrayList<Map<String, String>>> { + Log.i(TAG, "getCredentials() running...") + JamiService.getCredentials(accountId).toNative() + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running getCredentials()", e) + } + return null + } + + /** + * Sets the account's credentials in the Daemon + */ + fun setCredentials(accountId: String, credentials: List<Map<String, String>>) { + Log.i(TAG, "setCredentials() $accountId") + mExecutor.execute { JamiService.setCredentials(accountId, SwigNativeConverter.toSwig(credentials)) } + } + + /** + * Sets the registration state to true for all the accounts in the Daemon + */ + fun registerAllAccounts() { + Log.i(TAG, "registerAllAccounts()") + mExecutor.execute { registerAllAccounts() } + } + + /** + * Registers a new name on the blockchain for the account + */ + fun registerName(account: Account, password: String?, name: String?) { + if (account.registeringUsername) { + Log.w(TAG, "Already trying to register username") + return + } + account.registeringUsername = true + registerName(account.accountID, password ?: "", name) + } + + /** + * Register a new name on the blockchain for the account Id + */ + fun registerName(account: String?, password: String?, name: String?) { + Log.i(TAG, "registerName()") + mExecutor.execute { JamiService.registerName(account, password, name) } + } + /* contact requests */ + /** + * @return all trust requests from the daemon for the account Id + */ + fun getTrustRequests(accountId: String?): List<Map<String, String>>? { + try { + return mExecutor.submit<ArrayList<Map<String, String>>> { + JamiService.getTrustRequests( + accountId + ).toNative() + } + .get() + } catch (e: Exception) { + Log.e(TAG, "Error running getTrustRequests()", e) + } + return null + } + + /** + * Accepts a pending trust request + */ + fun acceptTrustRequest(accountId: String, from: Uri) { + Log.i(TAG, "acceptRequest() $accountId $from") + getAccount(accountId)?.let { account -> account.removeRequest(from)?.vCard?.let{ vcard -> + VCardUtils.savePeerProfileToDisk(vcard, accountId, from.rawRingId + ".vcf", mDeviceRuntimeService.provideFilesDir()) + }} + mExecutor.execute { JamiService.acceptTrustRequest(accountId, from.rawRingId) } + } + + /** + * Handles adding contacts and is the initial point of conversation creation + * + * @param conversation the user's account + * @param contactUri the contacts raw string uri + */ + private fun handleTrustRequest(conversation: Conversation, contactUri: Uri, request: TrustRequest?, type: ContactType) { + val event = ContactEvent() + when (type) { + ContactType.ADDED -> { + } + ContactType.INVITATION_RECEIVED -> { + event.status = InteractionStatus.UNKNOWN + event.author = contactUri.rawRingId + event.timestamp = request!!.timestamp + } + ContactType.INVITATION_ACCEPTED -> { + event.status = InteractionStatus.SUCCESS + event.author = contactUri.rawRingId + } + ContactType.INVITATION_DISCARDED -> { + mHistoryService.clearHistory(contactUri.rawRingId, conversation.accountId, true) + .subscribe() + return + } + else -> return + } + mHistoryService.insertInteraction(conversation.accountId, conversation, event).subscribe() + } + + private enum class ContactType { + ADDED, INVITATION_RECEIVED, INVITATION_ACCEPTED, INVITATION_DISCARDED + } + + /** + * Refuses and blocks a pending trust request + */ + fun discardTrustRequest(accountId: String, contactUri: Uri): Boolean { + val account = getAccount(accountId) + var removed = false + if (account != null) { + removed = account.removeRequest(contactUri) != null + mHistoryService.clearHistory(contactUri.rawRingId, accountId, true).subscribe() + } + mExecutor.execute { JamiService.discardTrustRequest(accountId, contactUri.rawRingId) } + return removed + } + + /** + * Sends a new trust request + */ + fun sendTrustRequest(conversation: Conversation, to: Uri, message: Blob?) { + Log.i(TAG, "sendTrustRequest() " + conversation.accountId + " " + to) + handleTrustRequest(conversation, to, null, ContactType.ADDED) + mExecutor.execute { JamiService.sendTrustRequest(conversation.accountId, to.rawRingId, message ?: Blob()) } + } + + /** + * Add a new contact for the account Id on the Daemon + */ + fun addContact(accountId: String, uri: String) { + Log.i(TAG, "addContact() $accountId $uri") + //handleTrustRequest(accountId, Uri.fromString(uri), null, ContactType.ADDED); + mExecutor.execute { JamiService.addContact(accountId, uri) } + } + + /** + * Remove an existing contact for the account Id on the Daemon + */ + fun removeContact(accountId: String, uri: String, ban: Boolean) { + Log.i(TAG, "removeContact() $accountId $uri ban:$ban") + mExecutor.execute { JamiService.removeContact(accountId, uri, ban) } + } + + /** + * @return the contacts list from the daemon + */ + fun getContacts(accountId: String?): List<Map<String, String>>? { + try { + return mExecutor.submit<ArrayList<Map<String, String>>> { + JamiService.getContacts( + accountId + ).toNative() + } + .get() + } catch (e: Exception) { + Log.e(TAG, "Error running getContacts()", e) + } + return null + } + + /** + * Looks up for the availability of the name on the blockchain + */ + fun lookupName(account: String, nameserver: String, name: String) { + Log.i(TAG, "lookupName() $account $nameserver $name") + mExecutor.execute { JamiService.lookupName(account, nameserver, name) } + } + + fun findRegistrationByName(account: String, nameserver: String, name: String): Single<RegisteredName> { + return if (name.isEmpty()) { + Single.just(RegisteredName(account, name)) + } else registeredNames + .filter { r: RegisteredName -> account == r.accountId && name == r.name } + .firstOrError() + .doOnSubscribe { + mExecutor.execute { JamiService.lookupName(account, nameserver, name) } + } + .subscribeOn(Schedulers.from(mExecutor)) + } + + fun searchUser(account: String, query: String): Single<UserSearchResult> { + if (StringUtils.isEmpty(query)) { + return Single.just(UserSearchResult(account, query)) + } + val encodedUrl: String = try { + URLEncoder.encode(query, "UTF-8") + } catch (e: UnsupportedEncodingException) { + return Single.error(e) + } + return searchResults + .filter { r: UserSearchResult -> account == r.accountId && encodedUrl == r.query } + .firstOrError() + .doOnSubscribe { + mExecutor.execute { JamiService.searchUser(account, encodedUrl) } + } + .subscribeOn(Schedulers.from(mExecutor)) + } + + /** + * Reverse looks up the address in the blockchain to find the name + */ + fun lookupAddress(account: String?, nameserver: String?, address: String?) { + mExecutor.execute { JamiService.lookupAddress(account, nameserver, address) } + } + + fun pushNotificationReceived(from: String?, data: Map<String?, String?>?) { + // Log.i(TAG, "pushNotificationReceived()"); + mExecutor.execute { JamiService.pushNotificationReceived(from, StringMap.toSwig(data)) } + } + + fun setPushNotificationToken(pushNotificationToken: String?) { + //Log.i(TAG, "setPushNotificationToken()"); + mExecutor.execute { JamiService.setPushNotificationToken(pushNotificationToken) } + } + + fun volumeChanged(device: String, value: Int) { + Log.w(TAG, "volumeChanged $device $value") + } + + fun accountsChanged() { + // Accounts have changed in Daemon, we have to update our local cache + refreshAccountsCacheFromDaemon() + } + + fun stunStatusFailure(accountId: String) { + Log.d(TAG, "stun status failure: $accountId") + } + + fun registrationStateChanged(accountId: String, newState: String, code: Int, detailString: String?) { + //Log.d(TAG, "registrationStateChanged: " + accountId + ", " + newState + ", " + code + ", " + detailString); + val account = getAccount(accountId) ?: return + val oldState = account.registrationState + if (oldState.contentEquals(AccountConfig.STATE_INITIALIZING) && !newState.contentEquals(AccountConfig.STATE_INITIALIZING)) { + account.setDetails(JamiService.getAccountDetails(account.accountID).toNative()) + account.setCredentials(JamiService.getCredentials(account.accountID).toNative()) + account.devices = JamiService.getKnownRingDevices(account.accountID).toNative() + account.setVolatileDetails(JamiService.getVolatileAccountDetails(account.accountID).toNative()) + } else { + account.setRegistrationState(newState, code) + } + if (oldState != newState) { + observableAccounts.onNext(account) + } + } + + fun accountDetailsChanged(accountId: String, details: Map<String, String>) { + val account = getAccount(accountId) ?: return + Log.d(TAG, "accountDetailsChanged: " + accountId + " " + details.size) + account.setDetails(details) + observableAccounts.onNext(account) + } + + fun volatileAccountDetailsChanged(accountId: String, details: Map<String, String>) { + val account = getAccount(accountId) ?: return + //Log.d(TAG, "volatileAccountDetailsChanged: " + accountId + " " + details.size()); + account.setVolatileDetails(details) + observableAccounts.onNext(account) + } + + fun accountProfileReceived(accountId: String, name: String?, photo: String?) { + val account = getAccount(accountId) ?: return + mVCardService.saveVCardProfile(accountId, account.uri, name, photo) + .subscribeOn(Schedulers.io()) + .subscribe({ account.resetProfile() }) { e -> Log.e(TAG, "Error saving profile", e) } + } + + fun profileReceived(accountId: String, peerId: String, vcardPath: String) { + val account = getAccount(accountId) ?: return + Log.w(TAG, "profileReceived: $accountId, $peerId, $vcardPath") + val contact = account.getContactFromCache(peerId) + mVCardService.peerProfileReceived(accountId, peerId, File(vcardPath)) + .subscribe({ profile: Tuple<String?, Any?> -> + contact.setProfile(profile.first, profile.second) + }) { e -> Log.e(TAG, "Error saving contact profile", e) } + } + + fun incomingAccountMessage(accountId: String, messageId: String?, callId: String?, from: String, messages: Map<String, String>) { + Log.d(TAG, "incomingAccountMessage: " + accountId + " " + messages.size) + incomingMessageSubject.onNext(Message(accountId, messageId, callId, from, messages)) + } + + fun accountMessageStatusChanged( + accountId: String, + conversationId: String, + messageId: String, + peer: String, + status: Int + ) { + val newStatus = InteractionStatus.fromIntTextMessage(status) + Log.d(TAG, "accountMessageStatusChanged: $accountId, $conversationId, $messageId, $peer, $newStatus") + if (StringUtils.isEmpty(conversationId)) { + mHistoryService + .accountMessageStatusChanged(accountId, messageId, peer, newStatus) + .subscribe({ t: TextMessage -> messageSubject.onNext(t) }) { e: Throwable -> + Log.e(TAG, "Error updating message: " + e.localizedMessage) } + } else { + val msg = Interaction(accountId) + msg.status = newStatus + msg.setSwarmInfo(conversationId, messageId, null) + messageSubject.onNext(msg) + } + } + + fun composingStatusChanged( + accountId: String, + conversationId: String, + contactUri: String, + status: Int + ) { + Log.d(TAG, "composingStatusChanged: $accountId, $contactUri, $conversationId, $status") + getAccountSingle(accountId) + .subscribe { account: Account -> + account.composingStatusChanged( + conversationId, + Uri.fromId(contactUri), + Account.ComposingStatus.fromInt(status) + ) + } + } + + fun errorAlert(alert: Int) { + Log.d(TAG, "errorAlert : $alert") + } + + fun knownDevicesChanged(accountId: String, devices: Map<String, String>) { + getAccount(accountId)?.let { account -> + account.devices = devices + observableAccounts.onNext(account) + } + } + + fun exportOnRingEnded(accountId: String, code: Int, pin: String) { + Log.d(TAG, "exportOnRingEnded: $accountId, $code, $pin") + mExportSubject.onNext(ExportOnRingResult(accountId, code, pin)) + } + + fun nameRegistrationEnded(accountId: String, state: Int, name: String) { + Log.d(TAG, "nameRegistrationEnded: $accountId, $state, $name") + val acc = getAccount(accountId) + if (acc == null) { + Log.w(TAG, "Can't find account for name registration callback") + return + } + acc.registeringUsername = false + acc.setVolatileDetails(JamiService.getVolatileAccountDetails(acc.accountID).toNative()) + if (state == 0) { + acc.setDetail(ConfigKey.ACCOUNT_REGISTERED_NAME, name) + } + observableAccounts.onNext(acc) + } + + fun migrationEnded(accountId: String, state: String) { + Log.d(TAG, "migrationEnded: $accountId, $state") + mMigrationSubject.onNext(MigrationResult(accountId, state)) + } + + fun deviceRevocationEnded(accountId: String, device: String, state: Int) { + Log.d(TAG, "deviceRevocationEnded: $accountId, $device, $state") + if (state == 0) { + getAccount(accountId)?.let { account -> + val devices = HashMap(account.devices) + devices.remove(device) + account.devices = devices + observableAccounts.onNext(account) + } + } + mDeviceRevocationSubject.onNext(DeviceRevocationResult(accountId, device, state)) + } + + fun incomingTrustRequest(accountId: String, conversationId: String,from: String, message: String?, received: Long) { + Log.d(TAG, "incomingTrustRequest: $accountId, $conversationId, $from, $received") + val account = getAccount(accountId) + if (account != null) { + val fromUri = Uri.fromString(from) + var request = account.getRequest(fromUri) + if (request == null) request = TrustRequest( + accountId, + fromUri, + received * 1000L, + message, + conversationId + ) else request.vCard = Ezvcard.parse(message).first() + val vcard = request.vCard + if (vcard != null) { + val contact = account.getContactFromCache(fromUri) + if (!contact.detailsLoaded) { + // VCardUtils.savePeerProfileToDisk(vcard, accountId, from + ".vcf", mDeviceRuntimeService.provideFilesDir()); + mVCardService.loadVCardProfile(vcard) + .subscribeOn(Schedulers.computation()) + .subscribe { profile: Tuple<String?, Any?> -> + contact.setProfile( + profile.first, + profile.second + ) + } + } + } + account.addRequest(request) + // handleTrustRequest(account, Uri.fromString(from), request, ContactType.INVITATION_RECEIVED); + if (account.isEnabled) lookupAddress(accountId, "", from) + incomingRequestsSubject.onNext(request) + } + } + + fun contactAdded(accountId: String, uri: String, confirmed: Boolean) { + val account = getAccount(accountId) + if (account != null) { + val details: Map<String, String> = JamiService.getContactDetails(accountId, uri) + val contact = account.addContact(details) + val conversationUri = contact.conversationUri.blockingFirst() + if (conversationUri.isSwarm) { + var conversation = account.getSwarm(conversationUri.rawRingId) + if (conversation == null) { + conversation = account.newSwarm(conversationUri.rawRingId, Conversation.Mode.Syncing) + conversation.addContact(contact) + } + } + //account.addContact(uri, confirmed); + if (account.isEnabled) lookupAddress(accountId, "", uri) + } + } + + fun contactRemoved(accountId: String, uri: String, banned: Boolean) { + Log.d(TAG, "Contact removed: $uri User is banned: $banned") + getAccount(accountId)?.let { account -> + mHistoryService.clearHistory(uri, accountId, true).subscribe() + account.removeContact(uri, banned) + } + } + + fun registeredNameFound(accountId: String, state: Int, address: String, name: String) { + try { + //Log.d(TAG, "registeredNameFound: " + accountId + ", " + state + ", " + name + ", " + address); + if (address.isNotEmpty()) { + getAccount(accountId)?.registeredNameFound(state, address, name) + } + registeredNameSubject.onNext(RegisteredName(accountId, name, address, state)) + } catch (e: Exception) { + Log.w(TAG, "registeredNameFound exception", e) + } + } + + fun userSearchEnded(accountId: String, state: Int, query: String, results: ArrayList<Map<String, String>>) { + val account = getAccount(accountId)!! + val r = UserSearchResult(accountId, query, state) + val contacts = ArrayList<Contact>(results.size) + for (m in results) { + val uri = m["id"]!! + val username = m["username"] + val firstName = m["firstName"] + val lastName = m["lastName"] + val picture_b64 = m["profilePicture"] + val contact = account.getContactFromCache(uri) + if (username != null) + contact.setUsername(username) + contact.setProfile("$firstName $lastName", mVCardService.base64ToBitmap(picture_b64)) + contacts.add(contact) + } + r.results = contacts + searchResultSubject.onNext(r) + } + + private fun addMessage( + account: Account, + conversation: Conversation, + message: Map<String, String> + ): Interaction { + for ((key, value) in message) { + Log.w(TAG, "$key -> $value") + } + val id = message["id"]!! + val type = message["type"]!! + val author = message["author"]!! + val parent = message["linearizedParent"] + val authorUri = Uri.fromId(author) + val timestamp = message["timestamp"]!!.toLong() * 1000 + val contact = conversation.findContact(authorUri) ?: account.getContactFromCache(authorUri) + var interaction: Interaction + when (type) { + "initial" -> { + if (conversation.mode.blockingFirst() == Conversation.Mode.OneToOne) { + val invited = message["invited"]!! + var invitedContact = conversation.findContact(Uri.fromId(invited)) + if (invitedContact == null) { + invitedContact = account.getContactFromCache(invited) + } + invitedContact.addedDate = Date(timestamp) + interaction = ContactEvent(invitedContact).setEvent(ContactEvent.Event.fromConversationAction("add")) + } else { + interaction = Interaction(conversation, Interaction.InteractionType.INVALID) + } + } + "member" -> { + val action = message["action"]!! + val uri = message["uri"]!! + var member = conversation.findContact(Uri.fromId(uri)) + if (member == null) { + member = account.getContactFromCache(uri) + } + member.addedDate = Date(timestamp) + interaction = ContactEvent(member).setEvent(ContactEvent.Event.fromConversationAction(action)) + } + "text/plain" -> interaction = TextMessage(author, account.accountID, timestamp, conversation, message["body"]!!, !contact.isUser) + "application/data-transfer+json" -> { + try { + val fileName = message["displayName"]!! + val fileId = message["fileId"] + //interaction = account.getDataTransfer(fileId); + //if (interaction == null) { + val paths = arrayOfNulls<String>(1) + val progressA = LongArray(1) + val totalA = LongArray(1) + JamiService.fileTransferInfo(account.accountID, conversation.uri.rawRingId, fileId, paths, totalA, progressA) + if (totalA[0] == 0L) { + totalA[0] = message["totalSize"]!!.toLong() + } + val path = File(paths[0]!!) + interaction = DataTransfer(fileId, account.accountID, author, fileName, contact.isUser, timestamp, totalA[0], progressA[0]) + interaction.daemonPath = path + val isComplete = path.exists() && progressA[0] == totalA[0] + Log.w(TAG, "add DataTransfer at " + paths[0] + " with progress " + progressA[0] + "/" + totalA[0]) + interaction.status = if (isComplete) InteractionStatus.TRANSFER_FINISHED else InteractionStatus.FILE_AVAILABLE + //} + } catch (e: Exception) { + interaction = Interaction(conversation, Interaction.InteractionType.INVALID) + } + } + "application/call-history+json" -> { + interaction = Call(null, account.accountID, authorUri.rawUriString, if (contact.isUser) Call.Direction.OUTGOING else Call.Direction.INCOMING,timestamp) + interaction.duration = message["duration"]!!.toLong() + } + "merge" -> interaction = Interaction(conversation, Interaction.InteractionType.INVALID) + else -> interaction = Interaction(conversation, Interaction.InteractionType.INVALID) + } + interaction.contact = contact + interaction.setSwarmInfo(conversation.uri.rawRingId, id, if (StringUtils.isEmpty(parent)) null else parent) + interaction.conversation = conversation + if (conversation.addSwarmElement(interaction)) { + if (conversation.isVisible) + mHistoryService.setMessageRead(account.accountID, conversation.uri, interaction.messageId) + } + return interaction + } + + fun conversationLoaded(accountId: String, conversationId: String, messages: List<Map<String, String>>) { + try { + // Log.w(TAG, "ConversationCallback: conversationLoaded " + accountId + "/" + conversationId + " " + messages.size()); + getAccount(accountId)?.let { account -> account.getSwarm(conversationId)?.let { conversation -> + synchronized(conversation) { + for (message in messages) { + addMessage(account, conversation, message) + } + conversation.stopLoading() + } + account.conversationChanged() + }} + } catch (e: Exception) { + Log.e(TAG, "Exception loading message", e) + } + } + + private enum class ConversationMemberEvent { + Add, Join, Remove, Ban + } + + fun conversationMemberEvent(accountId: String, conversationId: String, peerUri: String, event: Int) { + Log.w(TAG, "ConversationCallback: conversationMemberEvent $accountId/$conversationId") + getAccount(accountId)?.let { account -> account.getSwarm(conversationId)?.let { conversation -> + val uri = Uri.fromId(peerUri) + when (ConversationMemberEvent.values()[event]) { + ConversationMemberEvent.Add, ConversationMemberEvent.Join -> { + val contact = conversation.findContact(uri) + if (contact == null) { + conversation.addContact(account.getContactFromCache(uri)) + } + } + ConversationMemberEvent.Remove, ConversationMemberEvent.Ban -> { + if (conversation.mode.blockingFirst() != Conversation.Mode.OneToOne) { + conversation.findContact(uri)?.let { contact -> conversation.removeContact(contact) } + } + } + } + }} + } + + fun conversationReady(accountId: String, conversationId: String) { + Log.w(TAG, "ConversationCallback: conversationReady $accountId/$conversationId") + val account = getAccount(accountId) + if (account == null) { + Log.w(TAG, "conversationReady: can't find account") + return + } + val info = JamiService.conversationInfos(accountId, conversationId) + /*for (Map.Entry<String, String> i : info.entrySet()) { + Log.w(TAG, "conversation info: " + i.getKey() + " " + i.getValue()); + }*/ + val modeInt = info["mode"]!!.toInt() + val mode = Conversation.Mode.values()[modeInt] + var c = account.getSwarm(conversationId) + var setMode = false + if (c == null) { + c = account.newSwarm(conversationId, mode) + } else { + setMode = mode != c.mode.blockingFirst() + } + val conversation = c + synchronized(conversation) { + // Making sure to add contacts before changing the mode + for (member in JamiService.getConversationMembers(accountId, conversationId)) { + val uri = Uri.fromId(member["uri"]!!) + var contact = conversation.findContact(uri) + if (contact == null) { + contact = account.getContactFromCache(uri) + conversation.addContact(contact) + } + } + if (conversation.lastElementLoaded == null) conversation.lastElementLoaded = + Completable.defer { loadMore(conversation, 2).ignoreElement() } + .cache() + if (setMode) conversation.setMode(mode) + } + account.conversationStarted(conversation) + loadMore(conversation, 2) + } + + fun conversationRemoved(accountId: String, conversationId: String) { + val account = getAccount(accountId) + if (account == null) { + Log.w(TAG, "conversationRemoved: can't find account") + return + } + account.removeSwarm(conversationId) + } + + fun conversationRequestDeclined(accountId: String, conversationId: String) { + Log.d(TAG, "conversation's request for $conversationId is declined") + val account = getAccount(accountId) + if (account == null) { + Log.w(TAG, "conversationRequestDeclined: can't find account") + return + } + account.removeRequestPerConvId(conversationId) + } + + fun conversationRequestReceived(accountId: String, conversationId: String, metadata: Map<String, String>) { + Log.w(TAG, "ConversationCallback: conversationRequestReceived " + accountId + "/" + conversationId + " " + metadata.size) + val account = getAccount(accountId) + if (account == null) { + Log.w(TAG, "conversationRequestReceived: can't find account") + return + } + val contactUri = Uri.fromId(metadata["from"]!!) + val request = account.getRequest(contactUri) + if (request == null || conversationId != request.conversationId) { + val received = metadata["received"] + account.addRequest(TrustRequest(account.accountID, contactUri, java.lang.Long.decode(received) * 1000L, null, conversationId)) + } + } + + fun messageReceived(accountId: String, conversationId: String, message: Map<String, String>) { + Log.w(TAG, "ConversationCallback: messageReceived " + accountId + "/" + conversationId + " " + message.size) + getAccount(accountId)?.let { account -> account.getSwarm(conversationId)?.let { conversation -> + synchronized(conversation) { + val interaction = addMessage(account, conversation, message) + account.conversationUpdated(conversation) + val isIncoming = !interaction.contact!!.isUser + if (isIncoming) + incomingSwarmMessageSubject.onNext(interaction) + if (interaction is DataTransfer) + dataTransfers.onNext(interaction) + } + }} + } + + fun sendFile(file: File, dataTransfer: DataTransfer): Single<DataTransfer> { + return Single.fromCallable { + mStartingTransfer = dataTransfer + val dataTransferInfo = DataTransferInfo() + dataTransferInfo.accountId = dataTransfer.account + val conversationId = dataTransfer.conversationId + if (!StringUtils.isEmpty(conversationId)) + dataTransferInfo.conversationId = conversationId + else + dataTransferInfo.peer = dataTransfer.conversation?.participant + dataTransferInfo.path = file.absolutePath + dataTransferInfo.displayName = dataTransfer.displayName + Log.i(TAG, "sendFile() id=" + dataTransfer.id + " accountId=" + dataTransferInfo.accountId + ", peer=" + dataTransferInfo.peer + ", filePath=" + dataTransferInfo.path) + val id = LongArray(1) + val err = getDataTransferError(JamiService.sendFileLegacy(dataTransferInfo, id)) + if (err != DataTransferError.SUCCESS) { + throw IOException(err.name) + } else { + Log.e(TAG, "sendFile: got ID " + id[0]) + dataTransfer.daemonId = id[0] + } + dataTransfer + }.subscribeOn(Schedulers.from(mExecutor)) + } + + fun sendFile(conversation: Conversation, file: File) { + mExecutor.execute { JamiService.sendFile(conversation.accountId, conversation.uri.rawRingId,file.absolutePath, file.name, "") } + } + + fun getLastMessages(accountId: String?, baseTime: Long): List<net.jami.daemon.Message> { + try { + return mExecutor.submit(Callable { + SwigNativeConverter.toJava(JamiService.getLastMessages(accountId, baseTime)) + }).get() + } catch (e: Exception) { + e.printStackTrace() + } + return ArrayList() + } + + fun acceptFileTransfer(accountId: String, conversationUri: Uri, messageId: String?, fileId: String) { + getAccount(accountId)?.let { account -> account.getByUri(conversationUri)?.let { conversation -> + val transfer = if (conversation.isSwarm) + conversation.getMessage(messageId!!) as DataTransfer? + else + account.getDataTransfer(fileId) + acceptFileTransfer(conversation, fileId, transfer!!) + }} + } + + fun acceptFileTransfer(conversation: Conversation, fileId: String, transfer: DataTransfer) { + if (conversation.isSwarm) { + val conversationId = conversation.uri.rawRingId + val newPath = mDeviceRuntimeService.getNewConversationPath(conversation.accountId, conversationId, transfer.displayName) + Log.i(TAG, "downloadFile() id=" + conversation.accountId + ", path=" + conversationId + " " + fileId + " to -> " + newPath.absolutePath) + JamiService.downloadFile(conversation.accountId, conversationId, transfer.messageId, fileId, newPath.absolutePath) + } else { + val path = mDeviceRuntimeService.getTemporaryPath(conversation.uri.rawRingId, transfer.storagePath) + Log.i(TAG, "acceptFileTransfer() id=" + fileId + ", path=" + path.absolutePath) + JamiService.acceptFileTransfer(conversation.accountId, fileId, path.absolutePath) + } + } + + fun cancelDataTransfer(accountId: String, conversationId: String, messageId: String?, fileId: String) { + Log.i(TAG, "cancelDataTransfer() id=$fileId") + mExecutor.execute { JamiService.cancelDataTransfer(accountId, conversationId, fileId) } + } + + private inner class DataTransferRefreshTask constructor( + private val mAccount: Account, + private val mConversation: Conversation?, + private val mToUpdate: DataTransfer + ) : Runnable { + var scheduledTask: ScheduledFuture<*>? = null + override fun run() { + synchronized(mToUpdate) { + if (mToUpdate.status == InteractionStatus.TRANSFER_ONGOING) { + dataTransferEvent(mAccount, mConversation, mToUpdate.messageId, mToUpdate.fileId!!, 5) + } else { + scheduledTask!!.cancel(false) + scheduledTask = null + } + } + } + } + + fun dataTransferEvent(accountId: String, conversationId: String, interactionId: String, fileId: String, eventCode: Int) { + val account = getAccount(accountId) + if (account != null) { + val conversation = if (conversationId.isEmpty()) null else account.getSwarm(conversationId) + dataTransferEvent(account, conversation, interactionId, fileId, eventCode) + } + } + + fun dataTransferEvent(account: Account, conversation: Conversation?, interactionId: String?, fileId: String, eventCode: Int) { + var conversation = conversation + val transferStatus = getDataTransferEventCode(eventCode) + Log.d(TAG, "Data Transfer $interactionId $fileId $transferStatus") + val from: String + val total: Long + val progress: Long + val displayName: String + var transfer = account.getDataTransfer(fileId) + var outgoing = false + if (conversation == null) { + val info = DataTransferInfo() + val err = + getDataTransferError(JamiService.dataTransferInfo(account.accountID, fileId, info)) + if (err != DataTransferError.SUCCESS) { + Log.d(TAG, "Data Transfer error getting details $err") + return + } + from = info.peer + total = info.totalSize + progress = info.bytesProgress + conversation = account.getByUri(from) + outgoing = info.flags == 0L + displayName = info.displayName + } else { + val paths = arrayOfNulls<String>(1) + val progressA = LongArray(1) + val totalA = LongArray(1) + JamiService.fileTransferInfo(account.accountID, conversation.uri.rawRingId, fileId, paths, totalA, progressA) + progress = progressA[0] + total = totalA[0] + if (transfer == null && interactionId != null && interactionId.isNotEmpty()) { + transfer = conversation.getMessage(interactionId) as DataTransfer + } + if (transfer == null) return + transfer.conversation = conversation + transfer.daemonPath = File(paths[0]!!) + from = transfer.author!! + displayName = transfer.displayName + } + if (transfer == null) { + val startingTransfer = mStartingTransfer + if (outgoing && startingTransfer != null) { + Log.d(TAG, "Data Transfer mStartingTransfer") + transfer = startingTransfer + mStartingTransfer = null + } else { + transfer = DataTransfer(conversation, from, account.accountID, displayName, + outgoing, total, + progress, fileId + ) + if (conversation!!.isSwarm) { + transfer.setSwarmInfo(conversation.uri.rawRingId, interactionId!!, null) + } else { + mHistoryService.insertInteraction(account.accountID, conversation, transfer) + .blockingAwait() + } + } + account.putDataTransfer(fileId, transfer) + } else synchronized(transfer) { + val oldState = transfer.status + if (oldState != transferStatus) { + if (transferStatus == InteractionStatus.TRANSFER_ONGOING) { + val task = DataTransferRefreshTask(account, conversation, transfer) + task.scheduledTask = mExecutor.scheduleAtFixedRate( + task, + DATA_TRANSFER_REFRESH_PERIOD, + DATA_TRANSFER_REFRESH_PERIOD, TimeUnit.MILLISECONDS + ) + } else if (transferStatus.isError) { + if (!transfer.isOutgoing) { + val tmpPath = mDeviceRuntimeService.getTemporaryPath( + conversation!!.uri.rawRingId, transfer.storagePath + ) + tmpPath.delete() + } + } else if (transferStatus == InteractionStatus.TRANSFER_FINISHED) { + if (!conversation!!.isSwarm && !transfer.isOutgoing) { + val tmpPath = mDeviceRuntimeService.getTemporaryPath( + conversation.uri.rawRingId, transfer.storagePath + ) + val path = mDeviceRuntimeService.getConversationPath( + conversation.uri.rawRingId, transfer.storagePath + ) + FileUtils.moveFile(tmpPath, path) + } + } + } + transfer.status = transferStatus + transfer.bytesProgress = progress + if (!conversation!!.isSwarm) { + mHistoryService.updateInteraction(transfer, account.accountID).subscribe() + } + } + Log.d(TAG, "Data Transfer dataTransferSubject.onNext") + dataTransfers.onNext(transfer) + } + + fun observeDataTransfer(transfer: DataTransfer): Observable<DataTransfer?> { + return dataTransfers + .filter { t: DataTransfer? -> t === transfer } + .startWithItem(transfer) + } + + fun setProxyEnabled(enabled: Boolean) { + mExecutor.execute { + synchronized(mAccountList) { + for (acc in mAccountList) { + if (acc.isJami && acc.isDhtProxyEnabled != enabled) { + Log.d(TAG, (if (enabled) "Enabling" else "Disabling") + " proxy for account " + acc.accountID) + acc.isDhtProxyEnabled = enabled + val details = JamiService.getAccountDetails(acc.accountID) + details[ConfigKey.PROXY_ENABLED.key()] = if (enabled) "true" else "false" + JamiService.setAccountDetails(acc.accountID, details) + } + } + } + } + } + + companion object { + private val TAG = AccountService::class.java.simpleName + private const val VCARD_CHUNK_SIZE = 1000 + private const val DATA_TRANSFER_REFRESH_PERIOD: Long = 500 + private const val PIN_GENERATION_SUCCESS = 0 + private const val PIN_GENERATION_WRONG_PASSWORD = 1 + private const val PIN_GENERATION_NETWORK_ERROR = 2 + private fun findAccount(accounts: List<Account?>, accountId: String): Account? { + for (account in accounts) if (accountId == account!!.accountID) return account + return null + } + + private fun getDataTransferEventCode(eventCode: Int): InteractionStatus { + var dataTransferEventCode = InteractionStatus.INVALID + try { + dataTransferEventCode = InteractionStatus.fromIntFile(eventCode) + } catch (ignored: ArrayIndexOutOfBoundsException) { + Log.e(TAG, "getEventCode: invalid data transfer status from daemon") + } + return dataTransferEventCode + } + + private fun getDataTransferError(errorCode: Long?): DataTransferError { + if (errorCode == null) { + Log.e(TAG, "getDataTransferError: invalid error code") + } else { + try { + return DataTransferError.values()[errorCode.toInt()] + } catch (ignored: ArrayIndexOutOfBoundsException) { + Log.e(TAG, "getDataTransferError: invalid data transfer error from daemon") + } + } + return DataTransferError.UNKNOWN + } + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/services/CallService.java b/ring-android/libringclient/src/main/java/net/jami/services/CallService.java deleted file mode 100644 index 875d0efb0..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/services/CallService.java +++ /dev/null @@ -1,837 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.services; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ScheduledExecutorService; - -import javax.inject.Inject; -import javax.inject.Named; - -import net.jami.daemon.Blob; -import net.jami.daemon.JamiService; -import net.jami.daemon.StringMap; -import net.jami.daemon.StringVect; -import net.jami.model.Account; -import net.jami.model.Contact; -import net.jami.model.Conference; -import net.jami.model.Conversation; -import net.jami.model.Call; -import net.jami.model.Uri; -import net.jami.utils.Log; -import net.jami.utils.StringUtils; -import ezvcard.VCard; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.PublishSubject; - -public class CallService { - - private final static String TAG = CallService.class.getSimpleName(); - public final static String MIME_TEXT_PLAIN = "text/plain"; - public static final String MIME_GEOLOCATION = "application/geo"; - public static final String MEDIA_TYPE_AUDIO = "MEDIA_TYPE_AUDIO"; - public static final String MEDIA_TYPE_VIDEO = "MEDIA_TYPE_VIDEO"; - - @Inject - @Named("DaemonExecutor") - ScheduledExecutorService mExecutor; - - @Inject - ContactService mContactService; - - @Inject - HistoryService mHistoryService; - - @Inject - AccountService mAccountService; - - @Inject - DeviceRuntimeService mDeviceRuntimeService; - - private final Map<String, Call> currentCalls = new HashMap<>(); - private final Map<String, Conference> currentConferences = new HashMap<>(); - - private final PublishSubject<Call> callSubject = PublishSubject.create(); - private final PublishSubject<Conference> conferenceSubject = PublishSubject.create(); - - // private final Set<String> currentConnections = new HashSet<>(); - // private final BehaviorSubject<Integer> connectionSubject = BehaviorSubject.createDefault(0); - - public Observable<Conference> getConfsUpdates() { - return conferenceSubject; - } - - private Observable<Conference> getConfCallUpdates(final Conference conf) { - Log.w(TAG, "getConfCallUpdates " + conf.getConfId()); - - return conferenceSubject - .filter(c -> c == conf) - .startWithItem(conf) - .map(Conference::getParticipants) - .switchMap(list -> Observable.fromIterable(list) - .flatMap(call -> callSubject.filter(c -> c == call))) - .map(call -> conf) - .startWithItem(conf); - } - - public Observable<Conference> getConfUpdates(final String confId) { - Call call = getCurrentCallForId(confId); - return call == null ? Observable.error(new IllegalArgumentException()) : getConfUpdates(call); - /*Conference call = currentConferences.get(confId); - return call == null ? Observable.error(new IllegalArgumentException()) : conferenceSubject - .filter(c -> c.getId().equals(confId));//getConfUpdates(call);*/ - } - - /*public Observable<Boolean> getConnectionUpdates() { - return connectionSubject - .map(i -> i > 0) - .distinctUntilChanged(); - }*/ - - private void updateConnectionCount() { - //connectionSubject.onNext(currentConnections.size() - 2*currentCalls.size()); - } - - public void setIsComposing(String accountId, String uri, boolean isComposing) { - mExecutor.execute(() -> JamiService.setIsComposing(accountId, uri, isComposing)); - } - - public void onConferenceInfoUpdated(String confId, List<Map<String, String>> info) { - Log.w(TAG, "onConferenceInfoUpdated " + confId + " " + info); - Conference conference = getConference(confId); - boolean isModerator = false; - if (conference != null) { - List<Conference.ParticipantInfo> newInfo = new ArrayList<>(info.size()); - if (conference.isConference()) { - for (Map<String, String> i : info) { - Call call = conference.findCallByContact(Uri.fromString(i.get("uri"))); - if (call != null) { - Conference.ParticipantInfo confInfo = new Conference.ParticipantInfo(call, call.getContact(), i); - if (confInfo.isEmpty()) { - Log.w(TAG, "onConferenceInfoUpdated: ignoring empty entry " + i); - continue; - } - if (confInfo.contact.isUser() && confInfo.isModerator) { - isModerator = true; - } - newInfo.add(confInfo); - } else { - Log.w(TAG, "onConferenceInfoUpdated " + confId + " can't find call for " + i); - // TODO - } - } - } else { - Account account = mAccountService.getAccount(conference.getCall().getAccount()); - for (Map<String, String> i : info) { - Conference.ParticipantInfo confInfo = new Conference.ParticipantInfo(null, account.getContactFromCache(Uri.fromString(i.get("uri"))), i); - if (confInfo.isEmpty()) { - Log.w(TAG, "onConferenceInfoUpdated: ignoring empty entry " + i); - continue; - } - if (confInfo.contact.isUser() && confInfo.isModerator) { - isModerator = true; - } - newInfo.add(confInfo); - } - } - conference.setIsModerator(isModerator); - conference.setInfo(newInfo); - } else { - Log.w(TAG, "onConferenceInfoUpdated can't find conference" + confId); - } - } - - public void setConfMaximizedParticipant(String confId, Uri uri) { - mExecutor.execute(() -> { - JamiService.setActiveParticipant(confId, uri == null ? "" : uri.getRawRingId()); - JamiService.setConferenceLayout(confId, 1); - }); - } - - public void setConfGridLayout(String confId) { - mExecutor.execute(() -> JamiService.setConferenceLayout(confId, 0)); - } - - public void remoteRecordingChanged(String callId, Uri peerNumber, boolean state) { - Log.w(TAG, "remoteRecordingChanged " + callId + " " + peerNumber + " " + state); - Conference conference = getConference(callId); - Call call; - if (conference == null) { - call = getCurrentCallForId(callId); - if (call != null) { - conference = getConference(call); - } - } else { - call = conference.getFirstCall(); - } - Account account = call == null ? null : mAccountService.getAccount(call.getAccount()); - Contact contact = account == null ? null : account.getContactFromCache(peerNumber); - if (conference != null && contact != null) { - conference.setParticipantRecording(contact, state); - } - } - - private static class ConferenceEntity { - Conference conference; - ConferenceEntity(Conference conf) { - conference = conf; - } - } - - public Observable<Conference> getConfUpdates(final Call call) { - return getConfUpdates(getConference(call)); - } - private Observable<Conference> getConfUpdates(final Conference conference) { - Log.w(TAG, "getConfUpdates " + conference.getId()); - - ConferenceEntity conferenceEntity = new ConferenceEntity(conference); - return conferenceSubject - .startWithItem(conference) - .filter(conf -> { - Log.w(TAG, "getConfUpdates filter " + conf.getConfId() + " " + conf.getParticipants().size() + " (tracked " + conferenceEntity.conference.getConfId() + " " + conferenceEntity.conference.getParticipants().size() + ")"); - if (conf == conferenceEntity.conference) { - return true; - } - if (conf.contains(conferenceEntity.conference.getId())) { - Log.w(TAG, "Switching tracked conference (up) to " + conf.getId()); - conferenceEntity.conference = conf; - return true; - } - if (conferenceEntity.conference.getParticipants().size() == 1 - && conf.getParticipants().size() == 1 - && conferenceEntity.conference.getCall() == conf.getCall() - && conf.getCall().getDaemonIdString().equals(conf.getConfId())) { - Log.w(TAG, "Switching tracked conference (down) to " + conf.getId()); - conferenceEntity.conference = conf; - return true; - } - return false; - }) - .switchMap(this::getConfCallUpdates); - } - - public Observable<Call> getCallsUpdates() { - return callSubject; - } - private Observable<Call> getCallUpdates(final Call call) { - return callSubject.filter(c -> c == call) - .startWithItem(call) - .takeWhile(c -> c.getCallStatus() != Call.CallStatus.OVER); - } - /*public Observable<SipCall> getCallUpdates(final String callId) { - SipCall call = getCurrentCallForId(callId); - return call == null ? Observable.error(new IllegalArgumentException()) : getCallUpdates(call); - }*/ - - public Observable<Call> placeCallObservable(final String accountId, final Uri conversationUri, final Uri number, final boolean audioOnly) { - return placeCall(accountId, conversationUri, number, audioOnly) - .flatMapObservable(this::getCallUpdates); - } - - public Single<Call> placeCall(final String account, final Uri conversationUri, final Uri number, final boolean audioOnly) { - return Single.fromCallable(() -> { - Log.i(TAG, "placeCall() thread running... " + number + " audioOnly: " + audioOnly); - - HashMap<String, String> volatileDetails = new HashMap<>(); - volatileDetails.put(Call.KEY_AUDIO_ONLY, String.valueOf(audioOnly)); - - String callId = JamiService.placeCall(account, number.getUri(), StringMap.toSwig(volatileDetails)); - if (callId == null || callId.isEmpty()) - return null; - if (audioOnly) { - JamiService.muteLocalMedia(callId, "MEDIA_TYPE_VIDEO", true); - } - Call call = addCall(account, callId, number, Call.Direction.OUTGOING); - if (conversationUri != null && conversationUri.isSwarm()) - call.setSwarmInfo(conversationUri.getRawRingId()); - call.muteVideo(audioOnly); - updateConnectionCount(); - return call; - }).subscribeOn(Schedulers.from(mExecutor)); - } - - public void refuse(final String callId) { - mExecutor.execute(() -> { - Log.i(TAG, "refuse() running... " + callId); - JamiService.refuse(callId); - JamiService.hangUp(callId); - }); - } - - public void accept(final String callId) { - mExecutor.execute(() -> { - Log.i(TAG, "accept() running... " + callId); - JamiService.muteCapture(false); - JamiService.accept(callId); - }); - } - - public void hangUp(final String callId) { - mExecutor.execute(() -> { - Log.i(TAG, "hangUp() running... " + callId); - JamiService.hangUp(callId); - }); - } - - public void muteParticipant(String confId, String peerId, boolean mute) { - mExecutor.execute(() -> { - Log.i(TAG, "mute participant... " + peerId); - JamiService.muteParticipant(confId, peerId, mute); - }); - } - - public void hangupParticipant(String confId, String peerId) { - mExecutor.execute(() -> { - Log.i(TAG, "hangup participant... " + peerId); - JamiService.hangupParticipant(confId, peerId); - }); - } - - public void hold(final String callId) { - mExecutor.execute(() -> { - Log.i(TAG, "hold() running... " + callId); - JamiService.hold(callId); - }); - } - - public void unhold(final String callId) { - mExecutor.execute(() -> { - Log.i(TAG, "unhold() running... " + callId); - JamiService.unhold(callId); - }); - } - - public Map<String, String> getCallDetails(final String callId) { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "getCallDetails() running... " + callId); - return JamiService.getCallDetails(callId).toNative(); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getCallDetails()", e); - } - return null; - } - - public void muteRingTone(boolean mute) { - Log.d(TAG, (mute ? "Muting." : "Unmuting.") + " ringtone."); - JamiService.muteRingtone(mute); - } - - public void restartAudioLayer() { - mExecutor.execute(() -> { - Log.i(TAG, "restartAudioLayer() running..."); - JamiService.setAudioPlugin(JamiService.getCurrentAudioOutputPlugin()); - }); - } - - public void setAudioPlugin(final String audioPlugin) { - mExecutor.execute(() -> { - Log.i(TAG, "setAudioPlugin() running..."); - JamiService.setAudioPlugin(audioPlugin); - }); - } - - public String getCurrentAudioOutputPlugin() { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "getCurrentAudioOutputPlugin() running..."); - return JamiService.getCurrentAudioOutputPlugin(); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getCallDetails()", e); - } - return null; - } - - public void playDtmf(final String key) { - mExecutor.execute(() -> { - Log.i(TAG, "playDTMF() running..."); - JamiService.playDTMF(key); - }); - } - - public void setMuted(final boolean mute) { - mExecutor.execute(() -> { - Log.i(TAG, "muteCapture() running..."); - JamiService.muteCapture(mute); - }); - } - - public void setLocalMediaMuted(final String callId, String mediaType, final boolean mute) { - mExecutor.execute(() -> { - Log.i(TAG, "muteCapture() running..."); - JamiService.muteLocalMedia(callId, mediaType, mute); - }); - } - - public boolean isCaptureMuted() { - return JamiService.isCaptureMuted(); - } - - public void transfer(final String callId, final String to) { - mExecutor.execute(() -> { - Log.i(TAG, "transfer() thread running..."); - if (JamiService.transfer(callId, to)) { - Log.i(TAG, "OK"); - } else { - Log.i(TAG, "NOT OK"); - } - }); - } - - public void attendedTransfer(final String transferId, final String targetID) { - mExecutor.execute(() -> { - Log.i(TAG, "attendedTransfer() thread running..."); - if (JamiService.attendedTransfer(transferId, targetID)) { - Log.i(TAG, "OK"); - } else { - Log.i(TAG, "NOT OK"); - } - }); - } - - public String getRecordPath() { - try { - return mExecutor.submit(JamiService::getRecordPath).get(); - } catch (Exception e) { - Log.e(TAG, "Error running isCaptureMuted()", e); - } - return null; - } - - public boolean toggleRecordingCall(final String id) { - mExecutor.execute(() -> JamiService.toggleRecording(id)); - return false; - } - - public boolean startRecordedFilePlayback(final String filepath) { - mExecutor.execute(() -> JamiService.startRecordedFilePlayback(filepath)); - return false; - } - - public void stopRecordedFilePlayback() { - mExecutor.execute(JamiService::stopRecordedFilePlayback); - } - - public void setRecordPath(final String path) { - mExecutor.execute(() -> JamiService.setRecordPath(path)); - } - - public void sendTextMessage(final String callId, final String msg) { - mExecutor.execute(() -> { - Log.i(TAG, "sendTextMessage() thread running..."); - StringMap messages = new StringMap(); - messages.setRaw("text/plain", Blob.fromString(msg)); - JamiService.sendTextMessage(callId, messages, "", false); - }); - } - - public Single<Long> sendAccountTextMessage(final String accountId, final String to, final String msg) { - return Single.fromCallable(() -> { - Log.i(TAG, "sendAccountTextMessage() running... " + accountId + " " + to + " " + msg); - StringMap msgs = new StringMap(); - msgs.setRaw("text/plain", Blob.fromString(msg)); - return JamiService.sendAccountTextMessage(accountId, to, msgs); - }).subscribeOn(Schedulers.from(mExecutor)); - } - - public Completable cancelMessage(final String accountId, final long messageID) { - return Completable.fromAction(() -> { - Log.i(TAG, "CancelMessage() running... Account ID: " + accountId + " " + "Message ID " + " " + messageID); - JamiService.cancelMessage(accountId, messageID); - }).subscribeOn(Schedulers.from(mExecutor)); - } - - private Call getCurrentCallForId(String callId) { - return currentCalls.get(callId); - } - - /*public Call getCurrentCallForContactId(String contactId) { - for (Call call : currentCalls.values()) { - if (contactId.contains(call.getContact().getPrimaryNumber())) { - return call; - } - } - return null; - }*/ - - public void removeCallForId(String callId) { - synchronized (currentCalls) { - currentCalls.remove(callId); - currentConferences.remove(callId); - } - } - - private Call addCall(String accountId, String callId, Uri from, Call.Direction direction) { - synchronized (currentCalls) { - Call call = currentCalls.get(callId); - if (call == null) { - Account account = mAccountService.getAccount(accountId); - Contact contact = mContactService.findContact(account, from); - Uri conversationUri = contact.getConversationUri().blockingFirst(); - Conversation conversation = conversationUri.equals(from) ? account.getByUri(from) : account.getSwarm(conversationUri.getRawRingId()); - call = new Call(callId, from.getUri(), accountId, conversation, contact, direction); - currentCalls.put(callId, call); - } else { - Log.w(TAG, "Call already existed ! " + callId + " " + from); - } - return call; - } - } - - private Conference addConference(Call call) { - String confId = call.getConfId(); - if (confId == null) { - confId = call.getDaemonIdString(); - } - Conference conference = currentConferences.get(confId); - if (conference == null) { - conference = new Conference(call); - currentConferences.put(confId, conference); - conferenceSubject.onNext(conference); - } - return conference; - } - - private Call parseCallState(String callId, String newState) { - Call.CallStatus callState = Call.CallStatus.fromString(newState); - Call call = currentCalls.get(callId); - if (call != null) { - call.setCallState(callState); - call.setDetails(JamiService.getCallDetails(callId).toNative()); - } else if (callState != Call.CallStatus.OVER && callState != Call.CallStatus.FAILURE) { - Map<String, String> callDetails = JamiService.getCallDetails(callId); - call = new Call(callId, callDetails); - if (StringUtils.isEmpty(call.getContactNumber())) { - Log.w(TAG, "No number"); - return null; - } - - call.setCallState(callState); - Account account = mAccountService.getAccount(call.getAccount()); - - Contact contact = mContactService.findContact(account, Uri.fromString(call.getContactNumber())); - String registeredName = callDetails.get(Call.KEY_REGISTERED_NAME); - if (registeredName != null && !registeredName.isEmpty()) { - contact.setUsername(registeredName); - } - - Conversation conversation = account.getByUri(contact.getConversationUri().blockingFirst()); - call.setContact(contact); - call.setConversation(conversation); - Log.w(TAG, "parseCallState " + contact + " " + contact.getConversationUri().blockingFirst() + " " + conversation + " " + conversation.getParticipant()); - - currentCalls.put(callId, call); - updateConnectionCount(); - } - return call; - } - - public void connectionUpdate(String id, int state) { - // Log.d(TAG, "connectionUpdate: " + id + " " + state); - /*switch(state) { - case 0: - currentConnections.add(id); - break; - case 1: - case 2: - currentConnections.remove(id); - break; - } - updateConnectionCount();*/ - } - - void callStateChanged(String callId, String newState, int detailCode) { - Log.d(TAG, "call state changed: " + callId + ", " + newState + ", " + detailCode); - try { - synchronized (currentCalls) { - Call call = parseCallState(callId, newState); - if (call != null) { - callSubject.onNext(call); - if (call.getCallStatus() == Call.CallStatus.OVER) { - currentCalls.remove(call.getDaemonIdString()); - currentConferences.remove(call.getDaemonIdString()); - updateConnectionCount(); - } - } - } - } catch (Exception e) { - Log.w(TAG, "Exception during state change: ", e); - } - } - - void incomingCall(String accountId, String callId, String from) { - Log.d(TAG, "incoming call: " + accountId + ", " + callId + ", " + from); - - Call call = addCall(accountId, callId, Uri.fromStringWithName(from).first, Call.Direction.INCOMING); - callSubject.onNext(call); - updateConnectionCount(); - } - - public void incomingMessage(String callId, String from, Map<String, String> messages) { - Call call = currentCalls.get(callId); - if (call == null || messages == null) { - Log.w(TAG, "incomingMessage: unknown call or no message: " + callId + " " + from); - return; - } - VCard vcard = call.appendToVCard(messages); - if (vcard != null) { - mContactService.saveVCardContactData(call.getContact(), call.getAccount(), vcard); - } - if (messages.containsKey(MIME_TEXT_PLAIN)) { - mAccountService.incomingAccountMessage(call.getAccount(), null, callId, from, messages); - } - } - - void recordPlaybackFilepath(String id, String filename) { - Log.d(TAG, "record playback filepath: " + id + ", " + filename); - // todo needs more explainations on that - } - - void onRtcpReportReceived(String callId) { - Log.i(TAG, "on RTCP report received: " + callId); - } - - public void removeConference(final String confId) { - mExecutor.execute(() -> JamiService.removeConference(confId)); - } - - public Single<Boolean> joinParticipant(final String selCallId, final String dragCallId) { - return Single.fromCallable(() -> JamiService.joinParticipant(selCallId, dragCallId)) - .subscribeOn(Schedulers.from(mExecutor)); - } - - public void addParticipant(final String callId, final String confId) { - mExecutor.execute(() -> JamiService.addParticipant(callId, confId)); - } - - public void addMainParticipant(final String confId) { - mExecutor.execute(() -> JamiService.addMainParticipant(confId)); - } - - public void detachParticipant(final String callId) { - mExecutor.execute(() -> JamiService.detachParticipant(callId)); - } - - public void joinConference(final String selConfId, final String dragConfId) { - mExecutor.execute(() -> JamiService.joinConference(selConfId, dragConfId)); - } - - public void hangUpConference(final String confId) { - mExecutor.execute(() -> JamiService.hangUpConference(confId)); - } - - public void holdConference(final String confId) { - mExecutor.execute(() -> JamiService.holdConference(confId)); - } - - public void unholdConference(final String confId) { - mExecutor.execute(() -> JamiService.unholdConference(confId)); - } - - public boolean isConferenceParticipant(final String callId) { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "isConferenceParticipant() running..."); - return JamiService.isConferenceParticipant(callId); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running isConferenceParticipant()", e); - } - return false; - } - - public Map<String, ArrayList<String>> getConferenceList() { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "getConferenceList() running..."); - StringVect callIds = JamiService.getCallList(); - HashMap<String, ArrayList<String>> confs = new HashMap<>(callIds.size()); - for (int i = 0; i < callIds.size(); i++) { - String callId = callIds.get(i); - String confId = JamiService.getConferenceId(callId); - Map<String, String> callDetails = JamiService.getCallDetails(callId).toNative(); - - //todo remove condition when callDetails does not contains sips ids anymore - if (!callDetails.get("PEER_NUMBER").contains("sips")) { - if (confId == null || confId.isEmpty()) { - confId = callId; - } - ArrayList<String> calls = confs.get(confId); - if (calls == null) { - calls = new ArrayList<>(); - confs.put(confId, calls); - } - calls.add(callId); - } - } - return confs; - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running isConferenceParticipant()", e); - } - return null; - } - - public List<String> getParticipantList(final String confId) { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "getParticipantList() running..."); - return new ArrayList<>(JamiService.getParticipantList(confId)); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getParticipantList()", e); - } - return null; - } - - public Conference getConference(Call call) { - return addConference(call); - } - - public String getConferenceId(String callId) { - return JamiService.getConferenceId(callId); - } - - public String getConferenceState(final String callId) { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "getConferenceDetails() thread running..."); - return JamiService.getConferenceDetails(callId).get("CONF_STATE"); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getParticipantList()", e); - } - return null; - } - - public Conference getConference(final String id) { - return currentConferences.get(id); - } - - public Map<String, String> getConferenceDetails(final String id) { - try { - return mExecutor.submit(() -> { - Log.i(TAG, "getCredentials() thread running..."); - return JamiService.getConferenceDetails(id).toNative(); - }).get(); - } catch (Exception e) { - Log.e(TAG, "Error running getParticipantList()", e); - } - return null; - } - - void conferenceCreated(final String confId) { - Log.d(TAG, "conference created: " + confId); - - Conference conf = currentConferences.get(confId); - if (conf == null) { - conf = new Conference(confId); - currentConferences.put(confId, conf); - } - StringVect participants = JamiService.getParticipantList(confId); - StringMap map = JamiService.getConferenceDetails(confId); - conf.setState(map.get("STATE")); - for (String callId : participants) { - Call call = getCurrentCallForId(callId); - if (call != null) { - Log.d(TAG, "conference created: adding participant " + callId + " " + call.getContact().getDisplayName()); - call.setConfId(confId); - conf.addParticipant(call); - } - Conference rconf = currentConferences.remove(callId); - Log.d(TAG, "conference created: removing conference " + callId + " " + rconf + " now " + currentConferences.size()); - } - conferenceSubject.onNext(conf); - } - - void conferenceRemoved(String confId) { - Log.d(TAG, "conference removed: " + confId); - - Conference conf = currentConferences.remove(confId); - if (conf != null) { - for (Call call : conf.getParticipants()) { - call.setConfId(null); - } - conf.removeParticipants(); - conferenceSubject.onNext(conf); - } - } - - void conferenceChanged(String confId, String state) { - Log.d(TAG, "conference changed: " + confId + ", " + state); - try { - Conference conf = currentConferences.get(confId); - if (conf == null) { - conf = new Conference(confId); - currentConferences.put(confId, conf); - } - conf.setState(state); - Set<String> participants = new HashSet<>(JamiService.getParticipantList(confId)); - // Add new participants - for (String callId : participants) { - if (!conf.contains(callId)) { - Call call = getCurrentCallForId(callId); - if (call != null) { - Log.d(TAG, "conference changed: adding participant " + callId + " " + call.getContact().getDisplayName()); - call.setConfId(confId); - conf.addParticipant(call); - } - currentConferences.remove(callId); - } - } - - // Remove participants - List<Call> calls = conf.getParticipants(); - Iterator<Call> i = calls.iterator(); - boolean removed = false; - while (i.hasNext()) { - Call call = i.next(); - if (!participants.contains(call.getDaemonIdString())) { - Log.d(TAG, "conference changed: removing participant " + call.getDaemonIdString() + " " + call.getContact().getDisplayName()); - call.setConfId(null); - i.remove(); - removed = true; - } - } - - conferenceSubject.onNext(conf); - - if (removed && conf.getParticipants().size() == 1 && conf.getConfId() != null) { - Call call = conf.getCall(); - call.setConfId(null); - addConference(call); - } - } catch (Exception e) { - Log.w(TAG, "exception in conferenceChanged", e); - } - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/services/CallService.kt b/ring-android/libringclient/src/main/java/net/jami/services/CallService.kt new file mode 100644 index 000000000..485098353 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/services/CallService.kt @@ -0,0 +1,787 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.services + +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.PublishSubject +import net.jami.daemon.Blob +import net.jami.daemon.JamiService +import net.jami.daemon.StringMap +import net.jami.model.Call +import net.jami.model.Call.CallStatus +import net.jami.model.Conference +import net.jami.model.Conference.ParticipantInfo +import net.jami.model.Uri +import net.jami.model.Uri.Companion.fromString +import net.jami.model.Uri.Companion.fromStringWithName +import net.jami.utils.Log +import net.jami.utils.StringUtils.isEmpty +import java.util.* +import java.util.concurrent.Callable +import java.util.concurrent.ScheduledExecutorService + +class CallService( + private val mExecutor: ScheduledExecutorService, + private val mContactService: ContactService, + private val mAccountService: AccountService +) { + private val currentCalls: MutableMap<String, Call> = HashMap() + private val currentConferences: MutableMap<String, Conference> = HashMap() + private val callSubject = PublishSubject.create<Call>() + private val conferenceSubject = PublishSubject.create<Conference>() + + // private final Set<String> currentConnections = new HashSet<>(); + // private final BehaviorSubject<Integer> connectionSubject = BehaviorSubject.createDefault(0); + val confsUpdates: Observable<Conference> + get() = conferenceSubject + + private fun getConfCallUpdates(conf: Conference): Observable<Conference> { + Log.w(TAG, "getConfCallUpdates " + conf.id) + return conferenceSubject + .filter { c -> c == conf } + .startWithItem(conf) + .map(Conference::participants) + .switchMap { list: List<Call> -> Observable.fromIterable(list) + .flatMap { call: Call -> callSubject.filter { c -> c == call } } } + .map { conf } + .startWithItem(conf) + } + + fun getConfUpdates(confId: String): Observable<Conference> { + return getCurrentCallForId(confId)?.let { getConfUpdates(it) } + ?: Observable.error(IllegalArgumentException()) + /*Conference call = currentConferences.get(confId); + return call == null ? Observable.error(new IllegalArgumentException()) : conferenceSubject + .filter(c -> c.getId().equals(confId));//getConfUpdates(call);*/ + } + + /*public Observable<Boolean> getConnectionUpdates() { + return connectionSubject + .map(i -> i > 0) + .distinctUntilChanged(); + }*/ + private fun updateConnectionCount() { + //connectionSubject.onNext(currentConnections.size() - 2*currentCalls.size()); + } + + fun setIsComposing(accountId: String?, uri: String?, isComposing: Boolean) { + mExecutor.execute { JamiService.setIsComposing(accountId, uri, isComposing) } + } + + fun onConferenceInfoUpdated(confId: String, info: List<Map<String, String>>) { + Log.w(TAG, "onConferenceInfoUpdated $confId $info") + val conference = getConference(confId) + var isModerator = false + if (conference != null) { + val newInfo: MutableList<ParticipantInfo> = ArrayList(info.size) + if (conference.isConference) { + for (i in info) { + val call = conference.findCallByContact(fromString(i["uri"]!!)) + if (call != null) { + val confInfo = ParticipantInfo(call, call.contact!!, i) + if (confInfo.isEmpty) { + Log.w(TAG, "onConferenceInfoUpdated: ignoring empty entry $i") + continue + } + if (confInfo.contact.isUser && confInfo.isModerator) { + isModerator = true + } + newInfo.add(confInfo) + } else { + Log.w(TAG, "onConferenceInfoUpdated $confId can't find call for $i") + // TODO + } + } + } else { + val account = mAccountService.getAccount(conference.call!!.account!!)!! + for (i in info) { + val confInfo = ParticipantInfo(null, account.getContactFromCache(fromString(i["uri"]!!)), i) + if (confInfo.isEmpty) { + Log.w(TAG, "onConferenceInfoUpdated: ignoring empty entry $i") + continue + } + if (confInfo.contact.isUser && confInfo.isModerator) { + isModerator = true + } + newInfo.add(confInfo) + } + } + conference.isModerator = isModerator + conference.setInfo(newInfo) + } else { + Log.w(TAG, "onConferenceInfoUpdated can't find conference$confId") + } + } + + fun setConfMaximizedParticipant(confId: String, uri: Uri) { + mExecutor.execute { + JamiService.setActiveParticipant(confId, uri?.rawRingId ?: "") + JamiService.setConferenceLayout(confId, 1) + } + } + + fun setConfGridLayout(confId: String?) { + mExecutor.execute { JamiService.setConferenceLayout(confId, 0) } + } + + fun remoteRecordingChanged(callId: String, peerNumber: Uri, state: Boolean) { + Log.w(TAG, "remoteRecordingChanged $callId $peerNumber $state") + var conference = getConference(callId) + val call: Call? + if (conference == null) { + call = getCurrentCallForId(callId) + if (call != null) { + conference = getConference(call) + } + } else { + call = conference.firstCall + } + val account = if (call == null) null else mAccountService.getAccount(call.account!!) + val contact = account?.getContactFromCache(peerNumber) + if (conference != null && contact != null) { + conference.setParticipantRecording(contact, state) + } + } + + private class ConferenceEntity internal constructor(var conference: Conference) + + fun getConfUpdates(call: Call): Observable<Conference> { + return getConfUpdates(getConference(call)) + } + + private fun getConfUpdates(conference: Conference): Observable<Conference> { + Log.w(TAG, "getConfUpdates " + conference.id) + val conferenceEntity = ConferenceEntity(conference) + return conferenceSubject + .startWithItem(conference) + .filter { conf: Conference -> + Log.w(TAG, "getConfUpdates filter " + conf.id + " " + conf.participants.size + " (tracked " + conferenceEntity.conference.id + " " + conferenceEntity.conference.participants.size + ")") + if (conf == conferenceEntity.conference) { + return@filter true + } + if (conf.contains(conferenceEntity.conference.id)) { + Log.w(TAG, "Switching tracked conference (up) to " + conf.id) + conferenceEntity.conference = conf + return@filter true + } + if (conferenceEntity.conference.participants.size == 1 && conf.participants.size == 1 && conferenceEntity.conference.call == conf.call && conf.call!!.daemonIdString == conf.id) { + Log.w(TAG, "Switching tracked conference (down) to " + conf.id) + conferenceEntity.conference = conf + return@filter true + } + false + } + .switchMap { conf: Conference -> getConfCallUpdates(conf) } + } + + val callsUpdates: Observable<Call> + get() = callSubject + + private fun getCallUpdates(call: Call): Observable<Call> { + return callSubject.filter { c: Call -> c == call } + .startWithItem(call) + .takeWhile { c: Call -> c.callStatus !== CallStatus.OVER } + } + + /*public Observable<SipCall> getCallUpdates(final String callId) { + SipCall call = getCurrentCallForId(callId); + return call == null ? Observable.error(new IllegalArgumentException()) : getCallUpdates(call); + }*/ + fun placeCallObservable(accountId: String, conversationUri: Uri?, number: Uri, audioOnly: Boolean): Observable<Call> { + return placeCall(accountId, conversationUri, number, audioOnly) + .flatMapObservable { call: Call -> getCallUpdates(call) } + } + + fun placeCall(account: String, conversationUri: Uri?, number: Uri, audioOnly: Boolean): Single<Call> { + return Single.fromCallable<Call> { + Log.i(TAG, "placeCall() thread running... $number audioOnly: $audioOnly") + val volatileDetails = HashMap<String, String>() + volatileDetails[Call.KEY_AUDIO_ONLY] = audioOnly.toString() + val callId = JamiService.placeCall(account, number.uri, StringMap.toSwig(volatileDetails)) + if (callId == null || callId.isEmpty()) return@fromCallable null + if (audioOnly) { + JamiService.muteLocalMedia(callId, "MEDIA_TYPE_VIDEO", true) + } + val call = addCall(account, callId, number, Call.Direction.OUTGOING) + if (conversationUri != null && conversationUri.isSwarm) call.setSwarmInfo(conversationUri.rawRingId) + call.muteVideo(audioOnly) + updateConnectionCount() + call + }.subscribeOn(Schedulers.from(mExecutor)) + } + + fun refuse(callId: String) { + mExecutor.execute { + Log.i(TAG, "refuse() running... $callId") + JamiService.refuse(callId) + JamiService.hangUp(callId) + } + } + + fun accept(callId: String) { + mExecutor.execute { + Log.i(TAG, "accept() running... $callId") + JamiService.muteCapture(false) + JamiService.accept(callId) + } + } + + fun hangUp(callId: String) { + mExecutor.execute { + Log.i(TAG, "hangUp() running... $callId") + JamiService.hangUp(callId) + } + } + + fun muteParticipant(confId: String, peerId: String, mute: Boolean) { + mExecutor.execute { + Log.i(TAG, "mute participant... $peerId") + JamiService.muteParticipant(confId, peerId, mute) + } + } + + fun hangupParticipant(confId: String?, peerId: String) { + mExecutor.execute { + Log.i(TAG, "hangup participant... $peerId") + JamiService.hangupParticipant(confId, peerId) + } + } + + fun hold(callId: String) { + mExecutor.execute { + Log.i(TAG, "hold() running... $callId") + JamiService.hold(callId) + } + } + + fun unhold(callId: String) { + mExecutor.execute { + Log.i(TAG, "unhold() running... $callId") + JamiService.unhold(callId) + } + } + + fun getCallDetails(callId: String): Map<String, String>? { + try { + return mExecutor.submit<HashMap<String, String>> { + Log.i(TAG, "getCallDetails() running... $callId") + JamiService.getCallDetails(callId).toNative() + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running getCallDetails()", e) + } + return null + } + + fun muteRingTone(mute: Boolean) { + Log.d(TAG, (if (mute) "Muting." else "Unmuting.") + " ringtone.") + JamiService.muteRingtone(mute) + } + + fun restartAudioLayer() { + mExecutor.execute { + Log.i(TAG, "restartAudioLayer() running...") + JamiService.setAudioPlugin(JamiService.getCurrentAudioOutputPlugin()) + } + } + + fun setAudioPlugin(audioPlugin: String) { + mExecutor.execute { + Log.i(TAG, "setAudioPlugin() running...") + JamiService.setAudioPlugin(audioPlugin) + } + } + + val currentAudioOutputPlugin: String? + get() { + try { + return mExecutor.submit<String> { + Log.i(TAG, "getCurrentAudioOutputPlugin() running...") + JamiService.getCurrentAudioOutputPlugin() + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running getCallDetails()", e) + } + return null + } + + fun playDtmf(key: String) { + mExecutor.execute { + Log.i(TAG, "playDTMF() running...") + JamiService.playDTMF(key) + } + } + + fun setMuted(mute: Boolean) { + mExecutor.execute { + Log.i(TAG, "muteCapture() running...") + JamiService.muteCapture(mute) + } + } + + fun setLocalMediaMuted(callId: String, mediaType: String, mute: Boolean) { + mExecutor.execute { + Log.i(TAG, "muteCapture() running...") + JamiService.muteLocalMedia(callId, mediaType, mute) + } + } + + val isCaptureMuted: Boolean + get() = JamiService.isCaptureMuted() + + fun transfer(callId: String, to: String) { + mExecutor.execute { + Log.i(TAG, "transfer() thread running...") + if (JamiService.transfer(callId, to)) { + Log.i(TAG, "OK") + } else { + Log.i(TAG, "NOT OK") + } + } + } + + fun attendedTransfer(transferId: String, targetID: String) { + mExecutor.execute { + Log.i(TAG, "attendedTransfer() thread running...") + if (JamiService.attendedTransfer(transferId, targetID)) { + Log.i(TAG, "OK") + } else { + Log.i(TAG, "NOT OK") + } + } + } + + var recordPath: String? + get() { + try { + return mExecutor.submit<String> { JamiService.getRecordPath() }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running isCaptureMuted()", e) + } + return null + } + set(path) { + mExecutor.execute { JamiService.setRecordPath(path) } + } + + fun toggleRecordingCall(id: String?): Boolean { + mExecutor.execute { JamiService.toggleRecording(id) } + return false + } + + fun startRecordedFilePlayback(filepath: String): Boolean { + mExecutor.execute { JamiService.startRecordedFilePlayback(filepath) } + return false + } + + fun stopRecordedFilePlayback() { + mExecutor.execute { JamiService.stopRecordedFilePlayback() } + } + + fun sendTextMessage(callId: String, msg: String?) { + mExecutor.execute { + Log.i(TAG, "sendTextMessage() thread running...") + val messages = StringMap() + messages.setRaw("text/plain", Blob.fromString(msg)) + JamiService.sendTextMessage(callId, messages, "", false) + } + } + + fun sendAccountTextMessage(accountId: String, to: String, msg: String): Single<Long> { + return Single.fromCallable { + Log.i(TAG, "sendAccountTextMessage() running... $accountId $to $msg") + val msgs = StringMap() + msgs.setRaw("text/plain", Blob.fromString(msg)) + JamiService.sendAccountTextMessage(accountId, to, msgs) + }.subscribeOn(Schedulers.from(mExecutor)) + } + + fun cancelMessage(accountId: String, messageID: Long): Completable { + return Completable.fromAction { + Log.i(TAG, "CancelMessage() running... Account ID: $accountId Message ID $messageID") + JamiService.cancelMessage(accountId, messageID) + }.subscribeOn(Schedulers.from(mExecutor)) + } + + private fun getCurrentCallForId(callId: String): Call? { + return currentCalls[callId] + } + + /*public Call getCurrentCallForContactId(String contactId) { + for (Call call : currentCalls.values()) { + if (contactId.contains(call.getContact().getPrimaryNumber())) { + return call; + } + } + return null; + }*/ + fun removeCallForId(callId: String) { + synchronized(currentCalls) { + currentCalls.remove(callId) + currentConferences.remove(callId) + } + } + + private fun addCall(accountId: String, callId: String, from: Uri, direction: Call.Direction): Call { + synchronized(currentCalls) { + var call = currentCalls[callId] + if (call == null) { + val account = mAccountService.getAccount(accountId)!! + val contact = mContactService.findContact(account, from) + val conversationUri = contact.conversationUri.blockingFirst() + val conversation = + if (conversationUri.equals(from)) account.getByUri(from) else account.getSwarm(conversationUri.rawRingId) + call = Call(callId, from.uri, accountId, conversation, contact, direction) + currentCalls[callId] = call + } else { + Log.w(TAG, "Call already existed ! $callId $from") + } + return call + } + } + + private fun addConference(call: Call): Conference { + val confId = call.confId ?: call.daemonIdString!! + var conference = currentConferences[confId] + if (conference == null) { + conference = Conference(call) + currentConferences[confId] = conference + conferenceSubject.onNext(conference) + } + return conference + } + + private fun parseCallState(callId: String, newState: String): Call? { + val callState = CallStatus.fromString(newState) + var call = currentCalls[callId] + if (call != null) { + call.setCallState(callState) + call.setDetails(JamiService.getCallDetails(callId).toNative()) + } else if (callState !== CallStatus.OVER && callState !== CallStatus.FAILURE) { + val callDetails: Map<String?, String> = JamiService.getCallDetails(callId) + call = Call(callId, callDetails) + if (isEmpty(call.contactNumber)) { + Log.w(TAG, "No number") + return null + } + call.setCallState(callState) + val account = mAccountService.getAccount(call.account!!)!! + val contact = mContactService.findContact(account, fromString(call.contactNumber!!)) + val registeredName = callDetails[Call.KEY_REGISTERED_NAME] + if (registeredName != null && !registeredName.isEmpty()) { + contact.setUsername(registeredName) + } + val conversation = account.getByUri(contact.conversationUri.blockingFirst()) + call.contact = contact + call.conversation = conversation + Log.w(TAG, "parseCallState " + contact + " " + contact.conversationUri.blockingFirst() + " " + conversation + " " + conversation!!.participant) + currentCalls[callId] = call + updateConnectionCount() + } + return call + } + + fun connectionUpdate(id: String?, state: Int) { + // Log.d(TAG, "connectionUpdate: " + id + " " + state); + /*switch(state) { + case 0: + currentConnections.add(id); + break; + case 1: + case 2: + currentConnections.remove(id); + break; + } + updateConnectionCount();*/ + } + + fun callStateChanged(callId: String, newState: String, detailCode: Int) { + Log.d(TAG, "call state changed: $callId, $newState, $detailCode") + try { + synchronized(currentCalls) { + parseCallState(callId, newState)?.let { call -> + callSubject.onNext(call) + if (call.callStatus === CallStatus.OVER) { + currentCalls.remove(call.daemonIdString) + currentConferences.remove(call.daemonIdString) + updateConnectionCount() + } + } + } + } catch (e: Exception) { + Log.w(TAG, "Exception during state change: ", e) + } + } + + fun incomingCall(accountId: String, callId: String, from: String) { + Log.d(TAG, "incoming call: $accountId, $callId, $from") + val call = addCall(accountId, callId, fromStringWithName(from).first, Call.Direction.INCOMING) + callSubject.onNext(call) + updateConnectionCount() + } + + fun incomingMessage(callId: String, from: String, messages: Map<String, String>) { + val call = currentCalls[callId] + if (call == null) { + Log.w(TAG, "incomingMessage: unknown call or no message: $callId $from") + return + } + call.appendToVCard(messages)?.let { vcard -> + mContactService.saveVCardContactData(call.contact!!, call.account!!, vcard) + } + if (messages.containsKey(MIME_TEXT_PLAIN)) { + mAccountService.incomingAccountMessage(call.account!!, null, callId, from, messages) + } + } + + fun recordPlaybackFilepath(id: String, filename: String) { + Log.d(TAG, "record playback filepath: $id, $filename") + // todo needs more explanations on that + } + + fun onRtcpReportReceived(callId: String) { + Log.i(TAG, "on RTCP report received: $callId") + } + + fun removeConference(confId: String) { + mExecutor.execute { JamiService.removeConference(confId) } + } + + fun joinParticipant(selCallId: String, dragCallId: String): Single<Boolean> { + return Single.fromCallable { JamiService.joinParticipant(selCallId, dragCallId) } + .subscribeOn(Schedulers.from(mExecutor)) + } + + fun addParticipant(callId: String, confId: String) { + mExecutor.execute { JamiService.addParticipant(callId, confId) } + } + + fun addMainParticipant(confId: String) { + mExecutor.execute { JamiService.addMainParticipant(confId) } + } + + fun detachParticipant(callId: String) { + mExecutor.execute { JamiService.detachParticipant(callId) } + } + + fun joinConference(selConfId: String, dragConfId: String) { + mExecutor.execute { JamiService.joinConference(selConfId, dragConfId) } + } + + fun hangUpConference(confId: String) { + mExecutor.execute { JamiService.hangUpConference(confId) } + } + + fun holdConference(confId: String) { + mExecutor.execute { JamiService.holdConference(confId) } + } + + fun unholdConference(confId: String) { + mExecutor.execute { JamiService.unholdConference(confId) } + } + + fun isConferenceParticipant(callId: String): Boolean { + try { + return mExecutor.submit<Boolean> { + Log.i(TAG, "isConferenceParticipant() running...") + JamiService.isConferenceParticipant(callId) + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running isConferenceParticipant()", e) + } + return false + } + + //todo remove condition when callDetails does not contains sips ids anymore + val conferenceList: Map<String, ArrayList<String>>? + get() { + try { + return mExecutor.submit(Callable { + Log.i(TAG, "getConferenceList() running...") + val callIds = JamiService.getCallList() + val confs = HashMap<String, ArrayList<String>>(callIds.size) + for (i in callIds.indices) { + val callId = callIds[i] + var confId = JamiService.getConferenceId(callId) + val callDetails: Map<String, String> = JamiService.getCallDetails(callId).toNative() + + //todo remove condition when callDetails does not contains sips ids anymore + if (!callDetails["PEER_NUMBER"]!!.contains("sips")) { + if (confId == null || confId.isEmpty()) { + confId = callId + } + var calls = confs[confId] + if (calls == null) { + calls = ArrayList() + confs[confId] = calls + } + calls.add(callId) + } + } + confs + }).get() + } catch (e: Exception) { + Log.e(TAG, "Error running isConferenceParticipant()", e) + } + return null + } + + fun getParticipantList(confId: String?): List<String>? { + try { + return mExecutor.submit<ArrayList<String>> { + Log.i(TAG, "getParticipantList() running...") + ArrayList(JamiService.getParticipantList(confId)) + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running getParticipantList()", e) + } + return null + } + + fun getConference(call: Call): Conference { + return addConference(call) + } + + fun getConferenceId(callId: String): String { + return JamiService.getConferenceId(callId) + } + + fun getConferenceState(callId: String): String? { + try { + return mExecutor.submit<String> { + Log.i(TAG, "getConferenceDetails() thread running...") + JamiService.getConferenceDetails(callId)["CONF_STATE"] + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running getParticipantList()", e) + } + return null + } + + fun getConference(id: String): Conference? { + return currentConferences[id] + } + + fun getConferenceDetails(id: String): Map<String, String>? { + try { + return mExecutor.submit<HashMap<String, String>> { + Log.i(TAG, "getCredentials() thread running...") + JamiService.getConferenceDetails(id).toNative() + }.get() + } catch (e: Exception) { + Log.e(TAG, "Error running getParticipantList()", e) + } + return null + } + + fun conferenceCreated(confId: String) { + Log.d(TAG, "conference created: $confId") + var conf = currentConferences[confId] + if (conf == null) { + conf = Conference(confId) + currentConferences[confId] = conf + } + val participants = JamiService.getParticipantList(confId) + val map = JamiService.getConferenceDetails(confId) + conf.setState(map["STATE"]) + for (callId in participants) { + val call = getCurrentCallForId(callId) + if (call != null) { + Log.d(TAG, "conference created: adding participant " + callId + " " + call.contact!!.displayName) + call.confId = confId + conf.addParticipant(call) + } + val rconf = currentConferences.remove(callId) + Log.d(TAG, "conference created: removing conference " + callId + " " + rconf + " now " + currentConferences.size) + } + conferenceSubject.onNext(conf) + } + + fun conferenceRemoved(confId: String) { + Log.d(TAG, "conference removed: $confId") + val conf = currentConferences.remove(confId) + if (conf != null) { + for (call in conf.participants) { + call.confId = null + } + conf.removeParticipants() + conferenceSubject.onNext(conf) + } + } + + fun conferenceChanged(confId: String, state: String) { + Log.d(TAG, "conference changed: $confId, $state") + try { + var conf = currentConferences[confId] + if (conf == null) { + conf = Conference(confId) + currentConferences[confId] = conf + } + conf.setState(state) + val participants: Set<String> = JamiService.getParticipantList(confId).toHashSet() + // Add new participants + for (callId in participants) { + if (!conf.contains(callId)) { + val call = getCurrentCallForId(callId) + if (call != null) { + Log.d(TAG, "conference changed: adding participant " + callId + " " + call.contact!!.displayName) + call.confId = confId + conf.addParticipant(call) + } + currentConferences.remove(callId) + } + } + + // Remove participants + val calls = conf.participants + var removed = false + val i = calls.iterator() + while (i.hasNext()) { + val call = i.next() + if (!participants.contains(call.daemonIdString)) { + Log.d(TAG, "conference changed: removing participant " + call.daemonIdString + " " + call.contact!!.displayName) + call.confId = null + i.remove() + removed = true + } + } + conferenceSubject.onNext(conf) + if (removed && conf.participants.size == 1) { + val call = conf.participants[0] + call.confId = null + addConference(call) + } + } catch (e: Exception) { + Log.w(TAG, "exception in conferenceChanged", e) + } + } + + companion object { + private val TAG = CallService::class.simpleName!! + const val MIME_TEXT_PLAIN = "text/plain" + const val MIME_GEOLOCATION = "application/geo" + const val MEDIA_TYPE_AUDIO = "MEDIA_TYPE_AUDIO" + const val MEDIA_TYPE_VIDEO = "MEDIA_TYPE_VIDEO" + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/services/ContactService.java b/ring-android/libringclient/src/main/java/net/jami/services/ContactService.java deleted file mode 100644 index 5762b1496..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/services/ContactService.java +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.services; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; - -import net.jami.model.Account; -import net.jami.model.Contact; -import net.jami.model.Settings; -import net.jami.model.Uri; -import net.jami.utils.Log; -import net.jami.utils.StringUtils; -import ezvcard.VCard; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; - -/** - * This service handles the contacts - * - Load the contacts stored in the system - * - Keep a local cache of the contacts - * - Provide query tools to search contacts by id, number, ... - */ -public abstract class ContactService { - private final static String TAG = ContactService.class.getSimpleName(); - - @Inject - PreferencesService mPreferencesService; - - @Inject - DeviceRuntimeService mDeviceRuntimeService; - - @Inject - AccountService mAccountService; - - public abstract Map<Long, Contact> loadContactsFromSystem(boolean loadRingContacts, boolean loadSipContacts); - - protected abstract Contact findContactByIdFromSystem(Long contactId, String contactKey); - protected abstract Contact findContactBySipNumberFromSystem(String number); - protected abstract Contact findContactByNumberFromSystem(String number); - - public abstract Completable loadContactData(Contact contact, String accountId); - - public abstract void saveVCardContactData(Contact contact, String accountId, VCard vcard); - public abstract Single<VCard> saveVCardContact(String accountId, String uri, String displayName, String pictureB64); - - public ContactService() {} - - /** - * Load contacts from system and generate a local contact cache - * - * @param loadRingContacts if true, ring contacts will be taken care of - * @param loadSipContacts if true, sip contacts will be taken care of - */ - public Single<Map<Long, Contact>> loadContacts(final boolean loadRingContacts, final boolean loadSipContacts, final Account account) { - return Single.fromCallable(() -> { - Settings settings = mPreferencesService.getSettings(); - if (settings.isAllowSystemContacts() && mDeviceRuntimeService.hasContactPermission()) { - return loadContactsFromSystem(loadRingContacts, loadSipContacts); - } - return new HashMap<>(); - }); - } - - public Observable<Contact> observeContact(String accountId, Contact contact, boolean withPresence) { - //Log.w(TAG, "observeContact " + accountId + " " + contact.getUri() + " " + contact.isUser()); - if (contact.isUser()) - withPresence = false; - Uri uri = contact.getUri(); - String uriString = uri.getRawUriString(); - synchronized (contact) { - if (contact.getPresenceUpdates() == null) { - contact.setPresenceUpdates(Observable.<Boolean>create(emitter -> { - emitter.onNext(false); - contact.setPresenceEmitter(emitter); - mAccountService.subscribeBuddy(accountId, uriString, true); - emitter.setCancellable(() -> { - mAccountService.subscribeBuddy(accountId, uriString, false); - contact.setPresenceEmitter(null); - emitter.onNext(false); - }); - }) - .replay(1) - .refCount(5, TimeUnit.SECONDS)); - } - - if (contact.getUpdates() == null) { - contact.setUpdates(contact.getUpdatesSubject() - .doOnSubscribe(d -> { - if (!contact.isUsernameLoaded()) - mAccountService.lookupAddress(accountId, "", uri.getRawRingId()); - loadContactData(contact, accountId) - .subscribe(() -> {}, e -> {/*Log.e(TAG, "Error loading contact data: " + e.getMessage())*/}); - }) - .filter(c -> c.isUsernameLoaded() && c.detailsLoaded) - .replay(1) - .refCount()); - } - - return withPresence - ? Observable.combineLatest(contact.getUpdates(), contact.getPresenceUpdates(), (c, p) -> { - //Log.w(TAG, "observeContact UPDATE " + c + " " + p); - return c; - }) - : contact.getUpdates(); - } - } - - public Observable<List<Contact>> observeContact(String accountId, List<Contact> contacts, boolean withPresence) { - if (contacts.isEmpty()) { - return Observable.just(Collections.emptyList()); - } /*else if (contacts.size() == 1 || contacts.size() == 2) { - - return observeContact(accountId, contacts.get(contacts.size() - 1), withPresence).map(Collections::singletonList); - } */else { - List<Observable<Contact>> observables = new ArrayList<>(contacts.size()); - for (Contact contact : contacts) { - if (!contact.isUser()) - observables.add(observeContact(accountId, contact, withPresence)); - } - if (observables.isEmpty()) - return Observable.just(Collections.emptyList()); - return Observable.combineLatest(observables, a -> { - List<Contact> obs = new ArrayList<>(a.length); - for (Object o : a) - obs.add((Contact) o); - return obs; - }); - } - } - - public Single<Contact> getLoadedContact(String accountId, Contact contact, boolean withPresence) { - return observeContact(accountId, contact, withPresence) - .filter(c -> c.isUsernameLoaded() && c.detailsLoaded) - .firstOrError(); - } - public Single<Contact> getLoadedContact(String accountId, Contact contact) { - return getLoadedContact(accountId, contact, false); - } - - public Single<List<Contact>> getLoadedContact(String accountId, List<Contact> contacts, boolean withPresence) { - if (contacts.isEmpty()) - return Single.just(Collections.emptyList()); - return Observable.fromIterable(contacts) - .concatMapEager(contact -> getLoadedContact(accountId, contact, withPresence).toObservable()) - .toList(contacts.size()); - } - - public List<Observable<Contact>> observeLoadedContact(String accountId, List<Contact> contacts, boolean withPresence) { - if (contacts.isEmpty()) - return Collections.emptyList(); - List<Observable<Contact>> ret = new ArrayList<>(contacts.size()); - for (Contact contact : contacts) - ret.add(observeContact(accountId, contact, withPresence) - .filter(c -> c.isUsernameLoaded() && c.detailsLoaded)); - return ret; - } - - /** - * Searches a contact in the local cache and then in the system repository - * In the last case, the contact is created and added to the local cache - * - * @return The found/created contact - */ - public Contact findContactByNumber(Account account, String number) { - if (StringUtils.isEmpty(number) || account == null) { - return null; - } - return findContact(account, Uri.fromString(number)); - } - - public Contact findContact(Account account, Uri uri) { - if (uri == null || account == null) { - return null; - } - - Contact contact = account.getContactFromCache(uri); - // TODO load system contact info into SIP contact - if (account.isSip()) { - loadContactData(contact, account.getAccountID()).subscribe(() -> {}, e -> Log.e(TAG, "Can't load contact data")); - } - return contact; - } -} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/services/ContactService.kt b/ring-android/libringclient/src/main/java/net/jami/services/ContactService.kt new file mode 100644 index 000000000..755ea2e3c --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/services/ContactService.kt @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.services + +import ezvcard.VCard +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.ObservableEmitter +import io.reactivex.rxjava3.core.Single +import net.jami.model.Account +import net.jami.model.Contact +import net.jami.model.Uri +import net.jami.utils.Log +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * This service handles the contacts + * - Load the contacts stored in the system + * - Keep a local cache of the contacts + * - Provide query tools to search contacts by id, number, ... + */ +abstract class ContactService( + val mPreferencesService: PreferencesService, + val mDeviceRuntimeService: DeviceRuntimeService, + val mAccountService: AccountService +) { + abstract fun loadContactsFromSystem(loadRingContacts: Boolean, loadSipContacts: Boolean): Map<Long, Contact> + protected abstract fun findContactByIdFromSystem(contactId: Long, contactKey: String): Contact? + protected abstract fun findContactBySipNumberFromSystem(number: String): Contact? + protected abstract fun findContactByNumberFromSystem(number: String): Contact? + abstract fun loadContactData(contact: Contact, accountId: String): Completable + abstract fun saveVCardContactData(contact: Contact, accountId: String, vcard: VCard) + abstract fun saveVCardContact(accountId: String, uri: String, displayName: String, pictureB64: String): Single<VCard> + + /** + * Load contacts from system and generate a local contact cache + * + * @param loadRingContacts if true, ring contacts will be taken care of + * @param loadSipContacts if true, sip contacts will be taken care of + */ + fun loadContacts(loadRingContacts: Boolean, loadSipContacts: Boolean, account: Account?): Single<Map<Long, Contact>> { + return Single.fromCallable { + val settings = mPreferencesService.settings + if (settings.isAllowSystemContacts && mDeviceRuntimeService.hasContactPermission()) { + return@fromCallable loadContactsFromSystem(loadRingContacts, loadSipContacts) + } + HashMap() + } + } + + fun observeContact(accountId: String, contact: Contact, withPresence: Boolean): Observable<Contact> { + //Log.w(TAG, "observeContact " + accountId + " " + contact.getUri() + " " + contact.isUser()); + var withPresence = withPresence + if (contact.isUser) withPresence = false + val uri = contact.uri + val uriString = uri.rawUriString + synchronized(contact) { + if (contact.presenceUpdates == null) { + contact.presenceUpdates = Observable.create { emitter: ObservableEmitter<Boolean> -> + emitter.onNext(false) + contact.setPresenceEmitter(emitter) + mAccountService.subscribeBuddy(accountId, uriString, true) + emitter.setCancellable { + mAccountService.subscribeBuddy(accountId, uriString, false) + contact.setPresenceEmitter(null) + emitter.onNext(false) + } + } + .replay(1) + .refCount(5, TimeUnit.SECONDS) + } + if (contact.updates == null) { + contact.updates = contact.updatesSubject + .doOnSubscribe { + if (!contact.isUsernameLoaded) mAccountService.lookupAddress(accountId, "", uri.rawRingId) + loadContactData(contact, accountId) + .subscribe({}) { } + } + .filter { c: Contact -> c.isUsernameLoaded && c.detailsLoaded } + .replay(1) + .refCount() + } + return if (withPresence) Observable.combineLatest( + contact.updates, + contact.presenceUpdates, + { c: Contact, p: Boolean -> c }) else contact.updates!! + } + } + + fun observeContact(accountId: String, contacts: List<Contact>, withPresence: Boolean): Observable<List<Contact>> { + return if (contacts.isEmpty()) { + Observable.just(emptyList()) + } /*else if (contacts.size() == 1 || contacts.size() == 2) { + + return observeContact(accountId, contacts.get(contacts.size() - 1), withPresence).map(Collections::singletonList); + } */ else { + val observables: MutableList<Observable<Contact>> = ArrayList(contacts.size) + for (contact in contacts) { + if (!contact.isUser) observables.add(observeContact(accountId, contact, withPresence)) + } + if (observables.isEmpty()) Observable.just(emptyList()) else Observable.combineLatest(observables) { a: Array<Any> -> + val obs: MutableList<Contact> = ArrayList(a.size) + for (o in a) obs.add(o as Contact) + obs + } + } + } + + fun getLoadedContact(accountId: String, contact: Contact, withPresence: Boolean): Single<Contact> { + return observeContact(accountId, contact, withPresence) + .filter { c: Contact -> c.isUsernameLoaded && c.detailsLoaded } + .firstOrError() + } + + fun getLoadedContact(accountId: String, contact: Contact): Single<Contact> { + return getLoadedContact(accountId, contact, false) + } + + fun getLoadedContact(accountId: String, contacts: List<Contact>, withPresence: Boolean): Single<List<Contact>> { + return if (contacts.isEmpty()) Single.just(emptyList()) else Observable.fromIterable(contacts) + .concatMapEager { contact: Contact -> getLoadedContact(accountId, contact, withPresence).toObservable() } + .toList(contacts.size) + } + + fun observeLoadedContact( + accountId: String, + contacts: List<Contact>, + withPresence: Boolean + ): List<Observable<Contact>> { + if (contacts.isEmpty()) return emptyList() + val ret: MutableList<Observable<Contact>> = ArrayList(contacts.size) + for (contact in contacts) ret.add(observeContact(accountId, contact, withPresence) + .filter { c: Contact -> c.isUsernameLoaded && c.detailsLoaded }) + return ret + } + + /** + * Searches a contact in the local cache and then in the system repository + * In the last case, the contact is created and added to the local cache + * + * @return The found/created contact + */ + fun findContactByNumber(account: Account, number: String): Contact? { + return if (number.isEmpty()) null else findContact(account, Uri.fromString(number)) + } + + fun findContact(account: Account, uri: Uri): Contact { + val contact = account.getContactFromCache(uri) + // TODO load system contact info into SIP contact + if (account.isSip) { + loadContactData(contact, account.accountID).subscribe({}) { e: Throwable? -> + Log.e( + TAG, + "Can't load contact data" + ) + } + } + return contact + } + + companion object { + private val TAG = ContactService::class.simpleName!! + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/services/ConversationFacade.kt b/ring-android/libringclient/src/main/java/net/jami/services/ConversationFacade.kt new file mode 100644 index 000000000..6c369270a --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/services/ConversationFacade.kt @@ -0,0 +1,702 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.services + +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.PublishSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.model.* +import net.jami.model.Account.ContactLocationEntry +import net.jami.model.Call.CallStatus +import net.jami.model.Interaction.InteractionStatus +import net.jami.model.Uri.Companion.fromString +import net.jami.services.AccountService.RegisteredName +import net.jami.smartlist.SmartListViewModel +import net.jami.utils.FileUtils.moveFile +import net.jami.utils.Log +import net.jami.utils.Tuple +import java.io.File +import java.util.* +import java.util.concurrent.TimeUnit + +class ConversationFacade( + private val mHistoryService: HistoryService, + private val mCallService: CallService, + private val mAccountService: AccountService, + private val mContactService: ContactService, + private val mNotificationService: NotificationService, + private val mHardwareService: HardwareService, + private val mDeviceRuntimeService: DeviceRuntimeService, + private val mPreferencesService: PreferencesService +) { + private val mDisposableBag = CompositeDisposable() + val currentAccountSubject: Observable<Account> = mAccountService + .currentAccountSubject + .switchMapSingle { account: Account -> loadSmartlist(account) } + private val conversationSubject: Subject<Conversation> = PublishSubject.create() + val updatedConversation: Observable<Conversation> + get() = conversationSubject + + fun startConversation(accountId: String, contactId: Uri): Single<Conversation> { + return getAccountSubject(accountId) + .map { account: Account -> account.getByUri(contactId) } + } + + fun getAccountSubject(accountId: String): Single<Account> { + return mAccountService + .getAccountSingle(accountId) + .flatMap { account: Account -> loadSmartlist(account) } + } + + val conversationsSubject: Observable<List<Conversation>> + get() = currentAccountSubject + .switchMap { obj: Account -> obj.getConversationsSubject() } + + fun readMessages(accountId: String, contact: Uri): String? { + val account = mAccountService.getAccount(accountId) + return if (account != null) readMessages(account, account.getByUri(contact), true) else null + } + + fun readMessages(account: Account, conversation: Conversation?, cancelNotification: Boolean): String? { + if (conversation != null) { + val lastMessage = readMessages(conversation) + if (lastMessage != null) { + account.refreshed(conversation) + if (mPreferencesService.settings.isAllowReadIndicator) { + mAccountService.setMessageDisplayed(account.accountID, conversation.uri, lastMessage) + } + if (cancelNotification) { + mNotificationService.cancelTextNotification(account.accountID, conversation.uri) + } + } + return lastMessage + } + return null + } + + private fun readMessages(conversation: Conversation): String? { + var lastRead: String? = null + if (conversation.isSwarm) { + lastRead = conversation.readMessages() + if (lastRead != null) mHistoryService.setMessageRead(conversation.accountId, conversation.uri, lastRead) + } else { + val messages = conversation.rawHistory + for (e in messages.descendingMap().values) { + if (e.type != Interaction.InteractionType.TEXT) continue + if (e.isRead) { + break + } + e.read() + val did = e.daemonId + if (lastRead == null && did != null && did != 0L) lastRead = java.lang.Long.toString(did, 16) + mHistoryService.updateInteraction(e, conversation.accountId).subscribe() + } + } + return lastRead + } + + fun sendTextMessage(c: Conversation, to: Uri, txt: String?): Completable { + if (c.isSwarm) { + mAccountService.sendConversationMessage(c.accountId, c.uri, txt!!) + return Completable.complete() + } + return mCallService.sendAccountTextMessage(c.accountId, to.rawUriString, txt!!) + .map { id: Long -> + val message = TextMessage(null, c.accountId, java.lang.Long.toHexString(id), c, txt) + if (c.isVisible) message.read() + mHistoryService.insertInteraction(c.accountId, c, message).subscribe() + c.addTextMessage(message) + mAccountService.getAccount(c.accountId)!!.conversationUpdated(c) + message + }.ignoreElement() + } + + fun sendTextMessage(c: Conversation, conf: Conference, txt: String?) { + mCallService.sendTextMessage(conf.id, txt) + val message = TextMessage(null, c.accountId, conf.id, c, txt!!) + message.read() + mHistoryService.insertInteraction(c.accountId, c, message).subscribe() + c.addTextMessage(message) + } + + fun setIsComposing(accountId: String, conversationUri: Uri, isComposing: Boolean) { + mCallService.setIsComposing(accountId, conversationUri.uri, isComposing) + } + + fun sendFile(conversation: Conversation, to: Uri, file: File?): Completable { + if (file == null || !file.exists() || !file.canRead()) { + return Completable.error(IllegalArgumentException("file not found or not readable")) + } + if (conversation.isSwarm) { + val destPath = mDeviceRuntimeService.getNewConversationPath(conversation.accountId, conversation.uri.rawRingId, file.name) + moveFile(file, destPath) + mAccountService.sendFile(conversation, destPath) + return Completable.complete() + } + return Single.fromCallable { + val transfer = DataTransfer( + conversation, + to.rawRingId, + conversation.accountId, + file.name, + true, + file.length(), + 0, + null + ) + mHistoryService.insertInteraction(conversation.accountId, conversation, transfer).blockingAwait() + transfer.destination = mDeviceRuntimeService.getConversationDir(conversation.uri.rawRingId) + transfer + } + .flatMap { t: DataTransfer -> mAccountService.sendFile(file, t) } + .flatMapCompletable { transfer: DataTransfer -> + Completable.fromAction { + val destination = File(transfer.destination, transfer.storagePath) + if (!mDeviceRuntimeService.hardLinkOrCopy(file, destination)) { + Log.e(TAG, "sendFile: can't move file to $destination") + } + } + } + .subscribeOn(Schedulers.io()) + } + + fun deleteConversationItem(conversation: Conversation?, element: Interaction) { + if (element.type === Interaction.InteractionType.DATA_TRANSFER) { + val transfer = element as DataTransfer + if (transfer.status === InteractionStatus.TRANSFER_ONGOING) { + mAccountService.cancelDataTransfer( + conversation!!.accountId, + conversation.uri.rawRingId, + transfer.messageId, + transfer.fileId!! + ) + } else { + val file = mDeviceRuntimeService.getConversationPath(conversation!!.uri.rawRingId, transfer.storagePath) + mDisposableBag.add(Completable.mergeArrayDelayError( + mHistoryService.deleteInteraction(element.id, element.account), + Completable.fromAction { file.delete() } + .subscribeOn(Schedulers.io())) + .subscribe( + { conversation.removeInteraction(transfer) } + ) { e: Throwable? -> Log.e(TAG, "Can't delete file transfer", e) }) + } + } else { + // handling is the same for calls and texts + mDisposableBag.add(mHistoryService.deleteInteraction(element.id, element.account) + .subscribeOn(Schedulers.io()) + .subscribe( + { conversation!!.removeInteraction(element) } + ) { e: Throwable? -> Log.e(TAG, "Can't delete message", e) }) + } + } + + fun cancelMessage(message: Interaction) { + val accountId = message.account!! + mDisposableBag.add( + Completable.mergeArrayDelayError(mCallService.cancelMessage(accountId, message.id.toLong()).subscribeOn(Schedulers.io())) + .andThen(startConversation(accountId, fromString(message.conversation!!.participant))) + .subscribe({ c: Conversation -> c.removeInteraction(message) } + ) { e: Throwable? -> Log.e(TAG, "Can't cancel message sending", e) }) + } + + /** + * Loads the smartlist from cache or database + * + * @param account the user account + * @return an account single + */ + private fun loadSmartlist(account: Account): Single<Account> { + synchronized(account) { + account.historyLoader.let { loader -> + return loader ?: getSmartlist(account).apply { + account.historyLoader = this + } + } + } + } + + /** + * Loads history for a specific conversation from cache or database + * + * @param account the user account + * @param conversationUri the conversation + * @return a conversation single + */ + fun loadConversationHistory(account: Account, conversationUri: Uri): Single<Conversation> { + val conversation = account.getByUri(conversationUri) + ?: return Single.error(RuntimeException("Can't get conversation")) + synchronized(conversation) { + if (!conversation.isSwarm && conversation.id == null) { + return Single.just(conversation) + } + var ret = conversation.loaded + if (ret == null) { + ret = if (conversation.isSwarm) mAccountService.loadMore(conversation) else getConversationHistory( + conversation + ) + conversation.loaded = ret + } + return ret + } + } + + private fun observeConversation(account: Account, conversation: Conversation, hasPresence: Boolean): Observable<SmartListViewModel> { + return Observable.merge(account.getConversationSubject() + .filter { c: Conversation -> c == conversation } + .startWithItem(conversation), + mContactService.observeContact(conversation.accountId, conversation.contacts, hasPresence)) + .map { SmartListViewModel(conversation, hasPresence) } + /*return account.getConversationSubject() + .filter(c -> c == conversation) + .startWith(conversation) + .switchMap(c -> mContactService + .observeContact(c.getAccountId(), c.getContacts(), hasPresence) + .map(contact -> new SmartListViewModel(c, hasPresence)));*/ + } + + fun getSmartList(currentAccount: Observable<Account>, hasPresence: Boolean): Observable<List<Observable<SmartListViewModel>>> { + return currentAccount.switchMap { account: Account -> + account.getConversationsSubject() + .switchMapSingle { conversations -> Observable.fromIterable(conversations) + .map { conversation: Conversation -> observeConversation(account, conversation, hasPresence) } + .toList() } } + } + + fun getContactList(currentAccount: Observable<Account>): Observable<List<SmartListViewModel>> { + return currentAccount.switchMap { account: Account -> + account.getConversationsSubject() + .switchMapSingle { conversations: List<Conversation>? -> + Observable.fromIterable(conversations) + .filter { conversation: Conversation -> !conversation.isSwarm } + .map { conversation: Conversation -> SmartListViewModel(conversation, false) } + .toList() + } + } + } + + fun getPendingList(currentAccount: Observable<Account>): Observable<List<Observable<SmartListViewModel>>> { + return currentAccount.switchMap { account: Account -> + account.getPendingSubject() + .switchMapSingle { conversations -> Observable.fromIterable(conversations) + .map { conversation: Conversation -> observeConversation(account, conversation, false) } + .toList() } } + } + + fun getSmartList(hasPresence: Boolean): Observable<List<Observable<SmartListViewModel>>> { + return getSmartList(mAccountService.currentAccountSubject, hasPresence) + } + + val pendingList: Observable<List<Observable<SmartListViewModel>>> + get() = getPendingList(mAccountService.currentAccountSubject) + val contactList: Observable<List<SmartListViewModel>> + get() = getContactList(mAccountService.currentAccountSubject) + + private fun getSearchResults(account: Account, query: String): Single<List<Observable<SmartListViewModel>>> { + val uri = fromString(query) + return if (account.isSip) { + val contact = account.getContactFromCache(uri) + mContactService.loadContactData(contact, account.accountID) + .andThen(Single.just(listOf(Observable.just(SmartListViewModel(account.accountID, contact, contact.primaryNumber, null))))) + } else if (uri.isHexId) { + mContactService.getLoadedContact(account.accountID, account.getContactFromCache(uri)) + .map { contact -> listOf(Observable.just(SmartListViewModel(account.accountID, contact, contact.primaryNumber, null))) } + } else if (account.canSearch() && !query.contains("@")) { + mAccountService.searchUser(account.accountID, query) + .map(AccountService.UserSearchResult::resultsViewModels) + } else { + mAccountService.findRegistrationByName(account.accountID, "", query) + .map { result: RegisteredName -> + if (result.state == 0) + listOf(observeConversation(account, account.getByUri(result.address)!!, false)) + else + emptyList() + } + } + } + + private fun getSearchResults(account: Account, query: Observable<String>): Observable<List<Observable<SmartListViewModel>>> { + return query.switchMapSingle { q: String -> + if (q.isEmpty()) + SmartListViewModel.EMPTY_LIST + else getSearchResults(account, q) + }.distinctUntilChanged() + } + + fun getFullList(currentAccount: Observable<Account>, query: Observable<String>, hasPresence: Boolean): Observable<List<Observable<SmartListViewModel>>> { + return currentAccount.switchMap { account: Account -> + Observable.combineLatest<List<Conversation>, List<Observable<SmartListViewModel>>, String, List<Observable<SmartListViewModel>>>( + account.getConversationsSubject(), + getSearchResults(account, query), + query, + { conversations: List<Conversation>, searchResults: List<Observable<SmartListViewModel>>, q: String -> + val newList: MutableList<Observable<SmartListViewModel>> = + ArrayList(conversations.size + searchResults.size + 2) + if (searchResults.isNotEmpty()) { + newList.add(SmartListViewModel.TITLE_PUBLIC_DIR) + newList.addAll(searchResults) + } + if (conversations.isNotEmpty()) { + if (q.isEmpty()) { + for (conversation in conversations) + newList.add(observeConversation(account, conversation, hasPresence)) + } else { + val lq = q.lowercase() + newList.add(SmartListViewModel.TITLE_CONVERSATIONS) + var nRes = 0 + for (conversation in conversations) { + if (conversation.matches(lq)) { + newList.add(observeConversation(account, conversation, hasPresence)) + nRes++ + } + } + if (nRes == 0) newList.removeAt(newList.size - 1) + } + } + newList + }) + } + } + + /** + * Loads the smartlist from the database and updates the view + * + * @param account the user account + */ + private fun getSmartlist(account: Account): Single<Account> { + val actions: MutableList<Completable?> = ArrayList(account.getConversations().size + 1) + for (c in account.getConversations()) { + if (c.isSwarm) actions.add(c.lastElementLoaded) + } + actions.add(mHistoryService.getSmartlist(account.accountID) + .flatMapCompletable { conversationHistoryList: List<Interaction> -> + Completable.fromAction { + val conversations: MutableList<Conversation> = ArrayList() + for (e in conversationHistoryList) { + val conversation = account.getByUri(e.conversation!!.participant) ?: continue + conversation.id = e.conversation!!.id + conversation.addElement(e) + conversations.add(conversation) + } + account.setHistoryLoaded(conversations) + } + }) + return Completable.merge(actions) + .andThen(Single.just(account)) + .cache() + } + + /** + * Loads a conversation's history from the database + * + * @param conversation a conversation object with a valid conversation ID + * @return a conversation single + */ + private fun getConversationHistory(conversation: Conversation): Single<Conversation> { + Log.d(TAG, "getConversationHistory() " + conversation.accountId + " " + conversation.uri) + return mHistoryService.getConversationHistory(conversation.accountId, conversation.id) + .map { loadedConversation: List<Interaction> -> + conversation.clearHistory(true) + conversation.setHistory(loadedConversation) + conversation + } + .cache() + } + + fun clearHistory(accountId: String, contact: Uri): Completable { + return mHistoryService + .clearHistory(contact.uri, accountId, false) + .doOnSubscribe { + mAccountService.getAccount(accountId)?.clearHistory(contact, false) + } + } + + fun clearAllHistory(): Completable { + return mAccountService.observableAccountList + .firstElement() + .flatMapCompletable { accounts: List<Account> -> + mHistoryService.clearHistory(accounts) + .doOnSubscribe { + for (account in accounts) + account.clearAllHistory() + } + } + } + + fun updateTextNotifications(accountId: String, conversations: List<Conversation>) { + Log.d(TAG, "updateTextNotifications() " + accountId + " " + conversations.size) + for (conversation in conversations) { + mNotificationService.showTextNotification(accountId, conversation) + } + } + + private fun parseNewMessage(txt: TextMessage) { + val accountId = txt.account!! + if (txt.isRead) { + if (txt.messageId == null) { + mHistoryService.updateInteraction(txt, accountId).subscribe() + } + if (mPreferencesService.settings.isAllowReadIndicator) { + if (txt.messageId != null) { + mAccountService.setMessageDisplayed(txt.account, Uri(Uri.SWARM_SCHEME, txt.conversationId!!), txt.messageId) + } else { + mAccountService.setMessageDisplayed(txt.account, Uri(Uri.JAMI_URI_SCHEME, txt.author!!), txt.daemonIdString) + } + } + } + getAccountSubject(accountId) + .flatMapObservable { obj: Account -> obj.getConversationsSubject() } + .firstOrError() + .subscribeOn(Schedulers.io()) + .subscribe({ c: List<Conversation> -> updateTextNotifications(txt.account!!, c) }) + { e: Throwable -> Log.e(TAG, e.message) } + } + + fun acceptRequest(accountId: String, contactUri: Uri) { + mPreferencesService.removeRequestPreferences(accountId, contactUri.rawRingId) + mAccountService.acceptTrustRequest(accountId, contactUri) + } + + fun discardRequest(accountId: String, contact: Uri) { + //mHistoryService.clearHistory(contact.uri, accountId, true).subscribe() + mPreferencesService.removeRequestPreferences(accountId, contact.rawRingId) + mAccountService.discardTrustRequest(accountId, contact) + } + + private fun handleDataTransferEvent(transfer: DataTransfer) { + val conversation = mAccountService.getAccount(transfer.account!!)!!.onDataTransferEvent(transfer) + val status = transfer.status + Log.d(TAG, "handleDataTransferEvent $status " + transfer.canAutoAccept(mPreferencesService.getMaxFileAutoAccept(transfer.account))) + if (status === InteractionStatus.TRANSFER_AWAITING_HOST || status === InteractionStatus.FILE_AVAILABLE) { + if (transfer.canAutoAccept(mPreferencesService.getMaxFileAutoAccept(transfer.account))) { + mAccountService.acceptFileTransfer(conversation, transfer.fileId!!, transfer) + return + } + } + mNotificationService.handleDataTransferNotification(transfer, conversation, conversation.isVisible) + } + + private fun onConfStateChange(conference: Conference) { + Log.d(TAG, "onConfStateChange Thread id: " + Thread.currentThread().id) + } + + private fun onCallStateChange(call: Call) { + Log.d(TAG, "onCallStateChange Thread id: " + Thread.currentThread().id) + val newState = call.callStatus + val incomingCall = newState === CallStatus.RINGING && call.isIncoming + mHardwareService.updateAudioState(newState, incomingCall, !call.isAudioOnly) + val account = mAccountService.getAccount(call.account!!) ?: return + val contact = call.contact + val conversationId = call.conversationId + Log.w(TAG, "CallStateChange " + call.daemonIdString + " conversationId:" + conversationId) + val conversation = if (conversationId == null) + if (contact == null) null else account.getByUri(contact.uri) + else + account.getSwarm(conversationId) + var conference: Conference? = null + if (conversation != null) { + conference = conversation.getConference(call.daemonIdString) + if (conference == null) { + if (newState === CallStatus.OVER) return + conference = Conference(call) + conversation.addConference(conference) + account.updated(conversation) + } + } + Log.w(TAG, "CALL_STATE_CHANGED : updating call state to $newState") + if ((call.isRinging || newState === CallStatus.CURRENT) && call.timestamp == 0L) { + call.timestamp = System.currentTimeMillis() + } + if (incomingCall) { + mNotificationService.handleCallNotification(conference, false) + mHardwareService.setPreviewSettings() + } else if (newState === CallStatus.CURRENT && call.isIncoming + || newState === CallStatus.RINGING && !call.isIncoming + ) { + mNotificationService.handleCallNotification(conference, false) + mAccountService.sendProfile(call.daemonIdString!!, call.account!!) + } else if (newState === CallStatus.HUNGUP || newState === CallStatus.BUSY || newState === CallStatus.FAILURE || newState === CallStatus.OVER) { + mNotificationService.handleCallNotification(conference, true) + mHardwareService.closeAudioState() + val now = System.currentTimeMillis() + if (call.timestamp == 0L) { + call.timestamp = now + } + if (newState === CallStatus.HUNGUP || call.timestampEnd == 0L) { + call.timestampEnd = now + } + if (conference != null && conference.removeParticipant(call) && !conversation!!.isSwarm) { + Log.w(TAG, "Adding call history for conversation " + conversation.uri) + mHistoryService.insertInteraction(account.accountID, conversation, call).subscribe() + conversation.addCall(call) + if (call.isIncoming && call.isMissed) { + mNotificationService.showMissedCallNotification(call) + } + account.updated(conversation) + } + mCallService.removeCallForId(call.daemonIdString!!) + if (conversation != null && conference!!.participants.isEmpty()) { + conversation.removeConference(conference) + } + } + } + + fun placeCall(accountId: String, contactUri: Uri, video: Boolean): Single<Call> { + //String rawId = contactUri.getRawRingId(); + return getAccountSubject(accountId).flatMap { account: Account -> + mCallService.placeCall(accountId, null, contactUri, video) } + } + + fun cancelFileTransfer(accountId: String, conversationId: Uri, messageId: String?, fileId: String?) { + mAccountService.cancelDataTransfer(accountId, + if (conversationId.isSwarm) conversationId.rawRingId else "", + messageId, fileId!!) + mNotificationService.removeTransferNotification(accountId, conversationId, fileId) + val transfer = mAccountService.getAccount(accountId)?.getDataTransfer(fileId) + if (transfer != null) + deleteConversationItem(transfer.conversation as Conversation?, transfer) + } + + fun removeConversation(accountId: String, conversationUri: Uri): Completable { + return if (conversationUri.isSwarm) { + // For a one to one conversation, contact is strongly related, so remove the contact. + // This will remove related conversations + val account = mAccountService.getAccount(accountId) + val conversation = account!!.getSwarm(conversationUri.rawRingId) + if (conversation != null && conversation.mode.blockingFirst() === Conversation.Mode.OneToOne) { + val contact = conversation.contact + mAccountService.removeContact(accountId, contact!!.uri.rawRingId, false) + Completable.complete() + } else { + mAccountService.removeConversation(accountId, conversationUri) + } + } else { + mHistoryService + .clearHistory(conversationUri.uri, accountId, true) + .doOnSubscribe { + mAccountService.getAccount(accountId)?.clearHistory(conversationUri, true) + mAccountService.removeContact(accountId, conversationUri.rawRingId, false) + } + } + } + + fun banConversation(accountId: String, conversationUri: Uri) { + if (conversationUri.isSwarm) { + startConversation(accountId, conversationUri) + .subscribe { conversation: Conversation -> + try { + val contact = conversation.contact + mAccountService.removeContact(accountId, contact!!.uri.rawUriString, true) + } catch (e: Exception) { + mAccountService.removeConversation(accountId, conversationUri) + } + } + //return mAccountService.removeConversation(accountId, conversationUri); + } else { + mAccountService.removeContact(accountId, conversationUri.rawUriString, true) + } + } + + fun createConversation(accountId: String, currentSelection: Collection<Contact>): Single<Conversation> { + val contactIds = currentSelection.map { contact -> contact.primaryNumber } + return mAccountService.startConversation(accountId, contactIds) + } + + companion object { + private val TAG = ConversationFacade::class.simpleName!! + } + + init { + mDisposableBag.add(mCallService.callsUpdates + .subscribe { call: Call -> onCallStateChange(call) }) + + /*mDisposableBag.add(mCallService.getConnectionUpdates() + .subscribe(mNotificationService::onConnectionUpdate));*/ + mDisposableBag.add(mCallService.confsUpdates + .observeOn(Schedulers.io()) + .subscribe { conference: Conference -> onConfStateChange(conference) }) + mDisposableBag.add(currentAccountSubject + .switchMap { a: Account -> a.getPendingSubject() + .doOnNext { mNotificationService.showIncomingTrustRequestNotification(a) } } + .subscribe()) + mDisposableBag.add(mAccountService.incomingRequests + .concatMapSingle { r: TrustRequest -> getAccountSubject(r.accountId) } + .subscribe( + { account: Account? -> mNotificationService.showIncomingTrustRequestNotification(account) } + ) { Log.e(TAG, "Error showing contact request") }) + mDisposableBag.add(mAccountService + .incomingMessages + .concatMapSingle { msg: TextMessage -> + getAccountSubject(msg.account!!) + .map { a: Account -> + a.addTextMessage(msg) + msg } + } + .subscribe({ txt: TextMessage -> parseNewMessage(txt) } + ) { e: Throwable -> Log.e(TAG, "Error adding text message", e) }) + mDisposableBag.add(mAccountService.incomingSwarmMessages + .subscribe({ txt: TextMessage -> parseNewMessage(txt) }, + { e: Throwable -> Log.e(TAG, "Error adding text message", e) })) + mDisposableBag.add(mAccountService.locationUpdates + .concatMapSingle { location: AccountService.Location -> + getAccountSubject(location.account) + .map { a: Account -> + val expiration = a.onLocationUpdate(location) + mDisposableBag.add( + Completable.timer(expiration, TimeUnit.MILLISECONDS) + .subscribe { a.maintainLocation() }) + location + } } + .subscribe()) + mDisposableBag.add(mAccountService.observableAccountList + .switchMap { accounts: List<Account> -> + val r: MutableList<Observable<Tuple<Account, ContactLocationEntry>>> = ArrayList(accounts.size) + for (a in accounts) r.add(a.locationUpdates.map { s: ContactLocationEntry -> Tuple(a, s) }) + Observable.merge(r) + } + .distinctUntilChanged() + .subscribe { t: Tuple<Account, ContactLocationEntry> -> + Log.e(TAG, "Location reception started for " + t.second.contact) + mNotificationService.showLocationNotification(t.first, t.second.contact) + mDisposableBag.add(t.second.location.doOnComplete { + mNotificationService.cancelLocationNotification(t.first, t.second.contact) + }.subscribe()) }) + mDisposableBag.add(mAccountService + .messageStateChanges + .concatMapSingle { e: Interaction -> + getAccountSubject(e.account!!) + .map { a: Account -> + if (e.conversation == null) + a.getSwarm(e.conversationId!!) + else + a.getByUri(e.conversation!!.participant) + } + .doOnSuccess { conversation: Conversation? -> conversation!!.updateInteraction(e) } + } + .subscribe({}) { e: Throwable -> Log.e(TAG, "Error updating text message", e) }) + mDisposableBag.add(mAccountService.dataTransfers + .subscribe({ transfer: DataTransfer -> handleDataTransferEvent(transfer) }, + { e: Throwable -> Log.e(TAG, "Error adding data transfer", e) })) + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/services/DaemonService.java b/ring-android/libringclient/src/main/java/net/jami/services/DaemonService.java index bbe45d2b9..d947844bf 100644 --- a/ring-android/libringclient/src/main/java/net/jami/services/DaemonService.java +++ b/ring-android/libringclient/src/main/java/net/jami/services/DaemonService.java @@ -46,22 +46,11 @@ public class DaemonService { private static final String TAG = DaemonService.class.getSimpleName(); - @Inject @Named("DaemonExecutor") - ScheduledExecutorService mExecutor; - - @Inject - HistoryService mHistoryService; - - @Inject - protected CallService mCallService; - - @Inject - protected HardwareService mHardwareService; - - @Inject - protected AccountService mAccountService; - + private final ScheduledExecutorService mExecutor; + private final CallService mCallService; + private final HardwareService mHardwareService; + private final AccountService mAccountService; private final SystemInfoCallbacks mSystemInfoCallbacks; // references must be kept to avoid garbage collection while pointers are stored in the daemon. @@ -74,8 +63,12 @@ public class DaemonService { private boolean mDaemonStarted = false; - public DaemonService(SystemInfoCallbacks systemInfoCallbacks) { + public DaemonService(SystemInfoCallbacks systemInfoCallbacks, ScheduledExecutorService executor, CallService callService, HardwareService hardwareService, AccountService accountService) { mSystemInfoCallbacks = systemInfoCallbacks; + mExecutor = executor; + mCallService = callService; + mHardwareService = hardwareService; + mAccountService = accountService; } public interface SystemInfoCallbacks { @@ -412,6 +405,10 @@ public class DaemonService { mAccountService.conversationRequestReceived(accountId, conversationId, metadata.toNative()); } + @Override + public void conversationRequestDeclined(String accountId, String conversationId) { + mAccountService.conversationRequestDeclined(accountId, conversationId); + } @Override public void conversationMemberEvent(String accountId, String conversationId, String uri, int event) { mAccountService.conversationMemberEvent(accountId, conversationId, uri, event); diff --git a/ring-android/libringclient/src/main/java/net/jami/services/HardwareService.java b/ring-android/libringclient/src/main/java/net/jami/services/HardwareService.java deleted file mode 100644 index 80249df32..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/services/HardwareService.java +++ /dev/null @@ -1,266 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.services; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; -import javax.inject.Named; - -import net.jami.daemon.IntVect; -import net.jami.daemon.JamiService; -import net.jami.daemon.StringMap; -import net.jami.daemon.UintVect; -import net.jami.model.Conference; -import net.jami.model.Call; -import net.jami.utils.Log; -import net.jami.utils.StringUtils; -import net.jami.utils.Tuple; - -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Emitter; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.ObservableOnSubscribe; -import io.reactivex.rxjava3.core.Scheduler; -import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.PublishSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public abstract class HardwareService { - - private static final String TAG = HardwareService.class.getSimpleName(); - - @Inject - @Named("DaemonExecutor") - ScheduledExecutorService mExecutor; - - @Inject - DeviceRuntimeService mDeviceRuntimeService; - - @Inject - public PreferencesService mPreferenceService; - - @Inject - @Named("UiScheduler") - protected Scheduler mUiScheduler; - - public static class VideoEvent { - public boolean start = false; - public boolean started = false; - public int w = 0, h = 0; - public int rot = 0; - public String callId = null; - } - public static class BluetoothEvent { - public boolean connected; - } - public enum AudioOutput { - INTERNAL, SPEAKERS, BLUETOOTH - } - public static class AudioState { - private final AudioOutput outputType; - private final String outputName; - - public AudioState(AudioOutput ot) { outputType = ot; outputName = null; } - public AudioState(AudioOutput ot, String name) { outputType = ot; outputName = name; } - - public AudioOutput getOutputType() { return outputType; } - public String getOutputName() { return outputName; } - } - protected static final AudioState STATE_SPEAKERS = new AudioState(AudioOutput.SPEAKERS); - protected static final AudioState STATE_INTERNAL = new AudioState(AudioOutput.INTERNAL); - - protected final Subject<VideoEvent> videoEvents = PublishSubject.create(); - protected final Subject<BluetoothEvent> bluetoothEvents = PublishSubject.create(); - protected final Subject<AudioState> audioStateSubject = BehaviorSubject.createDefault(STATE_INTERNAL); - protected final Subject<Boolean> connectivityEvents = BehaviorSubject.create(); - - public Observable<VideoEvent> getVideoEvents() { - return videoEvents; - } - public Observable<BluetoothEvent> getBluetoothEvents() { - return bluetoothEvents; - } - public Observable<AudioState> getAudioState() { - return audioStateSubject; - } - public Observable<Boolean> getConnectivityState() { - return connectivityEvents; - } - - public abstract Completable initVideo(); - - public abstract boolean isVideoAvailable(); - - public abstract void updateAudioState(Call.CallStatus state, boolean incomingCall, boolean isOngoingVideo); - - public abstract void closeAudioState(); - - public abstract boolean isSpeakerPhoneOn(); - - public abstract void toggleSpeakerphone(boolean checked); - - public abstract void startRinging(); - - public abstract void stopRinging(); - - public abstract void abandonAudioFocus(); - - public abstract void decodingStarted(String id, String shmPath, int width, int height, boolean isMixer); - - public abstract void decodingStopped(String id, String shmPath, boolean isMixer); - - public abstract void getCameraInfo(String camId, IntVect formats, UintVect sizes, UintVect rates); - - public abstract void setParameters(String camId, int format, int width, int height, int rate); - - public abstract void startCapture(String camId); - public abstract boolean startScreenShare(Object mediaProjection); - - public abstract boolean hasMicrophone(); - - public abstract void stopCapture(); - public abstract void endCapture(); - public abstract void stopScreenShare(); - - public abstract void requestKeyFrame(); - public abstract void setBitrate(String device, int bitrate); - - public abstract void addVideoSurface(String id, Object holder); - public abstract void updateVideoSurfaceId(String currentId, String newId); - public abstract void removeVideoSurface(String id); - - public abstract void addPreviewVideoSurface(Object holder, Conference conference); - public abstract void updatePreviewVideoSurface(Conference conference); - public abstract void removePreviewVideoSurface(); - - public abstract void switchInput(String id, boolean setDefaultCamera); - - public abstract void setPreviewSettings(); - - public abstract boolean hasCamera(); - public abstract int getCameraCount(); - public abstract Observable<Tuple<Integer, Integer>> getMaxResolutions(); - - public abstract boolean isPreviewFromFrontCamera(); - - public abstract boolean shouldPlaySpeaker(); - - public abstract void unregisterCameraDetectionCallback(); - - public abstract void startMediaHandler(String mediaHandlerId); - - public abstract void stopMediaHandler(); - - public void connectivityChanged(boolean isConnected) { - Log.i(TAG, "connectivityChange() " + isConnected); - connectivityEvents.onNext(isConnected); - mExecutor.execute(JamiService::connectivityChanged); - } - - protected void switchInput(final String id, final String uri) { - Log.i(TAG, "switchInput() " + uri); - mExecutor.execute(() -> JamiService.switchInput(id, uri)); - } - - public void setPreviewSettings(final Map<String, StringMap> cameraMaps) { - mExecutor.execute(() -> { - Log.i(TAG, "applySettings() thread running..."); - for (Map.Entry<String, StringMap> entry : cameraMaps.entrySet()) { - JamiService.applySettings(entry.getKey(), entry.getValue()); - } - }); - } - - public long startVideo(final String inputId, final Object surface, final int width, final int height) { - long inputWindow = JamiService.acquireNativeWindow(surface); - if (inputWindow == 0) { - return inputWindow; - } - JamiService.setNativeWindowGeometry(inputWindow, width, height); - JamiService.registerVideoCallback(inputId, inputWindow); - return inputWindow; - } - - public void stopVideo(final String inputId, long inputWindow) { - if (inputWindow == 0) { - return; - } - JamiService.unregisterVideoCallback(inputId, inputWindow); - JamiService.releaseNativeWindow(inputWindow); - } - - public abstract void setDeviceOrientation(int rotation); - - protected abstract List<String> getVideoDevices(); - - private Observable<String> logs = null; - private Emitter<String> logEmitter = null; - - synchronized public boolean isLogging() { - return logs != null; - } - - synchronized public Observable<String> startLogs() { - if (logs == null) { - logs = Observable.create((ObservableOnSubscribe<String>) emitter -> { - Log.w(TAG, "ObservableOnSubscribe JamiService.monitor(true)"); - logEmitter = emitter; - JamiService.monitor(true); - emitter.setCancellable(() -> { - Log.w(TAG, "ObservableOnSubscribe CANCEL JamiService.monitor(false)"); - synchronized (HardwareService.this) { - JamiService.monitor(false); - logEmitter = null; - logs = null; - } - }); - }) - .observeOn(Schedulers.io()) - .scan(new StringBuffer(1024), (sb, message) -> sb.append(message).append('\n')) - .throttleLatest(500, TimeUnit.MILLISECONDS) - .map(StringBuffer::toString) - .replay(1) - .autoConnect(); - } - return logs; - } - - synchronized public void stopLogs() { - if (logEmitter != null) { - Log.w(TAG, "stopLogs JamiService.monitor(false)"); - JamiService.monitor(false); - logEmitter.onComplete(); - logEmitter = null; - logs = null; - } - } - - void logMessage(String message) { - if (logEmitter != null && !StringUtils.isEmpty(message)) { - logEmitter.onNext(message); - } - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/services/HardwareService.kt b/ring-android/libringclient/src/main/java/net/jami/services/HardwareService.kt new file mode 100644 index 000000000..91239b566 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/services/HardwareService.kt @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Thibault Wittemberg <thibault.wittemberg@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.services + +import io.reactivex.rxjava3.core.* +import io.reactivex.rxjava3.core.ObservableOnSubscribe +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.PublishSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.utils.StringUtils.isEmpty +import net.jami.model.Call.CallStatus +import net.jami.daemon.IntVect +import net.jami.daemon.UintVect +import net.jami.daemon.JamiService +import net.jami.daemon.StringMap +import net.jami.model.Conference +import net.jami.utils.Log +import net.jami.utils.Tuple +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import kotlin.jvm.Synchronized + +abstract class HardwareService( + private val mExecutor: ScheduledExecutorService, + val mPreferenceService: PreferencesService, + protected val mUiScheduler: Scheduler +) { + class VideoEvent { + var start = false + var started = false + var w = 0 + var h = 0 + var rot = 0 + var callId: String? = null + } + + class BluetoothEvent { + var connected = false + } + + enum class AudioOutput { + INTERNAL, SPEAKERS, BLUETOOTH + } + + class AudioState { + val outputType: AudioOutput + val outputName: String? + + constructor(ot: AudioOutput) { + outputType = ot + outputName = null + } + + constructor(ot: AudioOutput, name: String?) { + outputType = ot + outputName = name + } + } + + protected val videoEvents: Subject<VideoEvent> = PublishSubject.create() + protected val bluetoothEvents: Subject<BluetoothEvent> = PublishSubject.create() + protected val audioStateSubject: Subject<AudioState> = BehaviorSubject.createDefault(STATE_INTERNAL) + protected val connectivityEvents: Subject<Boolean> = BehaviorSubject.create() + fun getVideoEvents(): Observable<VideoEvent> { + return videoEvents + } + + fun getBluetoothEvents(): Observable<BluetoothEvent> { + return bluetoothEvents + } + + val audioState: Observable<AudioState> + get() = audioStateSubject + val connectivityState: Observable<Boolean> + get() = connectivityEvents + + abstract fun initVideo(): Completable + abstract val isVideoAvailable: Boolean + abstract fun updateAudioState(state: CallStatus?, incomingCall: Boolean, isOngoingVideo: Boolean) + abstract fun closeAudioState() + abstract val isSpeakerphoneOn: Boolean + + abstract fun toggleSpeakerphone(checked: Boolean) + abstract fun startRinging() + abstract fun stopRinging() + abstract fun abandonAudioFocus() + abstract fun decodingStarted(id: String, shmPath: String, width: Int, height: Int, isMixer: Boolean) + abstract fun decodingStopped(id: String, shmPath: String, isMixer: Boolean) + abstract fun getCameraInfo(camId: String, formats: IntVect, sizes: UintVect, rates: UintVect) + abstract fun setParameters(camId: String, format: Int, width: Int, height: Int, rate: Int) + abstract fun startCapture(camId: String?) + abstract fun startScreenShare(mediaProjection: Any?): Boolean + abstract fun hasMicrophone(): Boolean + abstract fun stopCapture() + abstract fun endCapture() + abstract fun stopScreenShare() + abstract fun requestKeyFrame() + abstract fun setBitrate(device: String?, bitrate: Int) + abstract fun addVideoSurface(id: String?, holder: Any?) + abstract fun updateVideoSurfaceId(currentId: String?, newId: String?) + abstract fun removeVideoSurface(id: String?) + abstract fun addPreviewVideoSurface(holder: Any?, conference: Conference?) + abstract fun updatePreviewVideoSurface(conference: Conference?) + abstract fun removePreviewVideoSurface() + abstract fun switchInput(id: String?, setDefaultCamera: Boolean) + abstract fun setPreviewSettings() + abstract fun hasCamera(): Boolean + abstract val cameraCount: Int + abstract val maxResolutions: Observable<Tuple<Int, Int>> + abstract val isPreviewFromFrontCamera: Boolean + abstract fun shouldPlaySpeaker(): Boolean + abstract fun unregisterCameraDetectionCallback() + abstract fun startMediaHandler(mediaHandlerId: String?) + abstract fun stopMediaHandler() + fun connectivityChanged(isConnected: Boolean) { + Log.i(TAG, "connectivityChange() $isConnected") + connectivityEvents.onNext(isConnected) + mExecutor.execute { JamiService.connectivityChanged() } + } + + protected fun switchInput(id: String?, uri: String) { + Log.i(TAG, "switchInput() $uri") + mExecutor.execute { JamiService.switchInput(id, uri) } + } + + fun setPreviewSettings(cameraMaps: Map<String?, StringMap?>) { + mExecutor.execute { + Log.i(TAG, "applySettings() thread running...") + for ((key, value) in cameraMaps) { + JamiService.applySettings(key, value) + } + } + } + + fun startVideo(inputId: String?, surface: Any?, width: Int, height: Int): Long { + val inputWindow = JamiService.acquireNativeWindow(surface) + if (inputWindow == 0L) { + return inputWindow + } + JamiService.setNativeWindowGeometry(inputWindow, width, height) + JamiService.registerVideoCallback(inputId, inputWindow) + return inputWindow + } + + fun stopVideo(inputId: String?, inputWindow: Long) { + if (inputWindow == 0L) { + return + } + JamiService.unregisterVideoCallback(inputId, inputWindow) + JamiService.releaseNativeWindow(inputWindow) + } + + abstract fun setDeviceOrientation(rotation: Int) + protected abstract val videoDevices: List<String?>? + private var logs: Observable<String>? = null + private var logEmitter: Emitter<String>? = null + + @get:Synchronized + val isLogging: Boolean + get() = logs != null + + @Synchronized + fun startLogs(): Observable<String> { + return logs ?: Observable.create(ObservableOnSubscribe { emitter: ObservableEmitter<String> -> + Log.w(TAG, "ObservableOnSubscribe JamiService.monitor(true)") + logEmitter = emitter + JamiService.monitor(true) + emitter.setCancellable { + Log.w(TAG, "ObservableOnSubscribe CANCEL JamiService.monitor(false)") + synchronized(this@HardwareService) { + JamiService.monitor(false) + logEmitter = null + logs = null + } + } + } as ObservableOnSubscribe<String>) + .observeOn(Schedulers.io()) + .scan(StringBuffer(1024), { sb: StringBuffer, message: String? -> sb.append(message).append('\n') }) + .throttleLatest(500, TimeUnit.MILLISECONDS) + .map { obj: StringBuffer -> obj.toString() } + .replay(1) + .autoConnect() + .apply { logs = this } + } + + @Synchronized + fun stopLogs() { + logEmitter?.let { emitter -> + Log.w(TAG, "stopLogs JamiService.monitor(false)") + JamiService.monitor(false) + emitter.onComplete() + logEmitter = null + logs = null + } + } + + fun logMessage(message: String) { + if (message.isNotEmpty()) + logEmitter?.onNext(message) + } + + companion object { + private val TAG = HardwareService::class.java.simpleName + val STATE_SPEAKERS = AudioState(AudioOutput.SPEAKERS) + val STATE_INTERNAL = AudioState(AudioOutput.INTERNAL) + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/services/LogService.java b/ring-android/libringclient/src/main/java/net/jami/services/LogService.kt similarity index 65% rename from ring-android/libringclient/src/main/java/net/jami/services/LogService.java rename to ring-android/libringclient/src/main/java/net/jami/services/LogService.kt index fd8166763..f807ecefa 100644 --- a/ring-android/libringclient/src/main/java/net/jami/services/LogService.java +++ b/ring-android/libringclient/src/main/java/net/jami/services/LogService.kt @@ -17,23 +17,15 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ -package net.jami.services; - -public interface LogService { - - void e(String tag, String message); - - void d(String tag, String message); - - void w(String tag, String message); - - void i(String tag, String message); - - void e(String tag, String message, Throwable e); - - void d(String tag, String message, Throwable e); - - void w(String tag, String message, Throwable e); - - void i(String tag, String message, Throwable e); -} +package net.jami.services + +interface LogService { + fun e(tag: String, message: String) + fun d(tag: String, message: String) + fun w(tag: String, message: String) + fun i(tag: String, message: String) + fun e(tag: String, message: String, e: Throwable) + fun d(tag: String, message: String, e: Throwable) + fun w(tag: String, message: String, e: Throwable) + fun i(tag: String, message: String, e: Throwable) +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/services/NotificationService.java b/ring-android/libringclient/src/main/java/net/jami/services/NotificationService.java index 1229695e3..7239c8d08 100644 --- a/ring-android/libringclient/src/main/java/net/jami/services/NotificationService.java +++ b/ring-android/libringclient/src/main/java/net/jami/services/NotificationService.java @@ -55,13 +55,10 @@ public interface NotificationService { void removeTransferNotification(String accountId, Uri conversationUri, String fileId); Object getDataTransferNotification(int notificationId); - void updateNotification(Object notification, int notificationId); + //void updateNotification(Object notification, int notificationId); Object getServiceNotification(); - - - void onConnectionUpdate(Boolean b); void showLocationNotification(Account first, Contact contact); diff --git a/ring-android/libringclient/src/main/java/net/jami/services/PreferencesService.java b/ring-android/libringclient/src/main/java/net/jami/services/PreferencesService.java index f5b36bcb6..2062fdf32 100644 --- a/ring-android/libringclient/src/main/java/net/jami/services/PreferencesService.java +++ b/ring-android/libringclient/src/main/java/net/jami/services/PreferencesService.java @@ -32,11 +32,13 @@ import io.reactivex.rxjava3.subjects.Subject; public abstract class PreferencesService { - @Inject - AccountService mAccountService; + private final AccountService mAccountService; + private final DeviceRuntimeService mDeviceService; - @Inject - DeviceRuntimeService mDeviceService; + public PreferencesService(AccountService accountService, DeviceRuntimeService deviceService) { + mAccountService = accountService; + mDeviceService = deviceService; + } private Settings mUserSettings; private final Subject<Settings> mSettingsSubject = BehaviorSubject.create(); diff --git a/ring-android/libringclient/src/main/java/net/jami/services/VCardService.java b/ring-android/libringclient/src/main/java/net/jami/services/VCardService.java index 899995d75..87631ba8b 100644 --- a/ring-android/libringclient/src/main/java/net/jami/services/VCardService.java +++ b/ring-android/libringclient/src/main/java/net/jami/services/VCardService.java @@ -25,6 +25,7 @@ import net.jami.model.Account; import net.jami.utils.Tuple; import ezvcard.VCard; import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Single; public abstract class VCardService { @@ -32,7 +33,7 @@ public abstract class VCardService { public static final int MAX_SIZE_SIP = 256 * 1024; public static final int MAX_SIZE_REQUEST = 16 * 1024; - public abstract Single<Tuple<String, Object>> loadProfile(Account account); + public abstract Observable<Tuple<String, Object>> loadProfile(Account account); public abstract Maybe<VCard> loadSmallVCard(String accountId, int maxSize); public Single<VCard> loadSmallVCardWithDefault(String accountId, int maxSize) { diff --git a/ring-android/libringclient/src/main/java/net/jami/settings/SettingsPresenter.java b/ring-android/libringclient/src/main/java/net/jami/settings/SettingsPresenter.java index e71001547..d46793bd7 100644 --- a/ring-android/libringclient/src/main/java/net/jami/settings/SettingsPresenter.java +++ b/ring-android/libringclient/src/main/java/net/jami/settings/SettingsPresenter.java @@ -23,7 +23,7 @@ package net.jami.settings; import javax.inject.Inject; import javax.inject.Named; -import net.jami.facades.ConversationFacade; +import net.jami.services.ConversationFacade; import net.jami.model.Settings; import net.jami.mvp.GenericView; import net.jami.mvp.RootPresenter; diff --git a/ring-android/libringclient/src/main/java/net/jami/share/SharePresenter.java b/ring-android/libringclient/src/main/java/net/jami/share/SharePresenter.java index ec1ebb811..91021983c 100644 --- a/ring-android/libringclient/src/main/java/net/jami/share/SharePresenter.java +++ b/ring-android/libringclient/src/main/java/net/jami/share/SharePresenter.java @@ -23,6 +23,7 @@ package net.jami.share; import net.jami.services.AccountService; import javax.inject.Inject; +import javax.inject.Named; import net.jami.mvp.GenericView; import net.jami.mvp.RootPresenter; @@ -31,11 +32,11 @@ import io.reactivex.rxjava3.core.Scheduler; import io.reactivex.rxjava3.schedulers.Schedulers; public class SharePresenter extends RootPresenter<GenericView<ShareViewModel>> { - private final net.jami.services.AccountService mAccountService; + private final AccountService mAccountService; private final Scheduler mUiScheduler; @Inject - public SharePresenter(AccountService accountService, Scheduler uiScheduler) { + public SharePresenter(AccountService accountService, @Named("UiScheduler") Scheduler uiScheduler) { mAccountService = accountService; mUiScheduler = uiScheduler; } diff --git a/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListPresenter.java b/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListPresenter.java deleted file mode 100644 index 5b6b22f29..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListPresenter.java +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.smartlist; - -import net.jami.facades.ConversationFacade; -import net.jami.model.Account; -import net.jami.model.Contact; -import net.jami.model.Uri; -import net.jami.mvp.RootPresenter; -import net.jami.services.AccountService; -import net.jami.services.ContactService; -import net.jami.utils.Log; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; -import javax.inject.Named; - -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Scheduler; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.BehaviorSubject; -import io.reactivex.rxjava3.subjects.PublishSubject; -import io.reactivex.rxjava3.subjects.Subject; - -public class SmartListPresenter extends RootPresenter<SmartListView> { - - private static final String TAG = SmartListPresenter.class.getSimpleName(); - - private final AccountService mAccountService; - private final ContactService mContactService; - private final ConversationFacade mConversationFacade; - - private Account mAccount; - private final Subject<String> mCurrentQuery = BehaviorSubject.createDefault(""); - private final Subject<String> mQuery = PublishSubject.create(); - private final Observable<String> mDebouncedQuery = mQuery.debounce(350, TimeUnit.MILLISECONDS); - - private final Observable<Account> accountSubject; - - private final Scheduler mUiScheduler; - - private final CompositeDisposable mConversationDisposable = new CompositeDisposable(); - private Disposable mQueryDisposable = null; - - @Inject - public SmartListPresenter(AccountService accountService, - ContactService contactService, - ConversationFacade conversationFacade, - @Named("UiScheduler") Scheduler uiScheduler) { - mAccountService = accountService; - mContactService = contactService; - mConversationFacade = conversationFacade; - mUiScheduler = uiScheduler; - - accountSubject = mConversationFacade - .getCurrentAccountSubject() - .doOnNext(a -> mAccount = a); - } - - @Override - public void bindView(SmartListView view) { - super.bindView(view); - mCompositeDisposable.clear(); - mCompositeDisposable.add(mConversationDisposable); - loadConversations(); - } - - public void queryTextChanged(String query) { - if (query.isEmpty()) { - if (mQueryDisposable != null) { - mQueryDisposable.dispose(); - mQueryDisposable = null; - } - mCurrentQuery.onNext(query); - } else { - if (mQueryDisposable == null) { - mQueryDisposable = mDebouncedQuery.subscribe(mCurrentQuery::onNext); - } - mQuery.onNext(query); - } - } - - public void conversationClicked(SmartListViewModel viewModel) { - startConversation(viewModel.getAccountId(), viewModel.getUri()); - } - - public void conversationLongClicked(SmartListViewModel smartListViewModel) { - getView().displayConversationDialog(smartListViewModel); - } - - public String getAccountID() { - return mAccount.getAccountID(); - } - - public void fabButtonClicked() { - getView().displayMenuItem(); - } - - private void startConversation(String accountId, Uri conversationUri) { - Log.w(TAG, "startConversation " + accountId + " " + conversationUri); - SmartListView view = getView(); - if (view != null && conversationUri != null) { - view.goToConversation(accountId, conversationUri); - } - } - - public void startConversation(Uri uri) { - getView().goToConversation(mAccount.getAccountID(), uri); - } - - public void copyNumber(SmartListViewModel smartListViewModel) { - if (smartListViewModel.isSwarm()) { - // Copy first contact's URI for a swarm - // TODO other modes - Contact contact = smartListViewModel.getContacts().get(0); - getView().copyNumber(contact.getUri()); - } else { - getView().copyNumber(smartListViewModel.getUri()); - } - } - - public void clearConversation(SmartListViewModel smartListViewModel) { - getView().displayClearDialog(smartListViewModel.getUri()); - } - - public void clearConversation(final Uri uri) { - mConversationDisposable.add(mConversationFacade - .clearHistory(mAccount.getAccountID(), uri) - .subscribeOn(Schedulers.computation()).subscribe()); - } - - public void removeConversation(SmartListViewModel smartListViewModel) { - getView().displayDeleteDialog(smartListViewModel.getUri()); - } - - public void removeConversation(Uri uri) { - mConversationDisposable.add(mConversationFacade - .removeConversation(mAccount.getAccountID(), uri) - .subscribe()); - } - - public void banContact(SmartListViewModel smartListViewModel) { - mConversationFacade.banConversation(smartListViewModel.getAccountId(), smartListViewModel.getUri()); - } - public void clickQRSearch() { - getView().goToQRFragment(); - } - - void showConversations(Observable<List<Observable<SmartListViewModel>>> conversations) { - mConversationDisposable.clear(); - getView().setLoading(true); - - mConversationDisposable.add(conversations - .switchMap(viewModels -> viewModels.isEmpty() ? SmartListViewModel.EMPTY_RESULTS - : Observable.combineLatest(viewModels, obs -> { - List<SmartListViewModel> vms = new ArrayList<>(obs.length); - for (Object ob : obs) - vms.add((SmartListViewModel) ob); - return vms; - })) - .throttleLatest(150, TimeUnit.MILLISECONDS, mUiScheduler) - .observeOn(mUiScheduler) - .subscribe(viewModels -> { - final SmartListView view = getView(); - view.setLoading(false); - if (viewModels.isEmpty()) { - view.hideList(); - view.displayNoConversationMessage(); - return; - } - view.hideNoConversationMessage(); - view.updateList(viewModels, mConversationDisposable); - }, e -> Log.w(TAG, "showConversations error ", e))); - } - - private void loadConversations() { - showConversations(mConversationFacade.getFullList(accountSubject, mCurrentQuery, true)); - } - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListPresenter.kt b/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListPresenter.kt new file mode 100644 index 000000000..5787d744f --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListPresenter.kt @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.smartlist + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.PublishSubject +import io.reactivex.rxjava3.subjects.Subject +import net.jami.model.Account +import net.jami.model.Uri +import net.jami.mvp.RootPresenter +import net.jami.services.ConversationFacade +import net.jami.utils.Log +import java.util.* +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Named + +class SmartListPresenter @Inject constructor( + private val mConversationFacade: ConversationFacade, + @param:Named("UiScheduler") private val mUiScheduler: Scheduler +) : RootPresenter<SmartListView>() { + //private final CompositeDisposable mConversationDisposable = new CompositeDisposable(); + private var mQueryDisposable: Disposable? = null + private val mCurrentQuery: Subject<String> = BehaviorSubject.createDefault("") + private val mQuery: Subject<String> = PublishSubject.create() + private val mDebouncedQuery = mQuery.debounce(350, TimeUnit.MILLISECONDS) + private val accountSubject: Observable<Account> = mConversationFacade + .currentAccountSubject + .doOnNext { a: Account -> mAccount = a } + private var mAccount: Account? = null + + override fun bindView(view: SmartListView) { + super.bindView(view) + //mCompositeDisposable.clear(); + //mCompositeDisposable.add(mConversationDisposable); + showConversations(mConversationFacade.getFullList(accountSubject, mCurrentQuery, true)) + } + + fun queryTextChanged(query: String) { + if (query.isEmpty()) { + if (mQueryDisposable != null) { + mQueryDisposable!!.dispose() + mQueryDisposable = null + } + mCurrentQuery.onNext(query) + } else { + if (mQueryDisposable == null) { + mQueryDisposable = mDebouncedQuery.subscribe { t: String -> mCurrentQuery.onNext(t) } + } + mQuery.onNext(query) + } + } + + fun conversationClicked(viewModel: SmartListViewModel) { + startConversation(viewModel.accountId, viewModel.uri) + } + + fun conversationLongClicked(smartListViewModel: SmartListViewModel?) { + view!!.displayConversationDialog(smartListViewModel) + } + + fun fabButtonClicked() { + view!!.displayMenuItem() + } + + private fun startConversation(accountId: String, conversationUri: Uri?) { + Log.w(TAG, "startConversation $accountId $conversationUri") + val view = view + if (view != null && conversationUri != null) { + view.goToConversation(accountId, conversationUri) + } + } + + fun startConversation(uri: Uri?) { + view!!.goToConversation(mAccount!!.accountID, uri) + } + + fun copyNumber(smartListViewModel: SmartListViewModel) { + if (smartListViewModel.isSwarm) { + // Copy first contact's URI for a swarm + // TODO other modes + val contact = smartListViewModel.contacts[0] + view!!.copyNumber(contact.uri) + } else { + view!!.copyNumber(smartListViewModel.uri) + } + } + + fun clearConversation(smartListViewModel: SmartListViewModel) { + view!!.displayClearDialog(smartListViewModel.uri) + } + + fun clearConversation(uri: Uri?) { + mCompositeDisposable.add( + mConversationFacade + .clearHistory(mAccount!!.accountID, uri!!) + .subscribeOn(Schedulers.computation()).subscribe() + ) + } + + fun removeConversation(smartListViewModel: SmartListViewModel) { + view!!.displayDeleteDialog(smartListViewModel.uri) + } + + fun removeConversation(uri: Uri) { + mCompositeDisposable.add( + mConversationFacade.removeConversation(mAccount!!.accountID, uri) + .subscribe()) + } + + fun banContact(smartListViewModel: SmartListViewModel) { + mConversationFacade.banConversation(smartListViewModel.accountId, smartListViewModel.uri) + } + + fun clickQRSearch() { + view!!.goToQRFragment() + } + + private fun showConversations(conversations: Observable<List<Observable<SmartListViewModel>>>) { + //mConversationDisposable.clear(); + view!!.setLoading(true) + mCompositeDisposable.add(conversations + .switchMap { viewModels: List<Observable<SmartListViewModel>> -> + if (viewModels.isEmpty()) SmartListViewModel.EMPTY_RESULTS else Observable.combineLatest<SmartListViewModel, List<SmartListViewModel>>( + viewModels + ) { obs: Array<Any> -> //obs.map { it as SmartListViewModel } + val vms: MutableList<SmartListViewModel> = ArrayList(obs.size) + for (ob in obs) vms.add(ob as SmartListViewModel) + vms + }.throttleLatest(150, TimeUnit.MILLISECONDS, mUiScheduler) + } + .observeOn(mUiScheduler) + .subscribe({ viewModels: List<SmartListViewModel> -> + val view = view + view!!.setLoading(false) + if (viewModels.isEmpty()) { + view.hideList() + view.displayNoConversationMessage() + return@subscribe + } + view.hideNoConversationMessage() + view.updateList(viewModels, mCompositeDisposable) + }) { e: Throwable -> Log.w(TAG, "showConversations error ", e) }) + } + + companion object { + private val TAG = SmartListPresenter::class.java.simpleName + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListViewModel.java b/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListViewModel.java deleted file mode 100644 index 29f4140ca..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListViewModel.java +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.smartlist; - -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -import net.jami.model.Contact; -import net.jami.model.Conversation; -import net.jami.model.Interaction; -import net.jami.model.Uri; - -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; - -public class SmartListViewModel -{ - public static final Observable<SmartListViewModel> TITLE_CONVERSATIONS = Observable.just(new SmartListViewModel(Title.Conversations)); - public static final Observable<SmartListViewModel> TITLE_PUBLIC_DIR = Observable.just(new SmartListViewModel(Title.PublicDirectory)); - public static final Single<List<Observable<SmartListViewModel>>> EMPTY_LIST = Single.just(Collections.emptyList()); - public static final Observable<List<SmartListViewModel>> EMPTY_RESULTS = Observable.just(Collections.emptyList()); - - private final String accountId; - private final Uri uri; - private final List<Contact> contact; - private final String uuid; - private final String contactName; - private final boolean hasUnreadTextMessage; - private boolean hasOngoingCall; - - private final boolean showPresence; - private boolean isOnline = false; - private boolean isChecked = false; - private Observable<Boolean> isSelected = null; - private final Interaction lastEvent; - private boolean isSwarm = false; - - public enum Title { - None, - Conversations, - PublicDirectory - } - private final Title title; - - public SmartListViewModel(String accountId, Contact contact, Interaction lastEvent) { - this.accountId = accountId; - this.contact = Collections.singletonList(contact); - this.uri = contact.getUri(); - uuid = uri.getRawUriString(); - this.contactName = contact.getDisplayName(); - hasUnreadTextMessage = (lastEvent != null) && !lastEvent.isRead(); - this.hasOngoingCall = false; - this.lastEvent = lastEvent; - showPresence = true; - isOnline = contact.isOnline(); - title = Title.None; - } - public SmartListViewModel(String accountId, Contact contact, String id, Interaction lastEvent) { - this.accountId = accountId; - this.contact = Collections.singletonList(contact); - uri = contact.getUri(); - this.uuid = id; - this.contactName = contact.getDisplayName(); - hasUnreadTextMessage = (lastEvent != null) && !lastEvent.isRead(); - this.hasOngoingCall = false; - this.lastEvent = lastEvent; - showPresence = true; - isOnline = contact.isOnline(); - title = Title.None; - } - public SmartListViewModel(Conversation conversation, List<Contact> contacts, boolean presence) { - this.accountId = conversation.getAccountId(); - this.contact = contacts; - uri = conversation.getUri(); - this.uuid = uri.getRawUriString(); - this.contactName = conversation.getTitle(); - Interaction lastEvent = conversation.getLastEvent(); - hasUnreadTextMessage = (lastEvent != null) && !lastEvent.isRead(); - this.hasOngoingCall = false; - this.lastEvent = lastEvent; - isSelected = conversation.getVisible(); - isSwarm = conversation.isSwarm(); - for (Contact contact : contacts) { - if (contact.isUser()) - continue; - if (contact.isOnline()) { - isOnline = true; - break; - } - } - showPresence = presence; - title = Title.None; - } - public SmartListViewModel(Conversation conversation, boolean presence) { - this(conversation, conversation.getContacts(), presence); - } - - private SmartListViewModel(Title title) { - contactName = null; - this.accountId = null; - this.contact = null; - this.uuid = null; - uri = null; - hasUnreadTextMessage = false; - lastEvent = null; - showPresence = false; - this.title = title; - } - - public Uri getUri() { - return uri; - } - - public boolean isSwarm() { - return isSwarm; - } - - public List<Contact> getContacts() { - return contact; - } - - public String getContactName() { - return contactName; - } - - public long getLastInteractionTime() { - return (lastEvent == null) ? 0 : lastEvent.getTimestamp(); - } - - public boolean hasUnreadTextMessage() { - return hasUnreadTextMessage; - } - - public boolean hasOngoingCall() { - return hasOngoingCall; - } - - public String getUuid() { - return uuid; - } - - /*public boolean isOnline() { - return isOnline; - } - - public void setOnline(boolean online) { - if (showPresence) - isOnline = online; - }*/ - - public boolean showPresence() { - return showPresence; - } - - public boolean isOnline() { - return isOnline; - } - - public boolean isChecked() { return isChecked; } - - public void setChecked(boolean checked) { - isChecked = checked; - } - - public Observable<Boolean> getSelected() { return isSelected; } - - public void setHasOngoingCall(boolean hasOngoingCall) { - this.hasOngoingCall = hasOngoingCall; - } - - public Interaction getLastEvent() { - return lastEvent; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof SmartListViewModel)) - return false; - SmartListViewModel other = (SmartListViewModel) o; - return other.getHeaderTitle() == getHeaderTitle() - && (getHeaderTitle() != Title.None - || (contact == other.contact - && Objects.equals(contactName, other.contactName) - && isOnline == other.isOnline - && lastEvent == other.lastEvent - && hasOngoingCall == other.hasOngoingCall - && hasUnreadTextMessage == other.hasUnreadTextMessage)); - } - - public String getAccountId() { - return accountId; - } - - public Title getHeaderTitle() { - return title; - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListViewModel.kt b/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListViewModel.kt new file mode 100644 index 000000000..ded69f43b --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/smartlist/SmartListViewModel.kt @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.smartlist + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import net.jami.model.Contact +import net.jami.model.Conversation +import net.jami.model.Interaction +import net.jami.model.Uri + +class SmartListViewModel { + val accountId: String + val uri: Uri + val contacts: List<Contact> + val uuid: String? + val contactName: String? + private val hasUnreadTextMessage: Boolean + private var hasOngoingCall = false + private val showPresence: Boolean + var isOnline = false + private set + var isChecked = false + var selected: Observable<Boolean>? = null + private set + val lastEvent: Interaction? + + enum class Title { + None, Conversations, PublicDirectory + } + + val headerTitle: Title + + constructor(accountId: String, contact: Contact, lastEvent: Interaction?) { + this.accountId = accountId + contacts = listOf(contact) + uri = contact.uri + uuid = uri.rawUriString + contactName = contact.displayName + hasUnreadTextMessage = lastEvent != null && !lastEvent.isRead + hasOngoingCall = false + this.lastEvent = lastEvent + showPresence = true + isOnline = contact.isOnline + headerTitle = Title.None + } + + constructor(accountId: String, contact: Contact, id: String?, lastEvent: Interaction?) { + this.accountId = accountId + contacts = listOf(contact) + uri = contact.uri + uuid = id + contactName = contact.displayName + hasUnreadTextMessage = lastEvent != null && !lastEvent.isRead + hasOngoingCall = false + this.lastEvent = lastEvent + showPresence = true + isOnline = contact.isOnline + headerTitle = Title.None + } + + constructor(conversation: Conversation, contacts: List<Contact>, presence: Boolean) { + accountId = conversation.accountId + this.contacts = contacts + uri = conversation.uri + uuid = uri.rawUriString + contactName = conversation.title + val lastEvent = conversation.lastEvent + hasUnreadTextMessage = lastEvent != null && !lastEvent.isRead + hasOngoingCall = false + this.lastEvent = lastEvent + selected = conversation.getVisible() + for (contact in contacts) { + if (contact.isUser) continue + if (contact.isOnline) { + isOnline = true + break + } + } + showPresence = presence + headerTitle = Title.None + } + + constructor(conversation: Conversation, presence: Boolean) : this(conversation, conversation.contacts, presence) {} + + private constructor(title: Title) { + contactName = null + accountId = "" + contacts = emptyList() + uuid = null + uri = Uri() + hasUnreadTextMessage = false + lastEvent = null + showPresence = false + headerTitle = title + } + + val isSwarm: Boolean + get() = uri.isSwarm + + /** + * Used to get contact for one to one or legacy conversations + */ + fun getContact(): Contact? { + if (contacts!!.size == 1) return contacts[0] + for (c in contacts) { + if (!c.isUser) return c + } + return null + } + + val lastInteractionTime: Long + get() = lastEvent?.timestamp ?: 0 + + fun hasUnreadTextMessage(): Boolean { + return hasUnreadTextMessage + } + + fun hasOngoingCall(): Boolean { + return hasOngoingCall + } + + /*public boolean isOnline() { + return isOnline; + } + + public void setOnline(boolean online) { + if (showPresence) + isOnline = online; + }*/ + fun showPresence(): Boolean { + return showPresence + } + + fun setHasOngoingCall(hasOngoingCall: Boolean) { + this.hasOngoingCall = hasOngoingCall + } + + override fun equals(other: Any?): Boolean { + if (other !is SmartListViewModel) return false + return (other.headerTitle == headerTitle + && (headerTitle != Title.None + || (contacts === other.contacts && contactName == other.contactName + && isOnline == other.isOnline && lastEvent === other.lastEvent && hasOngoingCall == other.hasOngoingCall && hasUnreadTextMessage == other.hasUnreadTextMessage))) + } + + companion object { + val TITLE_CONVERSATIONS: Observable<SmartListViewModel> = Observable.just(SmartListViewModel(Title.Conversations)) + val TITLE_PUBLIC_DIR: Observable<SmartListViewModel> = Observable.just(SmartListViewModel(Title.PublicDirectory)) + val EMPTY_LIST: Single<List<Observable<SmartListViewModel>>> = Single.just(emptyList()) + val EMPTY_RESULTS: Observable<List<SmartListViewModel>> = Observable.just(emptyList()) + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/FileUtils.java b/ring-android/libringclient/src/main/java/net/jami/utils/FileUtils.kt similarity index 50% rename from ring-android/libringclient/src/main/java/net/jami/utils/FileUtils.java rename to ring-android/libringclient/src/main/java/net/jami/utils/FileUtils.kt index c985305e2..8e028d39e 100644 --- a/ring-android/libringclient/src/main/java/net/jami/utils/FileUtils.java +++ b/ring-android/libringclient/src/main/java/net/jami/utils/FileUtils.kt @@ -16,56 +16,56 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ -package net.jami.utils; +package net.jami.utils -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; +import java.io.* +import kotlin.Throws -public class FileUtils { - private static final String TAG = FileUtils.class.getSimpleName(); +object FileUtils { + private val TAG = FileUtils::class.simpleName!! - public static void copyFile(InputStream in, OutputStream out) throws IOException { + @Throws(IOException::class) + fun copyFile(input: InputStream, out: OutputStream) { // Buffer size based on https://stackoverflow.com/questions/10143731/android-optimal-buffer-size - byte[] buffer = new byte[64 * 1024]; - int read; - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); + val buffer = ByteArray(64 * 1024) + var read: Int + while (input.read(buffer).also { read = it } != -1) { + out.write(buffer, 0, read) } } - public static boolean copyFile(File src, File dest) { - try (InputStream inputStream = new FileInputStream(src); - FileOutputStream outputStream = new FileOutputStream(dest)) { - copyFile(inputStream, outputStream); - } catch (IOException e) { - Log.w(TAG, "Can't copy file", e); - return false; + fun copyFile(src: File, dest: File): Boolean { + try { + FileInputStream(src).use { inputStream -> + FileOutputStream(dest).use { outputStream -> + copyFile(inputStream, outputStream) + } + } + } catch (e: IOException) { + Log.w(TAG, "Can't copy file", e) + return false } - return true; + return true } - public static boolean moveFile(File file, File dest) { + @JvmStatic + fun moveFile(file: File, dest: File): Boolean { if (!file.exists() || !file.canRead()) { - Log.d(TAG, "moveFile: file is not accessible " + file.exists() + " " + file.canRead()); - return false; + Log.d(TAG, "moveFile: file is not accessible " + file.exists() + " " + file.canRead()) + return false } - if (file.equals(dest)) - return true; + if (file == dest) return true if (!file.renameTo(dest)) { - Log.w(TAG, "moveFile: can't rename file, trying copy+delete to " + dest); + Log.w(TAG, "moveFile: can't rename file, trying copy+delete to $dest") if (!copyFile(file, dest)) { - Log.w(TAG, "moveFile: can't copy file to " + dest); - return false; + Log.w(TAG, "moveFile: can't copy file to $dest") + return false } if (!file.delete()) { - Log.w(TAG, "moveFile: can't delete old file from " + file); + Log.w(TAG, "moveFile: can't delete old file from $file") } } - Log.d(TAG, "moveFile: moved " + file + " to " + dest); - return true; + Log.d(TAG, "moveFile: moved $file to $dest") + return true } -} +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/HashUtils.java b/ring-android/libringclient/src/main/java/net/jami/utils/HashUtils.java deleted file mode 100644 index 7cf554977..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/utils/HashUtils.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Pierre Duchemin <pierre.duchemin@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ - -package net.jami.utils; - -import java.math.BigInteger; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HashSet; -import java.util.Set; - -public class HashUtils { - - private static final String TAG = HashUtils.class.getSimpleName(); - - private HashUtils() { - } - - public static String md5(String s) { - return hash(s, "MD5"); - } - - public static String sha1(String s) { - return hash(s, "SHA-1"); - } - - private static String hash(final String s, final String algo) { - String result = null; - try { - MessageDigest messageDigest = MessageDigest.getInstance(algo); - messageDigest.update(s.getBytes(), 0, s.length()); - result = new BigInteger(1, messageDigest.digest()).toString(16); - } catch (NoSuchAlgorithmException e) { - Log.e(TAG, "Not able to find MD5 algorithm", e); - } - return result; - } - - @SafeVarargs - public static <T> Set<T> asSet(T... items) { - HashSet<T> s = new HashSet<>(items.length); - for (T t : items) - s.add(t); - return s; - } -} diff --git a/ring-android/app/src/main/java/cx/ring/utils/DeviceUtils.java b/ring-android/libringclient/src/main/java/net/jami/utils/HashUtils.kt similarity index 51% rename from ring-android/app/src/main/java/cx/ring/utils/DeviceUtils.java rename to ring-android/libringclient/src/main/java/net/jami/utils/HashUtils.kt index fc4cf8905..c2a2da1d6 100644 --- a/ring-android/app/src/main/java/cx/ring/utils/DeviceUtils.java +++ b/ring-android/libringclient/src/main/java/net/jami/utils/HashUtils.kt @@ -17,34 +17,31 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ +package net.jami.utils -package cx.ring.utils; +import java.math.BigInteger +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException -import android.app.UiModeManager; -import android.content.Context; -import android.content.res.Configuration; - -import androidx.annotation.NonNull; - -import net.jami.utils.Log; - -import cx.ring.R; - -import static android.content.Context.UI_MODE_SERVICE; - -public class DeviceUtils { - - private static final String TAG = DeviceUtils.class.getSimpleName(); - - private DeviceUtils() { +object HashUtils { + private val TAG = HashUtils::class.java.simpleName + fun md5(s: String): String? { + return hash(s, "MD5") } - public static boolean isTv(@NonNull Context context) { - UiModeManager uiModeManager = (UiModeManager) context.getSystemService(UI_MODE_SERVICE); - return (uiModeManager != null && uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION); + fun sha1(s: String): String? { + return hash(s, "SHA-1") } - public static boolean isTablet(@NonNull Context context) { - return context.getResources().getBoolean(R.bool.isTablet); + private fun hash(s: String, algo: String): String? { + var result: String? = null + try { + val messageDigest = MessageDigest.getInstance(algo) + messageDigest.update(s.toByteArray(), 0, s.length) + result = BigInteger(1, messageDigest.digest()).toString(16) + } catch (e: NoSuchAlgorithmException) { + Log.e(TAG, "Not able to find MD5 algorithm", e) + } + return result } -} +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/Log.java b/ring-android/libringclient/src/main/java/net/jami/utils/Log.java index 0267aee41..e075cb145 100644 --- a/ring-android/libringclient/src/main/java/net/jami/utils/Log.java +++ b/ring-android/libringclient/src/main/java/net/jami/utils/Log.java @@ -22,11 +22,9 @@ package net.jami.utils; import net.jami.services.LogService; public class Log { + private static LogService mLogService; - - private static net.jami.services.LogService mLogService; - - public static void injectLogService (LogService service) { + public static void injectLogService(LogService service) { mLogService = service; } diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/NameLookupInputHandler.java b/ring-android/libringclient/src/main/java/net/jami/utils/NameLookupInputHandler.java index 0d2593597..3eeb57eda 100644 --- a/ring-android/libringclient/src/main/java/net/jami/utils/NameLookupInputHandler.java +++ b/ring-android/libringclient/src/main/java/net/jami/utils/NameLookupInputHandler.java @@ -26,12 +26,12 @@ import java.util.TimerTask; public class NameLookupInputHandler { private static final int WAIT_DELAY = 350; - private final WeakReference<net.jami.services.AccountService> mAccountService; + private final WeakReference<AccountService> mAccountService; private final String mAccountId; private final Timer timer = new Timer(true); private NameTask lastTask = null; - public NameLookupInputHandler(net.jami.services.AccountService accountService, String accountId) { + public NameLookupInputHandler(AccountService accountService, String accountId) { mAccountService = new WeakReference<>(accountService); mAccountId = accountId; } diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/ProfileChunk.java b/ring-android/libringclient/src/main/java/net/jami/utils/ProfileChunk.kt similarity index 50% rename from ring-android/libringclient/src/main/java/net/jami/utils/ProfileChunk.java rename to ring-android/libringclient/src/main/java/net/jami/utils/ProfileChunk.kt index 0c2530379..ca8d87547 100644 --- a/ring-android/libringclient/src/main/java/net/jami/utils/ProfileChunk.java +++ b/ring-android/libringclient/src/main/java/net/jami/utils/ProfileChunk.kt @@ -17,33 +17,14 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ +package net.jami.utils -package net.jami.utils; +import java.lang.StringBuilder -import net.jami.daemon.StringVect; - -public class ProfileChunk { - public final static String TAG = ProfileChunk.class.getSimpleName(); - - private long mNumberOfParts; - private long mInsertedParts; - private StringVect mParts; - - /** - * Constructor - * - * @param numberOfParts Number of part to complete the Profile - */ - public ProfileChunk(long numberOfParts) { - net.jami.utils.Log.d(TAG, "Create ProfileChink of size " + numberOfParts); - this.mInsertedParts = 0; - this.mNumberOfParts = numberOfParts; - this.mParts = new StringVect(); - this.mParts.reserve(mNumberOfParts); - for (int i = 0; i < mNumberOfParts; i++) { - this.mParts.add(""); - } - } +class ProfileChunk(private val numberOfParts: Int) { + private var mTotalSize = 0 + private var mInsertedParts: Int = 0 + private val mParts: MutableList<String> = ArrayList(numberOfParts) /** * Inserts a profile part in the data structure, at a given position @@ -51,10 +32,11 @@ public class ProfileChunk { * @param part the part to insert * @param index the given position to insert the part */ - public void addPartAtIndex(String part, int index) { - mParts.set(index - 1, part); - mInsertedParts++; - Log.d(TAG, "Inserting part " + part + " at index " + index); + fun addPartAtIndex(part: String, index: Int) { + mParts[index - 1] = part + mTotalSize += part.length + mInsertedParts++ + Log.d(TAG, "Inserting part $part at index $index") } /** @@ -62,24 +44,33 @@ public class ProfileChunk { * * @return true if complete, false otherwise */ - public boolean isProfileComplete() { - return this.mInsertedParts == this.mNumberOfParts; - } + val isProfileComplete: Boolean + get() = mInsertedParts == numberOfParts /** * Builds the profile based on the gathered parts. * * @return the complete profile as a String */ - public String getCompleteProfile() { - if (this.isProfileComplete()) { - StringBuilder stringBuilder = new StringBuilder(); - for (int i = 0; i < this.mParts.size(); ++i) { - stringBuilder.append(this.mParts.get(i)); + val completeProfile: String? + get() = if (isProfileComplete) { + val stringBuilder = StringBuilder(mTotalSize) + for (part in mParts) { + stringBuilder.append(part) } - return stringBuilder.toString(); + stringBuilder.toString() } else { - return null; + null + } + + companion object { + val TAG = ProfileChunk::class.simpleName!! + } + + init { + Log.d(TAG, "Create ProfileChink of size $numberOfParts") + for (i in 0 until numberOfParts) { + mParts.add("") } } } \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/StringUtils.java b/ring-android/libringclient/src/main/java/net/jami/utils/StringUtils.java deleted file mode 100644 index ed7642de3..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/utils/StringUtils.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> - * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ -package net.jami.utils; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Set; - -public final class StringUtils { - - static private final Set<Character.UnicodeBlock> EMOJI_BLOCKS = new HashSet<>(Arrays.asList( - Character.UnicodeBlock.EMOTICONS, - Character.UnicodeBlock.DINGBATS, - Character.UnicodeBlock.MISCELLANEOUS_SYMBOLS, - Character.UnicodeBlock.MISCELLANEOUS_SYMBOLS_AND_PICTOGRAPHS, - Character.UnicodeBlock.MISCELLANEOUS_SYMBOLS_AND_ARROWS, - Character.UnicodeBlock.ALCHEMICAL_SYMBOLS, - Character.UnicodeBlock.ARROWS, - Character.UnicodeBlock.ENCLOSED_ALPHANUMERIC_SUPPLEMENT, - Character.UnicodeBlock.TRANSPORT_AND_MAP_SYMBOLS, - Character.UnicodeBlock.VARIATION_SELECTORS // Ignore modifier - )); - - public static boolean isEmpty(String s) { - return s == null || s.isEmpty(); - } - - public static boolean isEmpty(CharSequence s) { - return s == null || s.length() == 0; - } - - public static String capitalize(String s) { - if (isEmpty(s)) { - return ""; - } - char first = s.charAt(0); - if (Character.isUpperCase(first)) { - return s; - } else { - return Character.toUpperCase(first) + s.substring(1); - } - } - public static String toPassword(String s){ - if(isEmpty(s)){ - return ""; - } - char[] chars = new char[s.length()]; - Arrays.fill(chars, '*'); - return new String(chars); - } - - public static String toNumber(String s) { - if (s == null) - return null; - return s.replace("(", "") - .replace(")", "") - .replace("-", "") - .replace(" ", ""); - } - - public static String getFileExtension(String filename) { - int dot = filename.lastIndexOf('.'); - if (dot == -1 || dot == 0) - return ""; - return filename.substring(dot + 1); - } - - public static Iterable<Integer> codePoints(final String string) { - return () -> new Iterator<Integer>() { - int nextIndex = 0; - public boolean hasNext() { - return nextIndex < string.length(); - } - public Integer next() { - int result = string.codePointAt(nextIndex); - nextIndex += Character.charCount(result); - return result; - } - public void remove() { - throw new UnsupportedOperationException(); - } - }; - } - - public static boolean isOnlyEmoji(final String message) { - if (message == null || message.isEmpty()) { - return false; - } - for (int codePoint : StringUtils.codePoints(message)) { - if (Character.isWhitespace(codePoint)) { - continue; - } - // Common Emoji range: https://en.wikipedia.org/wiki/Unicode_block - if (codePoint >= 0x1F000 && codePoint < 0x20000) { - continue; - } - Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint); - if (!EMOJI_BLOCKS.contains(block)) { - return false; - } - } - return true; - } - - public static String join(String separator, List<String> values) { - if (values.isEmpty()) return "";//need at least one element - if (values.size() == 1) return values.get(0); - //all string operations use a new array, so minimize all calls possible - char[] sep = separator.toCharArray(); - - // determine final size and normalize nulls - int totalSize = (values.size() - 1) * sep.length;// separator size - for (int i = 0; i < values.size(); i++) { - totalSize += values.get(i).length(); - } - - //exact size; no bounds checks or resizes - char[] joined = new char[totalSize]; - int pos = 0; - //note, we are iterating all the elements except the last one - for (int i = 0, end = values.size()-1; i < end; i++) { - System.arraycopy(values.get(i).toCharArray(), 0, - joined, pos, values.get(i).length()); - pos += values.get(i).length(); - System.arraycopy(sep, 0, joined, pos, sep.length); - pos += sep.length; - } - //now, add the last element; - //this is why we checked values.length == 0 off the hop - System.arraycopy(values.get(values.size()-1).toCharArray(), 0, - joined, pos, values.get(values.size()-1).length()); - - return new String(joined); - } - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/StringUtils.kt b/ring-android/libringclient/src/main/java/net/jami/utils/StringUtils.kt new file mode 100644 index 000000000..45d71562b --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/utils/StringUtils.kt @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Hadrien De Sousa <hadrien.desousa@savoirfairelinux.com> + * Author: Adrien Beraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.utils + +import java.util.* + +object StringUtils { + private val EMOJI_BLOCKS: Set<Character.UnicodeBlock> = HashSet(listOf( + Character.UnicodeBlock.EMOTICONS, + Character.UnicodeBlock.DINGBATS, + Character.UnicodeBlock.MISCELLANEOUS_SYMBOLS, + Character.UnicodeBlock.MISCELLANEOUS_SYMBOLS_AND_PICTOGRAPHS, + Character.UnicodeBlock.MISCELLANEOUS_SYMBOLS_AND_ARROWS, + Character.UnicodeBlock.ALCHEMICAL_SYMBOLS, + Character.UnicodeBlock.ARROWS, + Character.UnicodeBlock.ENCLOSED_ALPHANUMERIC_SUPPLEMENT, + Character.UnicodeBlock.TRANSPORT_AND_MAP_SYMBOLS, + Character.UnicodeBlock.VARIATION_SELECTORS // Ignore modifier + )) + + @JvmStatic + fun isEmpty(s: String?): Boolean { + return s == null || s.isEmpty() + } + + @JvmStatic + fun isEmpty(s: CharSequence?): Boolean { + return s == null || s.isEmpty() + } + + fun capitalize(s: String): String { + if (isEmpty(s)) { + return "" + } + val first = s[0] + return if (Character.isUpperCase(first)) { + s + } else { + Character.toUpperCase(first).toString() + s.substring(1) + } + } + + @JvmStatic + fun toPassword(s: String): String { + if (isEmpty(s)) { + return "" + } + val chars = CharArray(s.length) + Arrays.fill(chars, '*') + return String(chars) + } + + @JvmStatic + fun toNumber(s: String?): String? { + return s?.replace("(", "")?.replace(")", "")?.replace("-", "")?.replace(" ", "") + } + + @JvmStatic + fun getFileExtension(filename: String): String { + val dot = filename.lastIndexOf('.') + return if (dot == -1 || dot == 0) "" else filename.substring(dot + 1) + } + + private fun codePoints(string: String): Iterable<Int> { + return Iterable { + object : Iterator<Int> { + var nextIndex = 0 + override fun hasNext(): Boolean { + return nextIndex < string.length + } + + override fun next(): Int { + val result = string.codePointAt(nextIndex) + nextIndex += Character.charCount(result) + return result + } + } + } + } + + @JvmStatic + fun isOnlyEmoji(message: String?): Boolean { + if (message == null || message.isEmpty()) { + return false + } + for (codePoint in codePoints(message)) { + if (Character.isWhitespace(codePoint)) { + continue + } + // Common Emoji range: https://en.wikipedia.org/wiki/Unicode_block + if (codePoint in 0x1F000..0x1ffff) { + continue + } + val block = Character.UnicodeBlock.of(codePoint) + if (!EMOJI_BLOCKS.contains(block)) { + return false + } + } + return true + } + + fun join(separator: String, values: List<String>): String { + if (values.isEmpty()) return "" //need at least one element + if (values.size == 1) return values[0] + //all string operations use a new array, so minimize all calls possible + val sep = separator.toCharArray() + + // determine final size and normalize nulls + var totalSize = (values.size - 1) * sep.size // separator size + for (i in values.indices) { + totalSize += values[i].length + } + + //exact size; no bounds checks or resizes + val joined = CharArray(totalSize) + var pos = 0 + //note, we are iterating all the elements except the last one + var i = 0 + val end = values.size - 1 + while (i < end) { + System.arraycopy(values[i].toCharArray(), 0, joined, pos, values[i].length) + pos += values[i].length + System.arraycopy(sep, 0, joined, pos, sep.size) + pos += sep.size + i++ + } + //now, add the last element; + //this is why we checked values.length == 0 off the hop + System.arraycopy(values[values.size - 1].toCharArray(), 0, joined, pos, values[values.size - 1].length) + return String(joined) + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/SwigNativeConverter.java b/ring-android/libringclient/src/main/java/net/jami/utils/SwigNativeConverter.java deleted file mode 100644 index f4cf2116f..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/utils/SwigNativeConverter.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ - -package net.jami.utils; - -import net.jami.daemon.MessageVect; -import net.jami.daemon.StringMap; -import net.jami.daemon.StringVect; -import net.jami.daemon.VectMap; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import net.jami.daemon.Message; - -public class SwigNativeConverter { - - public static net.jami.daemon.VectMap toSwig(List<Map<String, String>> creds) { - net.jami.daemon.VectMap toReturn = new VectMap(); - toReturn.reserve(creds.size()); - for (Map<String, String> aTodecode : creds) { - toReturn.add(StringMap.toSwig(aTodecode)); - } - return toReturn; - } - - public static ArrayList<String> toJava(StringVect vector) { - return new ArrayList<>(vector); - } - - public static ArrayList<Message> toJava(MessageVect vector) { - int size = vector.size(); - ArrayList<Message> toReturn = new ArrayList<>(size); - for (int i = 0; i < size; i++) - toReturn.add(vector.get(i)); - return toReturn; - } - -} diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/SwigNativeConverter.kt b/ring-android/libringclient/src/main/java/net/jami/utils/SwigNativeConverter.kt new file mode 100644 index 000000000..486d9ed93 --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/utils/SwigNativeConverter.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Alexandre Lision <alexandre.lision@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.utils + +import net.jami.daemon.* +import java.util.ArrayList + +object SwigNativeConverter { + fun toSwig(creds: List<Map<String, String>>): VectMap { + val toReturn = VectMap() + toReturn.reserve(creds.size.toLong()) + for (aTodecode in creds) { + toReturn.add(StringMap.toSwig(aTodecode)) + } + return toReturn + } + + fun toJava(vector: StringVect): ArrayList<String> { + return ArrayList(vector) + } + + fun toJava(vector: MessageVect): ArrayList<Message> { + val size = vector.size + val toReturn = ArrayList<Message>(size) + for (i in 0 until size) toReturn.add(vector[i]) + return toReturn + } +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/Tuple.java b/ring-android/libringclient/src/main/java/net/jami/utils/Tuple.kt similarity index 53% rename from ring-android/libringclient/src/main/java/net/jami/utils/Tuple.java rename to ring-android/libringclient/src/main/java/net/jami/utils/Tuple.kt index d7c26c089..d60d4c6a0 100644 --- a/ring-android/libringclient/src/main/java/net/jami/utils/Tuple.java +++ b/ring-android/libringclient/src/main/java/net/jami/utils/Tuple.kt @@ -17,45 +17,34 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ -package net.jami.utils; +package net.jami.utils -public class Tuple<X, Y> { - public final X first; - public final Y second; +class Tuple<X, Y>(@JvmField val first: X, @JvmField val second: Y) { - public Tuple(X first, Y second) { - this.first = first; - this.second = second; + override fun toString(): String { + return "($first,$second)" } - @Override - public String toString() { - return "(" + first + "," + second + ")"; - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; + override fun equals(other: Any?): Boolean { + if (other === this) { + return true } - - if (!(other instanceof Tuple)) { - return false; + if (other !is Tuple<*, *>) { + return false } - - Tuple<X, Y> other_ = (Tuple<X, Y>) other; + val other_ = other as Tuple<X, Y> // this may cause NPE if nulls are valid values for first or second. // The logic may be improved to handle nulls properly, if needed. - return other_.first.equals(this.first) && other_.second.equals(this.second); + return other_.first == first && other_.second == second } - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ((first == null) ? 0 : first.hashCode()); - result = prime * result + ((second == null) ? 0 : second.hashCode()); - return result; + override fun hashCode(): Int { + val prime = 31 + var result = 1 + result = prime * result + (first?.hashCode() ?: 0) + result = prime * result + (second?.hashCode() ?: 0) + return result } -} + +} \ No newline at end of file diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/VCardUtils.java b/ring-android/libringclient/src/main/java/net/jami/utils/VCardUtils.java deleted file mode 100644 index bb9c56335..000000000 --- a/ring-android/libringclient/src/main/java/net/jami/utils/VCardUtils.java +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright (C) 2004-2021 Savoir-faire Linux Inc. - * - * Author: Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ - -package net.jami.utils; - -import java.io.File; -import java.io.IOException; -import java.io.StringWriter; -import java.util.HashMap; - -import ezvcard.Ezvcard; -import ezvcard.VCard; -import ezvcard.VCardVersion; -import ezvcard.io.text.VCardWriter; -import ezvcard.parameter.ImageType; -import ezvcard.property.FormattedName; -import ezvcard.property.Photo; -import ezvcard.property.RawProperty; -import ezvcard.property.Uid; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class VCardUtils { - public static final String TAG = VCardUtils.class.getSimpleName(); - - public static final String MIME_PROFILE_VCARD = "x-ring/ring.profile.vcard"; - public static final String VCARD_KEY_MIME_TYPE = "mimeType"; - public static final String VCARD_KEY_PART = "part"; - public static final String VCARD_KEY_OF = "of"; - - public static final String LOCAL_USER_VCARD_NAME = "profile.vcf"; - private static final long VCARD_MAX_SIZE = 1024 * 1024 * 8; - - private VCardUtils() { - // Hidden default constructor - } - - public static Tuple<String, byte[]> readData(VCard vcard) { - String contactName = null; - byte[] photo = null; - if (vcard != null) { - if (!vcard.getPhotos().isEmpty()) { - try { - photo = vcard.getPhotos().get(0).getData(); - } catch (Exception e) { - Log.w(TAG, "Can't read photo from VCard", e); - photo = null; - } - } - FormattedName fname = vcard.getFormattedName(); - if (fname != null) { - if (!StringUtils.isEmpty(fname.getValue())) { - contactName = fname.getValue(); - } - } - } - return new Tuple<>(contactName, photo); - } - - public static VCard writeData(String uri, String displayName, byte[] picture) { - VCard vcard = new VCard(); - vcard.setFormattedName(new FormattedName(displayName)); - vcard.setUid(new Uid(uri)); - if (picture != null) { - vcard.addPhoto(new Photo(picture, ImageType.JPEG)); - } - vcard.removeProperties(RawProperty.class); - return vcard; - } - - /** - * Parse the "elements" of the mime attributes to build a proper hashtable - * - * @param mime the mimetype as returned by the daemon - * @return a correct hashtable, null if invalid input - */ - public static HashMap<String, String> parseMimeAttributes(String mime) { - String[] elements = mime.split(";"); - HashMap<String, String> messageKeyValue = new HashMap<>(); - if (elements.length < 2) { - return messageKeyValue; - } - messageKeyValue.put(VCARD_KEY_MIME_TYPE, elements[0]); - String[] pairs = elements[1].split(","); - for (String pair : pairs) { - String[] kv = pair.split("="); - messageKeyValue.put(kv[0].trim(), kv[1]); - } - return messageKeyValue; - } - - public static void savePeerProfileToDisk(VCard vcard, String accountId, String filename, File filesDir) { - saveToDisk(vcard, filename, peerProfilePath(filesDir, accountId)); - } - - public static Single<VCard> saveLocalProfileToDisk(VCard vcard, String accountId, File filesDir) { - return Single.fromCallable(() -> { - saveToDisk(vcard, LOCAL_USER_VCARD_NAME, localProfilePath(filesDir, accountId)); - return vcard; - }); - } - - /** - * Saves a vcard string to an internal new vcf file. - * - * @param vcard the VCard to save - * @param filename the filename of the vcf - * @param path the path of the vcf - */ - private static void saveToDisk(VCard vcard, String filename, File path) { - if (vcard == null || StringUtils.isEmpty(filename)) { - return; - } - if (!path.exists()) { - path.mkdirs(); - } - - File file = new File(path, filename); - try (VCardWriter writer = new VCardWriter(file, VCardVersion.V2_1)) { - writer.getVObjectWriter().getFoldedLineWriter().setLineLength(null); - writer.write(vcard); - } catch (Exception e) { - Log.e(TAG, "Error while saving VCard to disk", e); - } - } - - public static VCard loadPeerProfileFromDisk(File filesDir, String filename, String accountId) throws IOException { - File profileFolder = peerProfilePath(filesDir, accountId); - return loadFromDisk(new File(profileFolder, filename)); - } - - public static Single<VCard> loadLocalProfileFromDisk(File filesDir, String accountId) { - return Single.fromCallable(() -> { - String path = localProfilePath(filesDir, accountId).getAbsolutePath(); - return loadFromDisk(new File(path, LOCAL_USER_VCARD_NAME)); - }); - } - - public static Single<VCard> loadLocalProfileFromDiskWithDefault(File filesDir, String accountId) { - return loadLocalProfileFromDisk(filesDir, accountId) - .onErrorReturn(e -> setupDefaultProfile(filesDir, accountId)); - } - - /** - * Loads the vcard file from the disk - * - * @param path the filename of the vcard - * @return the VCard or null - */ - private static VCard loadFromDisk(File path) throws IOException { - if (path == null || !path.exists()) { - // Log.d(TAG, "vcardPath not exist " + path); - return null; - } - if (path.length() > VCARD_MAX_SIZE) { - Log.w(TAG, "vcardPath too big: " + path.length() / 1024 + " kB"); - return null; - } - return Ezvcard.parse(path).first(); - } - - public static String vcardToString(VCard vcard) { - StringWriter writer = new StringWriter(); - VCardWriter vcwriter = new VCardWriter(writer, VCardVersion.V2_1); - vcwriter.getVObjectWriter().getFoldedLineWriter().setLineLength(null); - String stringVCard; - try { - vcwriter.write(vcard); - stringVCard = writer.toString(); - vcwriter.close(); - writer.close(); - } catch (Exception e) { - Log.e(TAG, "Error while converting VCard to String", e); - stringVCard = null; - } - - return stringVCard; - } - - public static boolean isEmpty(VCard vCard) { - FormattedName name = vCard.getFormattedName(); - return (name == null || name.getValue().isEmpty()) && vCard.getPhotos().isEmpty(); - } - - private static File peerProfilePath(File filesDir, String accountId) { - File accountDir = new File(filesDir, accountId); - File profileDir = new File(accountDir, "profiles"); - profileDir.mkdirs(); - return profileDir; - } - - private static File localProfilePath(File filesDir, String accountId) { - File accountDir = new File(filesDir, accountId); - accountDir.mkdir(); - return accountDir; - } - - private static VCard setupDefaultProfile(File filesDir, String accountId) { - VCard vcard = new VCard(); - vcard.setUid(new Uid(accountId)); - saveLocalProfileToDisk(vcard, accountId, filesDir) - .subscribeOn(Schedulers.io()) - .subscribe(vc -> {}, e -> Log.e(TAG, "Error while saving vcard", e)); - return vcard; - } - - public static Single<VCard> peerProfileReceived(File filesDir, String accountId, String peerId, File vcard) { - return Single.fromCallable(() -> { - String filename = peerId + ".vcf"; - File peerProfilePath = VCardUtils.peerProfilePath(filesDir, accountId); - File file = new File(peerProfilePath, filename); - FileUtils.moveFile(vcard, file); - return VCardUtils.loadFromDisk(file); - }).subscribeOn(Schedulers.io()); - } -} diff --git a/ring-android/libringclient/src/main/java/net/jami/utils/VCardUtils.kt b/ring-android/libringclient/src/main/java/net/jami/utils/VCardUtils.kt new file mode 100644 index 000000000..4cd45da4d --- /dev/null +++ b/ring-android/libringclient/src/main/java/net/jami/utils/VCardUtils.kt @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2004-2021 Savoir-faire Linux Inc. + * + * Author: Romain Bertozzi <romain.bertozzi@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +package net.jami.utils + +import net.jami.utils.StringUtils.isEmpty +import net.jami.utils.FileUtils.moveFile +import ezvcard.VCard +import ezvcard.property.FormattedName +import ezvcard.property.Uid +import ezvcard.property.RawProperty +import ezvcard.io.text.VCardWriter +import ezvcard.VCardVersion +import kotlin.Throws +import ezvcard.Ezvcard +import ezvcard.parameter.ImageType +import ezvcard.property.Photo +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import java.io.File +import java.io.IOException +import java.io.StringWriter +import java.lang.Exception +import java.util.HashMap + +object VCardUtils { + val TAG = VCardUtils::class.simpleName + const val MIME_PROFILE_VCARD = "x-ring/ring.profile.vcard" + const val VCARD_KEY_MIME_TYPE = "mimeType" + const val VCARD_KEY_PART = "part" + const val VCARD_KEY_OF = "of" + const val LOCAL_USER_VCARD_NAME = "profile.vcf" + private const val VCARD_MAX_SIZE = 1024L * 1024L * 8 + + fun readData(vcard: VCard?): Tuple<String?, ByteArray?> { + var contactName: String? = null + var photo: ByteArray? = null + if (vcard != null) { + if (vcard.photos.isNotEmpty()) { + photo = try { + vcard.photos[0].data + } catch (e: Exception) { + Log.w(TAG, "Can't read photo from VCard", e) + null + } + } + val fname = vcard.formattedName + if (fname != null) { + if (!isEmpty(fname.value)) { + contactName = fname.value + } + } + } + return Tuple(contactName, photo) + } + + fun writeData(uri: String?, displayName: String?, picture: ByteArray?): VCard { + val vcard = VCard() + vcard.formattedName = FormattedName(displayName) + vcard.uid = Uid(uri) + if (picture != null) { + vcard.addPhoto(Photo(picture, ImageType.JPEG)) + } + vcard.removeProperties(RawProperty::class.java) + return vcard + } + + /** + * Parse the "elements" of the mime attributes to build a proper hashtable + * + * @param mime the mimetype as returned by the daemon + * @return a correct hashtable, null if invalid input + */ + fun parseMimeAttributes(mime: String): HashMap<String, String> { + val elements = mime.split(";".toRegex()).toTypedArray() + val messageKeyValue = HashMap<String, String>() + if (elements.size < 2) { + return messageKeyValue + } + messageKeyValue[VCARD_KEY_MIME_TYPE] = elements[0] + val pairs = elements[1].split(",".toRegex()).toTypedArray() + for (pair in pairs) { + val kv = pair.split("=".toRegex()).toTypedArray() + messageKeyValue[kv[0].trim { it <= ' ' }] = kv[1] + } + return messageKeyValue + } + + fun savePeerProfileToDisk(vcard: VCard?, accountId: String, filename: String, filesDir: File) { + saveToDisk(vcard, filename, peerProfilePath(filesDir, accountId)) + } + + @JvmStatic + fun saveLocalProfileToDisk(vcard: VCard, accountId: String, filesDir: File): Single<VCard> { + return Single.fromCallable { + saveToDisk(vcard, LOCAL_USER_VCARD_NAME, localProfilePath(filesDir, accountId)) + vcard + } + } + + /** + * Saves a vcard string to an internal new vcf file. + * + * @param vcard the VCard to save + * @param filename the filename of the vcf + * @param path the path of the vcf + */ + private fun saveToDisk(vcard: VCard?, filename: String, path: File) { + if (vcard == null || isEmpty(filename)) { + return + } + if (!path.exists()) { + path.mkdirs() + } + val file = File(path, filename) + try { + VCardWriter(file, VCardVersion.V2_1).use { writer -> + writer.vObjectWriter.foldedLineWriter.lineLength = null + writer.write(vcard) + } + } catch (e: Exception) { + Log.e(TAG, "Error while saving VCard to disk", e) + } + } + + @Throws(IOException::class) + fun loadPeerProfileFromDisk(filesDir: File, filename: String?, accountId: String): VCard? { + val profileFolder = peerProfilePath(filesDir, accountId) + return loadFromDisk(File(profileFolder, filename)) + } + + fun loadLocalProfileFromDisk(filesDir: File, accountId: String): Single<VCard> { + return Single.fromCallable { + val path = localProfilePath(filesDir, accountId).absolutePath + loadFromDisk(File(path, LOCAL_USER_VCARD_NAME)) + } + } + + @JvmStatic + fun loadLocalProfileFromDiskWithDefault(filesDir: File, accountId: String): Single<VCard> { + return loadLocalProfileFromDisk(filesDir, accountId) + .onErrorReturn { e: Throwable? -> setupDefaultProfile(filesDir, accountId) } + } + + /** + * Loads the vcard file from the disk + * + * @param path the filename of the vcard + * @return the VCard or null + */ + @Throws(IOException::class) + private fun loadFromDisk(path: File?): VCard? { + if (path == null || !path.exists()) { + // Log.d(TAG, "vcardPath not exist " + path); + return null + } + if (path.length() > VCARD_MAX_SIZE) { + Log.w(TAG, "vcardPath too big: " + path.length() / 1024 + " kB") + return null + } + return Ezvcard.parse(path).first() + } + + fun vcardToString(vcard: VCard?): String? { + val writer = StringWriter() + val vcwriter = VCardWriter(writer, VCardVersion.V2_1) + vcwriter.vObjectWriter.foldedLineWriter.lineLength = null + var stringVCard: String? + try { + vcwriter.write(vcard) + stringVCard = writer.toString() + vcwriter.close() + writer.close() + } catch (e: Exception) { + Log.e(TAG, "Error while converting VCard to String", e) + stringVCard = null + } + return stringVCard + } + + fun isEmpty(vCard: VCard): Boolean { + val name = vCard.formattedName + return (name == null || name.value.isEmpty()) && vCard.photos.isEmpty() + } + + private fun peerProfilePath(filesDir: File, accountId: String): File { + val accountDir = File(filesDir, accountId) + val profileDir = File(accountDir, "profiles") + profileDir.mkdirs() + return profileDir + } + + private fun localProfilePath(filesDir: File, accountId: String): File { + return File(filesDir, accountId).apply { mkdir() } + } + + private fun setupDefaultProfile(filesDir: File, accountId: String): VCard { + val vcard = VCard() + vcard.uid = Uid(accountId) + saveLocalProfileToDisk(vcard, accountId, filesDir) + .subscribeOn(Schedulers.io()) + .subscribe({}) { e -> Log.e(TAG, "Error while saving vcard", e) } + return vcard + } + + fun peerProfileReceived(filesDir: File, accountId: String, peerId: String, vcard: File?): Single<VCard> { + return Single.fromCallable<VCard> { + val filename = "$peerId.vcf" + val peerProfilePath = peerProfilePath(filesDir, accountId) + val file = File(peerProfilePath, filename) + moveFile(vcard!!, file) + loadFromDisk(file) + }.subscribeOn(Schedulers.io()) + } +} \ No newline at end of file -- GitLab