From 6830957d319297fda9ca719be483b27935780b94 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 2 Apr 2019 18:08:43 +0200 Subject: [PATCH] Import settings from Riot - not all fonctional of course --- build.gradle | 1 + .../matrix/android/api/session/Session.kt | 4 +- .../api/session/crypto/CryptoService.kt | 23 + vector/build.gradle | 16 +- vector/src/fdroid/AndroidManifest.xml | 15 + .../java/im/vector/push/fcm/FcmHelper.java | 56 + ...ificationTroubleshootTestManagerFactory.kt | 50 + .../fcm/troubleshoot/TestAutoStartBoot.kt | 44 + .../TestBackgroundRestrictions.kt | 71 + .../troubleshoot/TestBatteryOptimization.kt | 47 + .../OnApplicationUpgradeReceiver.java | 34 + vector/src/gplay/AndroidManifest.xml | 19 + vector/src/gplay/google-services.json | 46 + .../java/im/vector/push/fcm/FcmHelper.java | 120 + ...ificationTroubleshootTestManagerFactory.kt | 49 + .../fcm/VectorFirebaseMessagingService.kt | 271 ++ .../fcm/troubleshoot/TestFirebaseToken.kt | 74 + .../push/fcm/troubleshoot/TestPlayServices.kt | 55 + .../fcm/troubleshoot/TestTokenRegistration.kt | 57 + vector/src/main/AndroidManifest.xml | 9 + .../vector/riotredesign/core/di/AppModule.kt | 5 + .../core/extensions/UrlExtensions.kt | 37 + .../core/extensions/ViewExtensions.kt | 50 + .../core/preference/AddressPreference.kt | 69 + .../core/preference/BingRulePreference.kt | 243 ++ .../core/preference/ProgressBarPreference.kt | 35 + .../core/preference/RoomAvatarPreference.kt | 51 + .../core/preference/UserAvatarPreference.kt | 65 + .../preference/VectorEditTextPreference.kt | 54 + .../core/preference/VectorGroupPreference.kt | 105 + .../core/preference/VectorListPreference.kt | 96 + .../core/preference/VectorPreference.kt | 163 + .../preference/VectorPreferenceCategory.kt | 52 + .../preference/VectorPreferenceDivider.kt | 36 + .../core/preference/VectorSwitchPreference.kt | 49 + .../riotredesign/core/services/CallService.kt | 207 ++ .../core/services/EventStreamServiceX.kt | 583 ++++ .../core/services/PushSimulatorWorker.kt | 36 + .../core/services/VectorService.kt | 58 + .../riotredesign/core/utils/RingtoneUtils.kt | 119 + .../core/utils/SecretStoringUtils.kt | 576 ++++ .../riotredesign/core/utils/SystemUtils.kt | 10 +- .../riotredesign/features/badge/BadgeProxy.kt | 133 + .../features/home/HomeActivity.kt | 7 + .../homeserver/ServerUrlsRepository.kt | 107 + .../features/notifications/IconLoader.kt | 128 + .../notifications/InviteNotifiableEvent.kt | 36 + .../features/notifications/NotifiableEvent.kt | 36 + .../notifications/NotifiableEventResolver.kt | 188 ++ .../notifications/NotifiableMessageEvent.kt | 57 + .../NotificationBroadcastReceiver.kt | 182 + .../NotificationDrawerManager.kt | 463 +++ .../notifications/NotificationUtils.kt | 721 ++++ .../notifications/OutdatedEventDetector.kt | 48 + .../notifications/RoomEventGroupInfo.kt | 34 + .../notifications/SimpleNotifiableEvent.kt | 35 + .../features/settings/PreferencesManager.java | 861 +++++ .../settings/VectorSettingsActivity.kt | 116 + ...sAdvancedNotificationPreferenceFragment.kt | 296 ++ ...ctorSettingsFragmentInteractionListener.kt | 24 + ...ttingsNotificationsTroubleshootFragment.kt | 183 ++ .../VectorSettingsPreferencesFragment.kt | 2927 +++++++++++++++++ ...ficationTroubleshootRecyclerViewAdapter.kt | 127 + .../NotificationTroubleshootTestManager.kt | 101 + .../troubleshoot/TestAccountSettings.kt | 65 + .../troubleshoot/TestBingRulesSettings.kt | 85 + .../troubleshoot/TestDeviceSettings.kt | 47 + .../troubleshoot/TestSystemSettings.kt | 45 + .../settings/troubleshoot/TroubleshootTest.kt | 54 + .../main/res/anim/anim_slide_in_bottom.xml | 9 + .../src/main/res/anim/anim_slide_nothing.xml | 9 + .../main/res/anim/anim_slide_out_bottom.xml | 14 + .../src/main/res/anim/unread_marker_anim.xml | 16 + .../src/main/res/drawable-hdpi/unit_test.png | Bin 0 -> 684 bytes .../main/res/drawable-hdpi/unit_test_ko.png | Bin 0 -> 577 bytes .../main/res/drawable-hdpi/unit_test_ok.png | Bin 0 -> 838 bytes .../src/main/res/drawable-mdpi/unit_test.png | Bin 0 -> 411 bytes .../main/res/drawable-mdpi/unit_test_ko.png | Bin 0 -> 388 bytes .../main/res/drawable-mdpi/unit_test_ok.png | Bin 0 -> 548 bytes .../src/main/res/drawable-xhdpi/unit_test.png | Bin 0 -> 893 bytes .../main/res/drawable-xhdpi/unit_test_ko.png | Bin 0 -> 742 bytes .../main/res/drawable-xhdpi/unit_test_ok.png | Bin 0 -> 1119 bytes .../main/res/drawable-xxhdpi/ic_add_black.png | Bin 0 -> 114 bytes .../main/res/drawable-xxhdpi/ic_eye_black.png | Bin 0 -> 2750 bytes .../drawable-xxhdpi/ic_eye_closed_black.png | Bin 0 -> 3084 bytes .../ic_material_call_end_grey.png | Bin 0 -> 4424 bytes .../ic_material_done_all_white.png | Bin 0 -> 398 bytes .../ic_material_done_white.png | Bin 0 -> 255 bytes .../main/res/drawable-xxhdpi/ic_settings.png | Bin 0 -> 1510 bytes .../drawable-xxhdpi/icon_notif_important.png | Bin 0 -> 2121 bytes ...incoming_call_notification_transparent.png | Bin 0 -> 684 bytes .../res/drawable-xxhdpi/logo_transparent.png | Bin 0 -> 1666 bytes .../res/drawable-xxhdpi/main_alias_icon.png | Bin 0 -> 1792 bytes .../main/res/drawable-xxhdpi/unit_test.png | Bin 0 -> 1334 bytes .../main/res/drawable-xxhdpi/unit_test_ko.png | Bin 0 -> 1133 bytes .../main/res/drawable-xxhdpi/unit_test_ok.png | Bin 0 -> 1621 bytes .../vector_notification_accept_invitation.png | Bin 0 -> 473 bytes .../vector_notification_open.png | Bin 0 -> 318 bytes .../vector_notification_quick_reply.png | Bin 0 -> 269 bytes .../vector_notification_reject_invitation.png | Bin 0 -> 309 bytes .../drawable-xxhdpi/vector_warning_red.png | Bin 0 -> 436 bytes .../main/res/drawable-xxxhdpi/unit_test.png | Bin 0 -> 1792 bytes .../res/drawable-xxxhdpi/unit_test_ko.png | Bin 0 -> 1473 bytes .../res/drawable-xxxhdpi/unit_test_ok.png | Bin 0 -> 2190 bytes .../res/layout/activity_vector_settings.xml | 41 + .../main/res/layout/dialog_base_edit_text.xml | 21 + .../res/layout/dialog_change_password.xml | 87 + .../main/res/layout/dialog_device_details.xml | 66 + .../layout/dialog_preference_edit_text.xml | 27 + .../res/layout/dialog_select_text_size.xml | 85 + ...nt_settings_notifications_troubleshoot.xml | 91 + .../layout/item_notification_troubleshoot.xml | 94 + .../layout/vector_preference_bing_rule.xml | 81 + .../res/layout/vector_preference_divider.xml | 21 + .../vector_settings_address_preference.xml | 13 + ..._settings_list_preference_with_warning.xml | 13 + .../layout/vector_settings_round_avatar.xml | 24 + .../vector_settings_round_group_avatar.xml | 18 + .../vector_settings_spinner_preference.xml | 12 + vector/src/main/res/menu/home.xml | 11 + vector/src/main/res/values/array.xml | 136 + ...ings_notification_advanced_preferences.xml | 66 + .../res/xml/vector_settings_preferences.xml | 504 +++ 123 files changed, 12318 insertions(+), 7 deletions(-) create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt create mode 100644 vector/src/fdroid/AndroidManifest.xml create mode 100755 vector/src/fdroid/java/im/vector/push/fcm/FcmHelper.java create mode 100644 vector/src/fdroid/java/im/vector/push/fcm/NotificationTroubleshootTestManagerFactory.kt create mode 100644 vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestAutoStartBoot.kt create mode 100644 vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestBackgroundRestrictions.kt create mode 100644 vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestBatteryOptimization.kt create mode 100644 vector/src/fdroid/java/im/vector/receiver/OnApplicationUpgradeReceiver.java create mode 100755 vector/src/gplay/AndroidManifest.xml create mode 100644 vector/src/gplay/google-services.json create mode 100755 vector/src/gplay/java/im/vector/push/fcm/FcmHelper.java create mode 100644 vector/src/gplay/java/im/vector/push/fcm/NotificationTroubleshootTestManagerFactory.kt create mode 100755 vector/src/gplay/java/im/vector/push/fcm/VectorFirebaseMessagingService.kt create mode 100644 vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestFirebaseToken.kt create mode 100644 vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestPlayServices.kt create mode 100644 vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestTokenRegistration.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/extensions/UrlExtensions.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/extensions/ViewExtensions.kt create mode 100755 vector/src/main/java/im/vector/riotredesign/core/preference/AddressPreference.kt create mode 100755 vector/src/main/java/im/vector/riotredesign/core/preference/BingRulePreference.kt create mode 100755 vector/src/main/java/im/vector/riotredesign/core/preference/ProgressBarPreference.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/preference/RoomAvatarPreference.kt create mode 100755 vector/src/main/java/im/vector/riotredesign/core/preference/UserAvatarPreference.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/preference/VectorEditTextPreference.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/preference/VectorGroupPreference.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/preference/VectorListPreference.kt create mode 100755 vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreference.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreferenceCategory.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreferenceDivider.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/preference/VectorSwitchPreference.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/services/CallService.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/services/EventStreamServiceX.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/services/PushSimulatorWorker.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/services/VectorService.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/utils/RingtoneUtils.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/utils/SecretStoringUtils.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/badge/BadgeProxy.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/homeserver/ServerUrlsRepository.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/notifications/IconLoader.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/notifications/InviteNotifiableEvent.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableEvent.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableEventResolver.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableMessageEvent.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationBroadcastReceiver.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationDrawerManager.kt create mode 100755 vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationUtils.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/notifications/OutdatedEventDetector.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/notifications/RoomEventGroupInfo.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/notifications/SimpleNotifiableEvent.kt create mode 100755 vector/src/main/java/im/vector/riotredesign/features/settings/PreferencesManager.java create mode 100755 vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsActivity.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsAdvancedNotificationPreferenceFragment.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsFragmentInteractionListener.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsNotificationsTroubleshootFragment.kt create mode 100755 vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsPreferencesFragment.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/settings/troubleshoot/NotificationTroubleshootRecyclerViewAdapter.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/settings/troubleshoot/NotificationTroubleshootTestManager.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/settings/troubleshoot/TestAccountSettings.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/settings/troubleshoot/TestBingRulesSettings.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/settings/troubleshoot/TestDeviceSettings.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/settings/troubleshoot/TestSystemSettings.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/settings/troubleshoot/TroubleshootTest.kt create mode 100644 vector/src/main/res/anim/anim_slide_in_bottom.xml create mode 100644 vector/src/main/res/anim/anim_slide_nothing.xml create mode 100644 vector/src/main/res/anim/anim_slide_out_bottom.xml create mode 100644 vector/src/main/res/anim/unread_marker_anim.xml create mode 100644 vector/src/main/res/drawable-hdpi/unit_test.png create mode 100644 vector/src/main/res/drawable-hdpi/unit_test_ko.png create mode 100644 vector/src/main/res/drawable-hdpi/unit_test_ok.png create mode 100644 vector/src/main/res/drawable-mdpi/unit_test.png create mode 100644 vector/src/main/res/drawable-mdpi/unit_test_ko.png create mode 100644 vector/src/main/res/drawable-mdpi/unit_test_ok.png create mode 100644 vector/src/main/res/drawable-xhdpi/unit_test.png create mode 100644 vector/src/main/res/drawable-xhdpi/unit_test_ko.png create mode 100644 vector/src/main/res/drawable-xhdpi/unit_test_ok.png create mode 100755 vector/src/main/res/drawable-xxhdpi/ic_add_black.png create mode 100644 vector/src/main/res/drawable-xxhdpi/ic_eye_black.png create mode 100644 vector/src/main/res/drawable-xxhdpi/ic_eye_closed_black.png create mode 100644 vector/src/main/res/drawable-xxhdpi/ic_material_call_end_grey.png create mode 100755 vector/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png create mode 100755 vector/src/main/res/drawable-xxhdpi/ic_material_done_white.png create mode 100755 vector/src/main/res/drawable-xxhdpi/ic_settings.png create mode 100644 vector/src/main/res/drawable-xxhdpi/icon_notif_important.png create mode 100755 vector/src/main/res/drawable-xxhdpi/incoming_call_notification_transparent.png create mode 100755 vector/src/main/res/drawable-xxhdpi/logo_transparent.png create mode 100644 vector/src/main/res/drawable-xxhdpi/main_alias_icon.png create mode 100644 vector/src/main/res/drawable-xxhdpi/unit_test.png create mode 100644 vector/src/main/res/drawable-xxhdpi/unit_test_ko.png create mode 100644 vector/src/main/res/drawable-xxhdpi/unit_test_ok.png create mode 100755 vector/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png create mode 100644 vector/src/main/res/drawable-xxhdpi/vector_notification_open.png create mode 100755 vector/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png create mode 100755 vector/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png create mode 100755 vector/src/main/res/drawable-xxhdpi/vector_warning_red.png create mode 100644 vector/src/main/res/drawable-xxxhdpi/unit_test.png create mode 100644 vector/src/main/res/drawable-xxxhdpi/unit_test_ko.png create mode 100644 vector/src/main/res/drawable-xxxhdpi/unit_test_ok.png create mode 100755 vector/src/main/res/layout/activity_vector_settings.xml create mode 100644 vector/src/main/res/layout/dialog_base_edit_text.xml create mode 100644 vector/src/main/res/layout/dialog_change_password.xml create mode 100644 vector/src/main/res/layout/dialog_device_details.xml create mode 100644 vector/src/main/res/layout/dialog_preference_edit_text.xml create mode 100644 vector/src/main/res/layout/dialog_select_text_size.xml create mode 100644 vector/src/main/res/layout/fragment_settings_notifications_troubleshoot.xml create mode 100644 vector/src/main/res/layout/item_notification_troubleshoot.xml create mode 100644 vector/src/main/res/layout/vector_preference_bing_rule.xml create mode 100644 vector/src/main/res/layout/vector_preference_divider.xml create mode 100644 vector/src/main/res/layout/vector_settings_address_preference.xml create mode 100644 vector/src/main/res/layout/vector_settings_list_preference_with_warning.xml create mode 100644 vector/src/main/res/layout/vector_settings_round_avatar.xml create mode 100644 vector/src/main/res/layout/vector_settings_round_group_avatar.xml create mode 100644 vector/src/main/res/layout/vector_settings_spinner_preference.xml create mode 100644 vector/src/main/res/menu/home.xml create mode 100644 vector/src/main/res/values/array.xml create mode 100644 vector/src/main/res/xml/vector_settings_notification_advanced_preferences.xml create mode 100755 vector/src/main/res/xml/vector_settings_preferences.xml diff --git a/build.gradle b/build.gradle index 7e567983fc..479eb154c8 100644 --- a/build.gradle +++ b/build.gradle @@ -11,6 +11,7 @@ buildscript { } } dependencies { classpath 'com.android.tools.build:gradle:3.3.2' + classpath 'com.google.gms:google-services:4.2.0' classpath "com.airbnb.okreplay:gradle-plugin:1.4.0" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.6.2' diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt index ad45328206..e5ee1c0e63 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt @@ -19,6 +19,7 @@ package im.vector.matrix.android.api.session import androidx.annotation.MainThread import im.vector.matrix.android.api.auth.data.SessionParams import im.vector.matrix.android.api.session.content.ContentUrlResolver +import im.vector.matrix.android.api.session.crypto.CryptoService import im.vector.matrix.android.api.session.group.GroupService import im.vector.matrix.android.api.session.room.RoomService import im.vector.matrix.android.api.session.user.UserService @@ -27,7 +28,7 @@ import im.vector.matrix.android.api.session.user.UserService * This interface defines interactions with a session. * An instance of a session will be provided by the SDK. */ -interface Session : RoomService, GroupService, UserService { +interface Session : RoomService, GroupService, UserService, CryptoService { /** * The params associated to the session @@ -69,5 +70,4 @@ interface Session : RoomService, GroupService, UserService { // Not used at the moment interface Listener - } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt new file mode 100644 index 0000000000..86c8a86f9a --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.session.crypto + +interface CryptoService { + + // Not supported for the moment + fun isCryptoEnabled() = false +} \ No newline at end of file diff --git a/vector/build.gradle b/vector/build.gradle index 6856d5914d..3957a8a0ff 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -74,10 +74,12 @@ android { buildTypes { debug { resValue "bool", "debug_mode", "true" + buildConfigField "boolean", "LOW_PRIVACY_LOG_ENABLE", "false" } release { resValue "bool", "debug_mode", "false" + buildConfigField "boolean", "LOW_PRIVACY_LOG_ENABLE", "false" minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' @@ -181,6 +183,9 @@ dependencies { kapt "com.airbnb.android:epoxy-processor:$epoxy_version" implementation 'com.airbnb.android:mvrx:0.7.0' + // Work + implementation "android.arch.work:work-runtime-ktx:1.0.0" + // FP implementation "io.arrow-kt:arrow-core:$arrow_version" @@ -209,14 +214,23 @@ dependencies { implementation "com.github.bumptech.glide:glide:$glide_version" kapt "com.github.bumptech.glide:compiler:$glide_version" + // Badge for compatibility + implementation 'me.leolin:ShortcutBadger:1.1.2@aar' + // DI implementation "org.koin:koin-android:$koin_version" implementation "org.koin:koin-android-scope:$koin_version" + // gplay flavor only + gplayImplementation 'com.google.firebase:firebase-core:16.0.8' + gplayImplementation 'com.google.firebase:firebase-messaging:17.5.0' + // TESTS testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' } - +if (!getGradle().getStartParameter().getTaskRequests().toString().contains("fdroid")) { + apply plugin: 'com.google.gms.google-services' +} diff --git a/vector/src/fdroid/AndroidManifest.xml b/vector/src/fdroid/AndroidManifest.xml new file mode 100644 index 0000000000..01babce90a --- /dev/null +++ b/vector/src/fdroid/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/fdroid/java/im/vector/push/fcm/FcmHelper.java b/vector/src/fdroid/java/im/vector/push/fcm/FcmHelper.java new file mode 100755 index 0000000000..ba8badef83 --- /dev/null +++ b/vector/src/fdroid/java/im/vector/push/fcm/FcmHelper.java @@ -0,0 +1,56 @@ +/* + * Copyright 2014 OpenMarket Ltd + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.push.fcm; + +import android.app.Activity; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class FcmHelper { + + /** + * Retrieves the FCM registration token. + * + * @return the FCM token or null if not received from FCM + */ + @Nullable + public static String getFcmToken(Context context) { + return null; + } + + /** + * Store FCM token to the SharedPrefs + * + * @param context android context + * @param token the token to store + */ + public static void storeFcmToken(@NonNull Context context, + @Nullable String token) { + // No op + } + + /** + * onNewToken may not be called on application upgrade, so ensure my shared pref is set + * + * @param activity the first launch Activity + */ + public static void ensureFcmTokenIsRetrieved(final Activity activity) { + // No op + } +} diff --git a/vector/src/fdroid/java/im/vector/push/fcm/NotificationTroubleshootTestManagerFactory.kt b/vector/src/fdroid/java/im/vector/push/fcm/NotificationTroubleshootTestManagerFactory.kt new file mode 100644 index 0000000000..5a81c79d3b --- /dev/null +++ b/vector/src/fdroid/java/im/vector/push/fcm/NotificationTroubleshootTestManagerFactory.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.push.fcm + +import androidx.fragment.app.Fragment +import im.vector.fragments.troubleshoot.TestAccountSettings +import im.vector.fragments.troubleshoot.TestDeviceSettings +import im.vector.matrix.android.api.session.Session +import im.vector.push.fcm.troubleshoot.TestAutoStartBoot +import im.vector.push.fcm.troubleshoot.TestBackgroundRestrictions +import im.vector.riotredesign.features.settings.troubleshoot.NotificationTroubleshootTestManager +import im.vector.riotredesign.features.settings.troubleshoot.TestBingRulesSettings +import im.vector.riotredesign.features.settings.troubleshoot.TestSystemSettings + +class NotificationTroubleshootTestManagerFactory { + + companion object { + fun createTestManager(fragment: Fragment, session: Session?): NotificationTroubleshootTestManager { + val mgr = NotificationTroubleshootTestManager(fragment) + mgr.addTest(TestSystemSettings(fragment)) + if (session != null) { + mgr.addTest(TestAccountSettings(fragment, session)) + } + mgr.addTest(TestDeviceSettings(fragment)) + if (session != null) { + mgr.addTest(TestBingRulesSettings(fragment, session)) + } + // mgr.addTest(TestNotificationServiceRunning(fragment)) + // mgr.addTest(TestServiceRestart(fragment)) + mgr.addTest(TestAutoStartBoot(fragment)) + mgr.addTest(TestBackgroundRestrictions(fragment)) + // mgr.addTest(TestBatteryOptimization(fragment)) + return mgr + } + } + +} \ No newline at end of file diff --git a/vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestAutoStartBoot.kt b/vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestAutoStartBoot.kt new file mode 100644 index 0000000000..4e33423629 --- /dev/null +++ b/vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestAutoStartBoot.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.push.fcm.troubleshoot + +import androidx.fragment.app.Fragment +import im.vector.riotredesign.R +import im.vector.riotredesign.features.settings.PreferencesManager +import im.vector.riotredesign.features.settings.troubleshoot.TroubleshootTest + +/** + * Test that the application is started on boot + */ +class TestAutoStartBoot(val fragment: Fragment) : TroubleshootTest(R.string.settings_troubleshoot_test_service_boot_title) { + + override fun perform() { + if (PreferencesManager.autoStartOnBoot(fragment.context)) { + description = fragment.getString(R.string.settings_troubleshoot_test_service_boot_success) + status = TestStatus.SUCCESS + quickFix = null + } else { + description = fragment.getString(R.string.settings_troubleshoot_test_service_boot_failed) + quickFix = object : TroubleshootQuickFix(R.string.settings_troubleshoot_test_service_boot_quickfix) { + override fun doFix() { + PreferencesManager.setAutoStartOnBoot(fragment.context, true) + manager?.retry() + } + } + status = TestStatus.FAILED + } + } +} \ No newline at end of file diff --git a/vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestBackgroundRestrictions.kt b/vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestBackgroundRestrictions.kt new file mode 100644 index 0000000000..1ad298c60d --- /dev/null +++ b/vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestBackgroundRestrictions.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.push.fcm.troubleshoot + +import android.content.Context +import android.net.ConnectivityManager +import androidx.core.net.ConnectivityManagerCompat +import androidx.fragment.app.Fragment +import im.vector.riotredesign.R +import im.vector.riotredesign.features.settings.troubleshoot.TroubleshootTest + +class TestBackgroundRestrictions(val fragment: Fragment) : TroubleshootTest(R.string.settings_troubleshoot_test_bg_restricted_title) { + + override fun perform() { + (fragment.context!!.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).apply { + // Checks if the device is on a metered network + if (isActiveNetworkMetered) { + // Checks user’s Data Saver settings. + val restrictBackgroundStatus = ConnectivityManagerCompat.getRestrictBackgroundStatus(this) + when (restrictBackgroundStatus) { + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED -> { + // Background data usage is blocked for this app. Wherever possible, + // the app should also use less data in the foreground. + description = fragment.getString(R.string.settings_troubleshoot_test_bg_restricted_failed, + "RESTRICT_BACKGROUND_STATUS_ENABLED") + status = TestStatus.FAILED + quickFix = null + } + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED -> { + // The app is whitelisted. Wherever possible, + // the app should use less data in the foreground and background. + description = fragment.getString(R.string.settings_troubleshoot_test_bg_restricted_success, + "RESTRICT_BACKGROUND_STATUS_WHITELISTED") + status = TestStatus.SUCCESS + quickFix = null + } + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED -> { + // Data Saver is disabled. Since the device is connected to a + // metered network, the app should use less data wherever possible. + description = fragment.getString(R.string.settings_troubleshoot_test_bg_restricted_success, + "RESTRICT_BACKGROUND_STATUS_DISABLED") + status = TestStatus.SUCCESS + quickFix = null + } + + } + + } else { + // The device is not on a metered network. + // Use data as required to perform syncs, downloads, and updates. + description = fragment.getString(R.string.settings_troubleshoot_test_bg_restricted_success, "") + status = TestStatus.SUCCESS + quickFix = null + } + } + } + +} \ No newline at end of file diff --git a/vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestBatteryOptimization.kt b/vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestBatteryOptimization.kt new file mode 100644 index 0000000000..c7d6001853 --- /dev/null +++ b/vector/src/fdroid/java/im/vector/push/fcm/troubleshoot/TestBatteryOptimization.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.push.fcm.troubleshoot + +import androidx.fragment.app.Fragment +import im.vector.riotredesign.R +import im.vector.riotredesign.core.utils.isIgnoringBatteryOptimizations +import im.vector.riotredesign.core.utils.requestDisablingBatteryOptimization +import im.vector.riotredesign.features.settings.troubleshoot.NotificationTroubleshootTestManager +import im.vector.riotredesign.features.settings.troubleshoot.TroubleshootTest + +// Not used anymore +class TestBatteryOptimization(val fragment: Fragment) : TroubleshootTest(R.string.settings_troubleshoot_test_battery_title) { + + override fun perform() { + + if (fragment.context != null && isIgnoringBatteryOptimizations(fragment.context!!)) { + description = fragment.getString(R.string.settings_troubleshoot_test_battery_success) + status = TestStatus.SUCCESS + quickFix = null + } else { + description = fragment.getString(R.string.settings_troubleshoot_test_battery_failed) + quickFix = object : TroubleshootQuickFix(R.string.settings_troubleshoot_test_battery_quickfix) { + override fun doFix() { + fragment.activity?.let { + requestDisablingBatteryOptimization(it, fragment, NotificationTroubleshootTestManager.REQ_CODE_FIX) + } + } + } + status = TestStatus.FAILED + } + } + +} \ No newline at end of file diff --git a/vector/src/fdroid/java/im/vector/receiver/OnApplicationUpgradeReceiver.java b/vector/src/fdroid/java/im/vector/receiver/OnApplicationUpgradeReceiver.java new file mode 100644 index 0000000000..a90ca6b1ad --- /dev/null +++ b/vector/src/fdroid/java/im/vector/receiver/OnApplicationUpgradeReceiver.java @@ -0,0 +1,34 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import timber.log.Timber; + +public class OnApplicationUpgradeReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + Timber.d("## onReceive() : Application has been upgraded, restart event stream service."); + + // Start Event stream + // TODO EventStreamServiceX.Companion.onApplicationUpgrade(context); + } +} diff --git a/vector/src/gplay/AndroidManifest.xml b/vector/src/gplay/AndroidManifest.xml new file mode 100755 index 0000000000..e48db66767 --- /dev/null +++ b/vector/src/gplay/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/vector/src/gplay/google-services.json b/vector/src/gplay/google-services.json new file mode 100644 index 0000000000..8ffc2cef44 --- /dev/null +++ b/vector/src/gplay/google-services.json @@ -0,0 +1,46 @@ +{ + "project_info": { + "project_number": "912726360885", + "firebase_url": "https://vector-alpha.firebaseio.com", + "project_id": "vector-alpha", + "storage_bucket": "vector-alpha.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:912726360885:android:448c9b63161abc9c", + "android_client_info": { + "package_name": "im.vector.riotredesign" + } + }, + "oauth_client": [ + { + "client_id": "912726360885-rsae0i66rgqt6ivnudu1pv4tksg9i8b2.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c" + } + ], + "services": { + "analytics_service": { + "status": 1 + }, + "appinvite_service": { + "status": 1, + "other_platform_oauth_client": [] + }, + "ads_service": { + "status": 2 + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/vector/src/gplay/java/im/vector/push/fcm/FcmHelper.java b/vector/src/gplay/java/im/vector/push/fcm/FcmHelper.java new file mode 100755 index 0000000000..ff5ed9449a --- /dev/null +++ b/vector/src/gplay/java/im/vector/push/fcm/FcmHelper.java @@ -0,0 +1,120 @@ +/* + * Copyright 2014 OpenMarket Ltd + * Copyright 2017 Vector Creations Ltd + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.push.fcm; + +import android.app.Activity; +import android.content.Context; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.widget.Toast; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.OnSuccessListener; +import com.google.firebase.iid.FirebaseInstanceId; +import com.google.firebase.iid.InstanceIdResult; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import im.vector.riotredesign.R; +import timber.log.Timber; + +/** + * This class store the FCM token in SharedPrefs and ensure this token is retrieved. + * It has an alter ego in the fdroid variant. + */ +public class FcmHelper { + private static final String LOG_TAG = FcmHelper.class.getSimpleName(); + + private static final String PREFS_KEY_FCM_TOKEN = "FCM_TOKEN"; + + /** + * Retrieves the FCM registration token. + * + * @return the FCM token or null if not received from FCM + */ + @Nullable + public static String getFcmToken(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getString(PREFS_KEY_FCM_TOKEN, null); + } + + /** + * Store FCM token to the SharedPrefs + * + * @param context android context + * @param token the token to store + */ + public static void storeFcmToken(@NonNull Context context, + @Nullable String token) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putString(PREFS_KEY_FCM_TOKEN, token) + .apply(); + } + + /** + * onNewToken may not be called on application upgrade, so ensure my shared pref is set + * + * @param activity the first launch Activity + */ + public static void ensureFcmTokenIsRetrieved(final Activity activity) { + if (TextUtils.isEmpty(getFcmToken(activity))) { + + + //vfe: according to firebase doc + //'app should always check the device for a compatible Google Play services APK before accessing Google Play services features' + if (checkPlayServices(activity)) { + try { + FirebaseInstanceId.getInstance().getInstanceId() + .addOnSuccessListener(activity, new OnSuccessListener() { + @Override + public void onSuccess(InstanceIdResult instanceIdResult) { + storeFcmToken(activity, instanceIdResult.getToken()); + } + }) + .addOnFailureListener(activity, new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed " + e.getMessage()); + } + }); + } catch (Throwable e) { + Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed " + e.getMessage()); + } + } else { + Toast.makeText(activity, R.string.no_valid_google_play_services_apk, Toast.LENGTH_SHORT).show(); + Timber.e("No valid Google Play Services found. Cannot use FCM."); + } + } + } + + /** + * Check the device to make sure it has the Google Play Services APK. If + * it doesn't, display a dialog that allows users to download the APK from + * the Google Play Store or enable it in the device's system settings. + */ + private static boolean checkPlayServices(Activity activity) { + GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance(); + int resultCode = apiAvailability.isGooglePlayServicesAvailable(activity); + if (resultCode != ConnectionResult.SUCCESS) { + return false; + } + return true; + } +} diff --git a/vector/src/gplay/java/im/vector/push/fcm/NotificationTroubleshootTestManagerFactory.kt b/vector/src/gplay/java/im/vector/push/fcm/NotificationTroubleshootTestManagerFactory.kt new file mode 100644 index 0000000000..be03c08ea1 --- /dev/null +++ b/vector/src/gplay/java/im/vector/push/fcm/NotificationTroubleshootTestManagerFactory.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.push.fcm + +import androidx.fragment.app.Fragment +import im.vector.fragments.troubleshoot.TestAccountSettings +import im.vector.matrix.android.api.session.Session +import im.vector.push.fcm.troubleshoot.TestFirebaseToken +import im.vector.push.fcm.troubleshoot.TestPlayServices +import im.vector.push.fcm.troubleshoot.TestTokenRegistration +import im.vector.riotredesign.features.settings.troubleshoot.NotificationTroubleshootTestManager +import im.vector.riotredesign.features.settings.troubleshoot.TestBingRulesSettings +import im.vector.riotredesign.features.settings.troubleshoot.TestDeviceSettings +import im.vector.riotredesign.features.settings.troubleshoot.TestSystemSettings + +class NotificationTroubleshootTestManagerFactory { + + companion object { + fun createTestManager(fragment: Fragment, session: Session?): NotificationTroubleshootTestManager { + val mgr = NotificationTroubleshootTestManager(fragment) + mgr.addTest(TestSystemSettings(fragment)) + if (session != null) { + mgr.addTest(TestAccountSettings(fragment, session)) + } + mgr.addTest(TestDeviceSettings(fragment)) + if (session != null) { + mgr.addTest(TestBingRulesSettings(fragment, session)) + } + mgr.addTest(TestPlayServices(fragment)) + mgr.addTest(TestFirebaseToken(fragment)) + mgr.addTest(TestTokenRegistration(fragment)) + return mgr + } + } + +} \ No newline at end of file diff --git a/vector/src/gplay/java/im/vector/push/fcm/VectorFirebaseMessagingService.kt b/vector/src/gplay/java/im/vector/push/fcm/VectorFirebaseMessagingService.kt new file mode 100755 index 0000000000..1c74d09798 --- /dev/null +++ b/vector/src/gplay/java/im/vector/push/fcm/VectorFirebaseMessagingService.kt @@ -0,0 +1,271 @@ +/* + * Copyright 2019 New Vector Ltd + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.push.fcm + +import android.os.Handler +import android.os.Looper +import android.text.TextUtils +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.riotredesign.BuildConfig +import im.vector.riotredesign.R +import im.vector.riotredesign.core.preference.BingRule +import im.vector.riotredesign.features.badge.BadgeProxy +import im.vector.riotredesign.features.notifications.NotifiableEventResolver +import im.vector.riotredesign.features.notifications.NotifiableMessageEvent +import im.vector.riotredesign.features.notifications.NotificationDrawerManager +import im.vector.riotredesign.features.notifications.SimpleNotifiableEvent +import org.koin.android.ext.android.inject +import timber.log.Timber + +/** + * Class extending FirebaseMessagingService. + */ +class VectorFirebaseMessagingService : FirebaseMessagingService() { + + val notificationDrawerManager by inject() + + private val notifiableEventResolver by lazy { + NotifiableEventResolver(this) + } + + // UI handler + private val mUIHandler by lazy { + Handler(Looper.getMainLooper()) + } + + /** + * Called when message is received. + * + * @param message the message + */ + override fun onMessageReceived(message: RemoteMessage?) { + if (message == null || message.data == null) { + Timber.e("## onMessageReceived() : received a null message or message with no data") + return + } + if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { + Timber.i("## onMessageReceived()" + message.data.toString()) + Timber.i("## onMessageReceived() from FCM with priority " + message.priority) + } + + //safe guard + /* TODO + val pushManager = Matrix.getInstance(applicationContext).pushManager + if (!pushManager.areDeviceNotificationsAllowed()) { + Timber.i("## onMessageReceived() : the notifications are disabled") + return + } + */ + + //TODO if the app is in foreground, we could just ignore this. The sync loop is already going? + // TODO mUIHandler.post { onMessageReceivedInternal(message.data, pushManager) } + } + + /** + * Called if InstanceID token is updated. This may occur if the security of + * the previous token had been compromised. Note that this is also called + * when the InstanceID token is initially generated, so this is where + * you retrieve the token. + */ + override fun onNewToken(refreshedToken: String?) { + Timber.i("onNewToken: FCM Token has been updated") + FcmHelper.storeFcmToken(this, refreshedToken) + // TODO Matrix.getInstance(this)?.pushManager?.resetFCMRegistration(refreshedToken) + } + + override fun onDeletedMessages() { + Timber.d("## onDeletedMessages()") + } + + /** + * Internal receive method + * + * @param data Data map containing message data as key/value pairs. + * For Set of keys use data.keySet(). + */ + private fun onMessageReceivedInternal(data: Map /*, pushManager: PushManager*/) { + try { + if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { + Timber.i("## onMessageReceivedInternal() : $data") + } + // update the badge counter + val unreadCount = data.get("unread")?.let { Integer.parseInt(it) } ?: 0 + BadgeProxy.updateBadgeCount(applicationContext, unreadCount) + + /* TODO + val session = Matrix.getInstance(applicationContext)?.defaultSession + + if (VectorApp.isAppInBackground() && !pushManager.isBackgroundSyncAllowed) { + //Notification contains metadata and maybe data information + handleNotificationWithoutSyncingMode(data, session) + } else { + // Safe guard... (race?) + if (isEventAlreadyKnown(data["event_id"], data["room_id"])) return + //Catch up!! + EventStreamServiceX.onPushReceived(this) + } + */ + } catch (e: Exception) { + Timber.e(e, "## onMessageReceivedInternal() failed : " + e.message) + } + } + + // check if the event was not yet received + // a previous catchup might have already retrieved the notified event + private fun isEventAlreadyKnown(eventId: String?, roomId: String?): Boolean { + if (null != eventId && null != roomId) { + try { + /* TODO + val sessions = Matrix.getInstance(applicationContext).sessions + + if (null != sessions && !sessions.isEmpty()) { + for (session in sessions) { + if (session.dataHandler?.store?.isReady == true) { + session.dataHandler.store?.getEvent(eventId, roomId)?.let { + Timber.e("## isEventAlreadyKnown() : ignore the event " + eventId + + " in room " + roomId + " because it is already known") + return true + } + } + } + } + */ + } catch (e: Exception) { + Timber.e(e, "## isEventAlreadyKnown() : failed to check if the event was already defined " + e.message) + } + + } + return false + } + + private fun handleNotificationWithoutSyncingMode(data: Map, session: Session?) { + + if (session == null) { + Timber.e("## handleNotificationWithoutSyncingMode cannot find session") + return + } + + // The Matrix event ID of the event being notified about. + // This is required if the notification is about a particular Matrix event. + // It may be omitted for notifications that only contain updated badge counts. + // This ID can and should be used to detect duplicate notification requests. + val eventId = data["event_id"] ?: return //Just ignore + + + val eventType = data["type"] + if (eventType == null) { + //Just add a generic unknown event + val simpleNotifiableEvent = SimpleNotifiableEvent( + session.sessionParams.credentials.userId, + eventId, + true, //It's an issue in this case, all event will bing even if expected to be silent. + title = getString(R.string.notification_unknown_new_event), + description = "", + type = null, + timestamp = System.currentTimeMillis(), + soundName = BingRule.ACTION_VALUE_DEFAULT, + isPushGatewayEvent = true + ) + notificationDrawerManager.onNotifiableEventReceived(simpleNotifiableEvent) + notificationDrawerManager.refreshNotificationDrawer(null) + + return + } else { + + val event = parseEvent(data) + if (event?.roomId == null) { + //unsupported event + Timber.e("Received an event with no room id") + return + } else { + + var notifiableEvent = notifiableEventResolver.resolveEvent(event, null, null /* TODO session.fulfillRule(event) */, session) + + if (notifiableEvent == null) { + Timber.e("Unsupported notifiable event ${eventId}") + if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { + Timber.e("--> ${event}") + } + } else { + + + if (notifiableEvent is NotifiableMessageEvent) { + if (TextUtils.isEmpty(notifiableEvent.senderName)) { + notifiableEvent.senderName = data["sender_display_name"] ?: data["sender"] ?: "" + } + if (TextUtils.isEmpty(notifiableEvent.roomName)) { + notifiableEvent.roomName = findRoomNameBestEffort(data, session) ?: "" + } + } + + notifiableEvent.isPushGatewayEvent = true + notifiableEvent.matrixID = session.sessionParams.credentials.userId + notificationDrawerManager.onNotifiableEventReceived(notifiableEvent) + notificationDrawerManager.refreshNotificationDrawer(null) + } + } + } + } + + private fun findRoomNameBestEffort(data: Map, session: Session?): String? { + var roomName: String? = data["room_name"] + val roomId = data["room_id"] + if (null == roomName && null != roomId) { + // Try to get the room name from our store + /* + TODO + if (session?.dataHandler?.store?.isReady == true) { + val room = session.getRoom(roomId) + roomName = room?.getRoomDisplayName(this) + } + */ + } + return roomName + } + + /** + * Try to create an event from the FCM data + * + * @param data the FCM data + * @return the event + */ + private fun parseEvent(data: Map?): Event? { + // accept only event with room id. + if (null == data || !data.containsKey("room_id") || !data.containsKey("event_id")) { + return null + } + + try { + return Event(eventId = data["event_id"], + sender = data["sender"], + roomId = data["room_id"], + type = data.getValue("type"), + // TODO content = data.getValue("content"), + originServerTs = System.currentTimeMillis()) + } catch (e: Exception) { + Timber.e(e, "buildEvent fails " + e.localizedMessage) + } + + return null + } +} diff --git a/vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestFirebaseToken.kt b/vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestFirebaseToken.kt new file mode 100644 index 0000000000..7b3d9be404 --- /dev/null +++ b/vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestFirebaseToken.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.push.fcm.troubleshoot + +import androidx.fragment.app.Fragment +import com.google.firebase.iid.FirebaseInstanceId +import im.vector.riotredesign.R +import im.vector.riotredesign.core.utils.startAddGoogleAccountIntent +import im.vector.riotredesign.features.settings.troubleshoot.NotificationTroubleshootTestManager +import im.vector.riotredesign.features.settings.troubleshoot.TroubleshootTest +import timber.log.Timber + +/* +* Test that app can successfully retrieve a token via firebase + */ +class TestFirebaseToken(val fragment: Fragment) : TroubleshootTest(R.string.settings_troubleshoot_test_fcm_title) { + + override fun perform() { + status = TestStatus.RUNNING + val activity = fragment.activity + if (activity != null) { + try { + FirebaseInstanceId.getInstance().instanceId + .addOnCompleteListener(activity) { task -> + if (!task.isSuccessful) { + val errorMsg = if (task.exception == null) "Unknown" else task.exception!!.localizedMessage + //Can't find where this constant is (not documented -or deprecated in docs- and all obfuscated) + if ("SERVICE_NOT_AVAILABLE".equals(errorMsg)) { + description = fragment.getString(R.string.settings_troubleshoot_test_fcm_failed_service_not_available, errorMsg) + } else if ("TOO_MANY_REGISTRATIONS".equals(errorMsg)) { + description = fragment.getString(R.string.settings_troubleshoot_test_fcm_failed_too_many_registration, errorMsg) + } else if ("ACCOUNT_MISSING".equals(errorMsg)) { + description = fragment.getString(R.string.settings_troubleshoot_test_fcm_failed_account_missing, errorMsg) + quickFix = object : TroubleshootQuickFix(R.string.settings_troubleshoot_test_fcm_failed_account_missing_quick_fix) { + override fun doFix() { + startAddGoogleAccountIntent(fragment, NotificationTroubleshootTestManager.REQ_CODE_FIX) + } + } + } else { + description = fragment.getString(R.string.settings_troubleshoot_test_fcm_failed, errorMsg) + } + status = TestStatus.FAILED + } else { + task.result?.token?.let { + val tok = it.substring(0, Math.min(8, it.length)) + "********************" + description = fragment.getString(R.string.settings_troubleshoot_test_fcm_success, tok) + Timber.e("Retrieved FCM token success [$it].") + } + status = TestStatus.SUCCESS + } + } + } catch (e: Throwable) { + description = fragment.getString(R.string.settings_troubleshoot_test_fcm_failed, e.localizedMessage) + status = TestStatus.FAILED + } + } else { + status = TestStatus.FAILED + } + } + +} \ No newline at end of file diff --git a/vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestPlayServices.kt b/vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestPlayServices.kt new file mode 100644 index 0000000000..a5f93f4f8c --- /dev/null +++ b/vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestPlayServices.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.push.fcm.troubleshoot + +import androidx.fragment.app.Fragment +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import im.vector.riotredesign.R +import im.vector.riotredesign.features.settings.troubleshoot.TroubleshootTest +import timber.log.Timber + +/* +* Check that the play services APK is available an up-to-date. If needed provide quick fix to install it. + */ +class TestPlayServices(val fragment: Fragment) : TroubleshootTest(R.string.settings_troubleshoot_test_play_services_title) { + + override fun perform() { + val apiAvailability = GoogleApiAvailability.getInstance() + val resultCode = apiAvailability.isGooglePlayServicesAvailable(fragment.context) + if (resultCode == ConnectionResult.SUCCESS) { + quickFix = null + description = fragment.getString(R.string.settings_troubleshoot_test_play_services_success) + status = TestStatus.SUCCESS + } else { + if (apiAvailability.isUserResolvableError(resultCode)) { + quickFix = object : TroubleshootQuickFix(R.string.settings_troubleshoot_test_play_services_quickfix) { + override fun doFix() { + fragment.activity?.let { + apiAvailability.getErrorDialog(it, resultCode, 9000 /*hey does the magic number*/).show() + } + } + } + Timber.e("Play Services apk error $resultCode -> ${apiAvailability.getErrorString(resultCode)}.") + } + + description = fragment.getString(R.string.settings_troubleshoot_test_play_services_failed, apiAvailability.getErrorString(resultCode)) + status = TestStatus.FAILED + } + } + +} + diff --git a/vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestTokenRegistration.kt b/vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestTokenRegistration.kt new file mode 100644 index 0000000000..38b7375a71 --- /dev/null +++ b/vector/src/gplay/java/im/vector/push/fcm/troubleshoot/TestTokenRegistration.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.push.fcm.troubleshoot + +import androidx.fragment.app.Fragment +import im.vector.riotredesign.R +import im.vector.riotredesign.features.settings.troubleshoot.TroubleshootTest + +/** + * Force registration of the token to HomeServer + */ +class TestTokenRegistration(val fragment: Fragment) : TroubleshootTest(R.string.settings_troubleshoot_test_token_registration_title) { + + override fun perform() { + /* + TODO + Matrix.getInstance(VectorApp.getInstance().baseContext).pushManager.forceSessionsRegistration(object : ApiCallback { + override fun onSuccess(info: Void?) { + description = fragment.getString(R.string.settings_troubleshoot_test_token_registration_success) + status = TestStatus.SUCCESS + } + + override fun onNetworkError(e: Exception?) { + description = fragment.getString(R.string.settings_troubleshoot_test_token_registration_failed, e?.localizedMessage) + status = TestStatus.FAILED + } + + override fun onMatrixError(e: MatrixError?) { + description = fragment.getString(R.string.settings_troubleshoot_test_token_registration_failed, e?.localizedMessage) + status = TestStatus.FAILED + } + + override fun onUnexpectedError(e: Exception?) { + description = fragment.getString(R.string.settings_troubleshoot_test_token_registration_failed, e?.localizedMessage) + status = TestStatus.FAILED + } + }) + */ + + status = TestStatus.FAILED + + } + +} \ No newline at end of file diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 9155aa00d2..9e73f7a867 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -32,6 +32,15 @@ + + + + \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/di/AppModule.kt b/vector/src/main/java/im/vector/riotredesign/core/di/AppModule.kt index 9c14a508c0..3fd6987997 100644 --- a/vector/src/main/java/im/vector/riotredesign/core/di/AppModule.kt +++ b/vector/src/main/java/im/vector/riotredesign/core/di/AppModule.kt @@ -26,6 +26,7 @@ import im.vector.riotredesign.features.home.group.SelectedGroupStore import im.vector.riotredesign.features.home.room.VisibleRoomStore import im.vector.riotredesign.features.home.room.list.RoomSelectionRepository import im.vector.riotredesign.features.home.room.list.RoomSummaryComparator +import im.vector.riotredesign.features.notifications.NotificationDrawerManager import org.koin.dsl.module.module class AppModule(private val context: Context) { @@ -64,6 +65,10 @@ class AppModule(private val context: Context) { RoomSummaryComparator() } + single { + NotificationDrawerManager(context) + } + factory { Matrix.getInstance().currentSession!! } diff --git a/vector/src/main/java/im/vector/riotredesign/core/extensions/UrlExtensions.kt b/vector/src/main/java/im/vector/riotredesign/core/extensions/UrlExtensions.kt new file mode 100644 index 0000000000..e087c3fdb3 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/extensions/UrlExtensions.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.extensions + +import java.net.URLEncoder + +/** + * Append param and value to a Url, using "?" or "&". Value parameter will be encoded + * Return this for chaining purpose + */ +fun StringBuilder.appendParamToUrl(param: String, value: String): StringBuilder { + if (contains("?")) { + append("&") + } else { + append("?") + } + + append(param) + append("=") + append(URLEncoder.encode(value, "utf-8")) + + return this +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/extensions/ViewExtensions.kt b/vector/src/main/java/im/vector/riotredesign/core/extensions/ViewExtensions.kt new file mode 100644 index 0000000000..99af0a3854 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/extensions/ViewExtensions.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.extensions + +import android.text.InputType +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.appcompat.widget.SearchView +import im.vector.riotredesign.R + +/** + * Remove left margin of a SearchView + */ +fun SearchView.withoutLeftMargin() { + (findViewById(R.id.search_edit_frame))?.let { + val searchEditFrameParams = it.layoutParams as ViewGroup.MarginLayoutParams + searchEditFrameParams.leftMargin = 0 + it.layoutParams = searchEditFrameParams + } + + (findViewById(R.id.search_mag_icon))?.let { + val searchIconParams = it.layoutParams as ViewGroup.MarginLayoutParams + searchIconParams.leftMargin = 0 + it.layoutParams = searchIconParams + } +} + +fun EditText.showPassword(visible: Boolean, updateCursor: Boolean = true) { + if (visible) { + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + } else { + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + if (updateCursor) setSelection(text?.length ?: 0) +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/AddressPreference.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/AddressPreference.kt new file mode 100755 index 0000000000..e3fb1a079c --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/AddressPreference.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import androidx.preference.PreferenceViewHolder +import im.vector.riotredesign.R + +/** + * Preference used in Room setting for Room aliases + */ +class AddressPreference : VectorPreference { + + // members + private var mMainAddressIconView: ImageView? = null + private var mIsMainIconVisible = false + + /** + * @return the main icon view. + */ + val mainIconView: View? + get() = mMainAddressIconView + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + + init { + widgetLayoutResource = R.layout.vector_settings_address_preference + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val view = holder.itemView + mMainAddressIconView = view.findViewById(R.id.main_address_icon_view) + mMainAddressIconView!!.visibility = if (mIsMainIconVisible) View.VISIBLE else View.GONE + } + + /** + * Set the main address icon visibility. + * + * @param isVisible true to display the main icon + */ + fun setMainIconVisible(isVisible: Boolean) { + mIsMainIconVisible = isVisible + + mMainAddressIconView?.visibility = if (mIsMainIconVisible) View.VISIBLE else View.GONE + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/BingRulePreference.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/BingRulePreference.kt new file mode 100755 index 0000000000..bd10f70941 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/BingRulePreference.kt @@ -0,0 +1,243 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.View +import android.widget.RadioGroup +import android.widget.TextView +import androidx.preference.PreferenceViewHolder +import im.vector.riotredesign.R + +// TODO Replace by real Bingrule class +class BingRule(rule: BingRule) { + fun shouldNotNotify() = false + fun shouldNotify() = false + fun setNotify(b: Boolean) { + + } + + fun setHighlight(b: Boolean) { + + } + + fun removeNotificationSound() { + + } + + val ruleId: CharSequence? = null + var isEnabled = false + var notificationSound: String? = null + val kind: CharSequence? = null + + companion object { + const val RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS = "TODO" + const val ACTION_VALUE_DEFAULT = "TODO" + const val KIND_UNDERRIDE = "TODO" + const val RULE_ID_INVITE_ME = "TODO" + const val RULE_ID_CALL = "TODO" + const val ACTION_VALUE_RING = "TODO" + const val RULE_ID_DISABLE_ALL = "TODO" + const val ACTION_DONT_NOTIFY = "TODO" + const val RULE_ID_CONTAIN_DISPLAY_NAME = "TODO" + const val RULE_ID_CONTAIN_USER_NAME = "TODO" + const val RULE_ID_ONE_TO_ONE_ROOM = "TODO" + const val RULE_ID_ALL_OTHER_MESSAGES_ROOMS = "TODO" + } + +} + +class BingRulePreference : VectorPreference { + + /** + * @return the selected bing rule + */ + var rule: BingRule? = null + private set + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + + init { + layoutResource = R.layout.vector_preference_bing_rule + } + + /** + * @return the bing rule status index + */ + val ruleStatusIndex: Int + get() { + if (null != rule) { + if (TextUtils.equals(rule!!.ruleId, BingRule.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS)) { + if (rule!!.shouldNotNotify()) { + return if (rule!!.isEnabled) { + NOTIFICATION_OFF_INDEX + } else { + NOTIFICATION_SILENT_INDEX + } + } else if (rule!!.shouldNotify()) { + return NOTIFICATION_NOISY_INDEX + } + } + + if (rule!!.isEnabled) { + return if (rule!!.shouldNotNotify()) { + NOTIFICATION_OFF_INDEX + } else if (null != rule!!.notificationSound) { + NOTIFICATION_NOISY_INDEX + } else { + NOTIFICATION_SILENT_INDEX + } + } + } + + return NOTIFICATION_OFF_INDEX + } + + /** + * Update the bing rule. + * + * @param aBingRule + */ + fun setBingRule(aBingRule: BingRule) { + rule = aBingRule + refreshSummary() + } + + /** + * Refresh the summary + */ + private fun refreshSummary() { + summary = context.getString(when (ruleStatusIndex) { + NOTIFICATION_OFF_INDEX -> R.string.notification_off + NOTIFICATION_SILENT_INDEX -> R.string.notification_silent + else -> R.string.notification_noisy + }) + } + + /** + * Create a bing rule with the updated required at index. + * + * @param index index + * @return a bing rule with the updated flags / null if there is no update + */ + fun createRule(index: Int): BingRule? { + var rule: BingRule? = null + + if (null != this.rule && index != ruleStatusIndex) { + rule = BingRule(this.rule!!) + + if (TextUtils.equals(rule.ruleId, BingRule.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS)) { + when (index) { + NOTIFICATION_OFF_INDEX -> { + rule.isEnabled = true + rule.setNotify(false) + } + NOTIFICATION_SILENT_INDEX -> { + rule.isEnabled = false + rule.setNotify(false) + } + NOTIFICATION_NOISY_INDEX -> { + rule.isEnabled = true + rule.setNotify(true) + rule.notificationSound = BingRule.ACTION_VALUE_DEFAULT + } + } + + return rule + } + + + if (NOTIFICATION_OFF_INDEX == index) { + if (TextUtils.equals(this.rule!!.kind, BingRule.KIND_UNDERRIDE) + || TextUtils.equals(rule.ruleId, BingRule.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS)) { + rule.setNotify(false) + } else { + rule.isEnabled = false + } + } else { + rule.isEnabled = true + rule.setNotify(true) + rule.setHighlight(!TextUtils.equals(this.rule!!.kind, BingRule.KIND_UNDERRIDE) + && !TextUtils.equals(rule.ruleId, BingRule.RULE_ID_INVITE_ME) + && NOTIFICATION_NOISY_INDEX == index) + if (NOTIFICATION_NOISY_INDEX == index) { + rule.notificationSound = if (TextUtils.equals(rule.ruleId, BingRule.RULE_ID_CALL)) + BingRule.ACTION_VALUE_RING + else + BingRule.ACTION_VALUE_DEFAULT + } else { + rule.removeNotificationSound() + } + } + } + + return rule + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + holder.itemView.findViewById(android.R.id.summary)?.visibility = View.GONE + holder.itemView.setOnClickListener(null) + holder.itemView.setOnLongClickListener(null) + + val radioGroup = holder.findViewById(R.id.bingPreferenceRadioGroup) as? RadioGroup + radioGroup?.setOnCheckedChangeListener(null) + + when (ruleStatusIndex) { + NOTIFICATION_OFF_INDEX -> { + radioGroup?.check(R.id.bingPreferenceRadioBingRuleOff) + } + NOTIFICATION_SILENT_INDEX -> { + radioGroup?.check(R.id.bingPreferenceRadioBingRuleSilent) + } + else -> { + radioGroup?.check(R.id.bingPreferenceRadioBingRuleNoisy) + } + } + + radioGroup?.setOnCheckedChangeListener { group, checkedId -> + when (checkedId) { + R.id.bingPreferenceRadioBingRuleOff -> { + onPreferenceChangeListener?.onPreferenceChange(this, NOTIFICATION_OFF_INDEX) + } + R.id.bingPreferenceRadioBingRuleSilent -> { + onPreferenceChangeListener?.onPreferenceChange(this, NOTIFICATION_SILENT_INDEX) + } + R.id.bingPreferenceRadioBingRuleNoisy -> { + onPreferenceChangeListener?.onPreferenceChange(this, NOTIFICATION_NOISY_INDEX) + } + } + } + + } + + + companion object { + + // index in mRuleStatuses + private const val NOTIFICATION_OFF_INDEX = 0 + private const val NOTIFICATION_SILENT_INDEX = 1 + private const val NOTIFICATION_NOISY_INDEX = 2 + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/ProgressBarPreference.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/ProgressBarPreference.kt new file mode 100755 index 0000000000..de36106c28 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/ProgressBarPreference.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.Preference +import im.vector.riotredesign.R + +class ProgressBarPreference : Preference { + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + + init { + layoutResource = R.layout.vector_settings_spinner_preference + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/RoomAvatarPreference.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/RoomAvatarPreference.kt new file mode 100644 index 0000000000..9c77360750 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/RoomAvatarPreference.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.util.AttributeSet +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.room.Room + +/** + * Specialized class to target a Room avatar preference. + * Based don the avatar preference class it redefines refreshAvatar() and + * add the new method setConfiguration(). + */ +class RoomAvatarPreference : UserAvatarPreference { + + private var mRoom: Room? = null + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + + override fun refreshAvatar() { + if (null != mAvatarView && null != mRoom) { + // TODO + // VectorUtils.loadRoomAvatar(context, mSession, mAvatarView, mRoom) + } + } + + fun setConfiguration(aSession: Session, aRoom: Room) { + mSession = aSession + mRoom = aRoom + refreshAvatar() + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/UserAvatarPreference.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/UserAvatarPreference.kt new file mode 100755 index 0000000000..0514e51610 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/UserAvatarPreference.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.ProgressBar +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import im.vector.matrix.android.api.session.Session +import im.vector.riotredesign.R + +open class UserAvatarPreference : Preference { + + internal var mAvatarView: ImageView? = null + internal var mSession: Session? = null + private var mLoadingProgressBar: ProgressBar? = null + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + + init { + widgetLayoutResource = R.layout.vector_settings_round_avatar + isIconSpaceReserved = false + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + mAvatarView = holder.itemView.findViewById(R.id.settings_avatar) + mLoadingProgressBar = holder.itemView.findViewById(R.id.avatar_update_progress_bar) + refreshAvatar() + } + + open fun refreshAvatar() { + if (null != mAvatarView && null != mSession) { + // TODO + // val myUser = mSession!!.myUser + // VectorUtils.loadUserAvatar(context, mSession, mAvatarView, myUser.avatarUrl, myUser.user_id, myUser.displayname) + } + } + + fun setSession(session: Session) { + mSession = session + refreshAvatar() + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/VectorEditTextPreference.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorEditTextPreference.kt new file mode 100644 index 0000000000..973857b1d9 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorEditTextPreference.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceViewHolder +import im.vector.riotredesign.R +import timber.log.Timber + +/** + * Use this class to create an EditTextPreference form code and avoid a crash (see https://code.google.com/p/android/issues/detail?id=231576) + */ +class VectorEditTextPreference : EditTextPreference { + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + + init { + dialogLayoutResource = R.layout.dialog_preference_edit_text + isIconSpaceReserved = false + } + + // No single line for title + override fun onBindViewHolder(holder: PreferenceViewHolder) { + // display the title in multi-line to avoid ellipsis. + try { + holder.itemView.findViewById(android.R.id.title)?.setSingleLine(false) + } catch (e: Exception) { + Timber.e(e, "onBindView " + e.message) + } + + super.onBindViewHolder(holder) + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/VectorGroupPreference.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorGroupPreference.kt new file mode 100644 index 0000000000..553fd07ead --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorGroupPreference.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.preference.PreferenceViewHolder +import androidx.preference.SwitchPreference +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.group.Group +import im.vector.riotredesign.R + +class VectorGroupPreference : SwitchPreference { + + private var mAvatarView: ImageView? = null + + private var mGroup: Group? = null + private var mSession: Session? = null + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val createdView = holder.itemView + + if (mAvatarView == null) { + try { + // insert the group avatar to the left + val iconView = createdView.findViewById(android.R.id.icon) + + var iconViewParent = iconView.parent + + while (null != iconViewParent.parent) { + iconViewParent = iconViewParent.parent + } + + val inflater = LayoutInflater.from(context) + val layout = inflater.inflate(R.layout.vector_settings_round_group_avatar, (iconViewParent as LinearLayout), false) as FrameLayout + mAvatarView = layout.findViewById(R.id.settings_round_group_avatar) + + val params = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + params.gravity = Gravity.CENTER + layout.layoutParams = params + iconViewParent.addView(layout, 0) + + } catch (e: Exception) { + mAvatarView = null + } + + } + + refreshAvatar() + } + + /** + * Init the group information + * + * @param group the group + * @param session the session + */ + fun setGroup(group: Group, session: Session) { + mGroup = group + mSession = session + + refreshAvatar() + } + + /** + * Refresh the avatar + */ + private fun refreshAvatar() { + if (null != mAvatarView && null != mSession && null != mGroup) { + // TODO + // VectorUtils.loadGroupAvatar(context, mSession, mAvatarView, mGroup) + } + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/VectorListPreference.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorListPreference.kt new file mode 100644 index 0000000000..5ea5db3664 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorListPreference.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import im.vector.riotredesign.R + +/** + * Customize ListPreference class to add a warning icon to the right side of the list. + */ +class VectorListPreference : ListPreference { + + // + private var mWarningIconView: View? = null + private var mIsWarningIconVisible = false + private var mWarningIconClickListener: OnPreferenceWarningIconClickListener? = null + + /** + * Interface definition for a callback to be invoked when the warning icon is clicked. + */ + interface OnPreferenceWarningIconClickListener { + /** + * Called when a warning icon has been clicked. + * + * @param preference The Preference that was clicked. + */ + fun onWarningIconClick(preference: Preference) + } + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + init { + widgetLayoutResource = R.layout.vector_settings_list_preference_with_warning + isIconSpaceReserved = false + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val view = holder.itemView + + mWarningIconView = view.findViewById(R.id.list_preference_warning_icon) + mWarningIconView!!.visibility = if (mIsWarningIconVisible) View.VISIBLE else View.GONE + + mWarningIconView!!.setOnClickListener { + if (null != mWarningIconClickListener) { + mWarningIconClickListener!!.onWarningIconClick(this@VectorListPreference) + } + } + } + + /** + * Sets the callback to be invoked when this warning icon is clicked. + * + * @param onPreferenceWarningIconClickListener The callback to be invoked. + */ + fun setOnPreferenceWarningIconClickListener(onPreferenceWarningIconClickListener: OnPreferenceWarningIconClickListener) { + mWarningIconClickListener = onPreferenceWarningIconClickListener + } + + /** + * Set the warning icon visibility. + * + * @param isVisible to display the icon + */ + fun setWarningIconVisible(isVisible: Boolean) { + mIsWarningIconVisible = isVisible + + if (null != mWarningIconView) { + mWarningIconView!!.visibility = if (mIsWarningIconVisible) View.VISIBLE else View.GONE + } + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreference.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreference.kt new file mode 100755 index 0000000000..6b46a6332d --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreference.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.animation.Animator +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.util.AttributeSet +import android.view.View +import android.widget.TextView +import androidx.core.animation.doOnEnd +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import im.vector.riotredesign.R +import im.vector.riotredesign.features.themes.ThemeUtils +import timber.log.Timber + + +/** + * create a Preference with a dedicated click/long click methods. + * It also allow the title to be displayed on several lines + */ +open class VectorPreference : Preference { + + var mTypeface = Typeface.NORMAL + + // long press listener + /** + * Returns the callback to be invoked when this Preference is long clicked. + * + * @return The callback to be invoked. + */ + /** + * Sets the callback to be invoked when this Preference is long clicked. + * + * @param onPreferenceLongClickListener The callback to be invoked. + */ + var onPreferenceLongClickListener: OnPreferenceLongClickListener? = null + + /** + * Interface definition for a callback to be invoked when a preference is + * long clicked. + */ + interface OnPreferenceLongClickListener { + /** + * Called when a Preference has been clicked. + * + * @param preference The Preference that was clicked. + * @return True if the click was handled. + */ + fun onPreferenceLongClick(preference: Preference): Boolean + } + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + + init { + isIconSpaceReserved = false + } + + var isHighlighted = false + set(value) { + field = value + notifyChanged() + } + + var currentHighlightAnimator: Animator? = null + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + val itemView = holder.itemView + addClickListeners(itemView) + + // display the title in multi-line to avoid ellipsis. + try { + val title = itemView.findViewById(android.R.id.title) + val summary = itemView.findViewById(android.R.id.summary) + if (title != null) { + title.setSingleLine(false) + title.setTypeface(null, mTypeface) + } + + if (title !== summary) { + summary.setTypeface(null, mTypeface) + } + + //cancel existing animation (find a way to resume if happens during anim?) + currentHighlightAnimator?.cancel() + if (isHighlighted) { + val colorFrom = Color.TRANSPARENT + val colorTo = ThemeUtils.getColor(itemView.context, R.attr.colorAccent) + currentHighlightAnimator = ValueAnimator.ofObject(ArgbEvaluator(), colorFrom, colorTo).apply { + duration = 250 // milliseconds + addUpdateListener { animator -> + itemView?.setBackgroundColor(animator.animatedValue as Int) + } + doOnEnd { + currentHighlightAnimator = ValueAnimator.ofObject(ArgbEvaluator(), colorTo, colorFrom).apply { + duration = 250 // milliseconds + addUpdateListener { animator -> + itemView?.setBackgroundColor(animator.animatedValue as Int) + } + doOnEnd { + isHighlighted = false + } + start() + } + } + startDelay = 200 + start() + } + } else { + itemView.setBackgroundColor(Color.TRANSPARENT) + } + + } catch (e: Exception) { + Timber.e(LOG_TAG, "onBindView " + e.message, e) + } + + super.onBindViewHolder(holder) + } + + /** + * @param view + */ + private fun addClickListeners(view: View) { + view.setOnLongClickListener { + if (null != onPreferenceLongClickListener) { + onPreferenceLongClickListener!!.onPreferenceLongClick(this@VectorPreference) + } else false + } + + view.setOnClickListener { + // call only the click listener + if (onPreferenceClickListener != null) { + onPreferenceClickListener.onPreferenceClick(this@VectorPreference) + } + } + } + + companion object { + private val LOG_TAG = VectorPreference::class.java.simpleName + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreferenceCategory.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreferenceCategory.kt new file mode 100644 index 0000000000..ebe49127d8 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreferenceCategory.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.graphics.Typeface +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.TextView +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceViewHolder + +/** + * Customize PreferenceCategory class to redefine some attributes. + */ +class VectorPreferenceCategory : PreferenceCategory { + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + init { + isIconSpaceReserved = false + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val titleTextView = holder.itemView.findViewById(android.R.id.title) + + titleTextView?.setTypeface(null, Typeface.BOLD) + + // "isIconSpaceReserved = false" does not work for preference category, so remove the padding + (titleTextView?.parent as? ViewGroup)?.setPadding(0, 0, 0, 0) + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreferenceDivider.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreferenceDivider.kt new file mode 100644 index 0000000000..7cb1ec4cdf --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorPreferenceDivider.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.Preference +import im.vector.riotredesign.R + +/** + * Divider for Preference screen + */ +class VectorPreferenceDivider @JvmOverloads constructor(context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : Preference(context, attrs, defStyleAttr, defStyleRes) { + + init { + layoutResource = R.layout.vector_preference_divider + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/preference/VectorSwitchPreference.kt b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorSwitchPreference.kt new file mode 100644 index 0000000000..27af34551b --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/preference/VectorSwitchPreference.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.preference + +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.preference.PreferenceViewHolder +import androidx.preference.SwitchPreference + +/** + * Switch preference with title on multiline (only used in XML) + */ +class VectorSwitchPreference : SwitchPreference { + + // Note: @JvmOverload does not work here... + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context) : super(context) + + init { + isIconSpaceReserved = false + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + // display the title in multi-line to avoid ellipsis. + holder.itemView.findViewById(android.R.id.title)?.setSingleLine(false) + + super.onBindViewHolder(holder) + } +} diff --git a/vector/src/main/java/im/vector/riotredesign/core/services/CallService.kt b/vector/src/main/java/im/vector/riotredesign/core/services/CallService.kt new file mode 100644 index 0000000000..cd9f1329e7 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/services/CallService.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.services + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import im.vector.riotredesign.features.notifications.NotificationUtils +import timber.log.Timber + +/** + * Foreground service to manage calls + */ +class CallService : VectorService() { + + /** + * call in progress (foreground notification) + */ + private var mCallIdInProgress: String? = null + + /** + * incoming (foreground notification) + */ + private var mIncomingCallId: String? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) { + // Service started again by the system. + // TODO What do we do here? + return START_STICKY + } + + when (intent.action) { + ACTION_INCOMING_CALL -> displayIncomingCallNotification(intent) + ACTION_PENDING_CALL -> displayCallInProgressNotification(intent) + ACTION_NO_ACTIVE_CALL -> hideCallNotifications() + else -> + // Should not happen + myStopSelf() + } + + // We want the system to restore the service if killed + return START_STICKY + } + + //================================================================================ + // Call notification management + //================================================================================ + + /** + * Display a permanent notification when there is an incoming call. + * + * @param session the session + * @param isVideo true if this is a video call, false for voice call + * @param room the room + * @param callId the callId + */ + private fun displayIncomingCallNotification(intent: Intent) { + Timber.d("displayIncomingCallNotification") + + // TODO + /* + + // the incoming call in progress is already displayed + if (!TextUtils.isEmpty(mIncomingCallId)) { + Timber.d("displayIncomingCallNotification : the incoming call in progress is already displayed") + } else if (!TextUtils.isEmpty(mCallIdInProgress)) { + Timber.d("displayIncomingCallNotification : a 'call in progress' notification is displayed") + } else if (null == CallsManager.getSharedInstance().activeCall) { + val callId = intent.getStringExtra(EXTRA_CALL_ID) + + Timber.d("displayIncomingCallNotification : display the dedicated notification") + val notification = NotificationUtils.buildIncomingCallNotification( + this, + intent.getBooleanExtra(EXTRA_IS_VIDEO, false), + intent.getStringExtra(EXTRA_ROOM_NAME), + intent.getStringExtra(EXTRA_MATRIX_ID), + callId) + startForeground(NOTIFICATION_ID, notification) + + mIncomingCallId = callId + + // turn the screen on for 3 seconds + if (Matrix.getInstance(VectorApp.getInstance())!!.pushManager.isScreenTurnedOn) { + try { + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + val wl = pm.newWakeLock( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or PowerManager.ACQUIRE_CAUSES_WAKEUP, + CallService::class.java.simpleName) + wl.acquire(3000) + wl.release() + } catch (re: RuntimeException) { + Timber.e(re, "displayIncomingCallNotification : failed to turn screen on ") + } + + } + } else { + Timber.i("displayIncomingCallNotification : do not display the incoming call notification because there is a pending call") + }// test if there is no active call + */ + } + + /** + * Display a call in progress notification. + */ + private fun displayCallInProgressNotification(intent: Intent) { + val callId = intent.getStringExtra(EXTRA_CALL_ID) + + val notification = NotificationUtils.buildPendingCallNotification(applicationContext, + intent.getBooleanExtra(EXTRA_IS_VIDEO, false), + intent.getStringExtra(EXTRA_ROOM_NAME), + intent.getStringExtra(EXTRA_ROOM_ID), + intent.getStringExtra(EXTRA_MATRIX_ID), + callId) + + startForeground(NOTIFICATION_ID, notification) + + mCallIdInProgress = callId + } + + /** + * Hide the permanent call notifications + */ + private fun hideCallNotifications() { + val notification = NotificationUtils.buildCallEndedNotification(applicationContext) + + // It's mandatory to startForeground to avoid crash + startForeground(NOTIFICATION_ID, notification) + + myStopSelf() + } + + companion object { + private const val NOTIFICATION_ID = 6480 + + private const val ACTION_INCOMING_CALL = "im.vector.riotredesign.core.services.CallService.INCOMING_CALL" + private const val ACTION_PENDING_CALL = "im.vector.riotredesign.core.services.CallService.PENDING_CALL" + private const val ACTION_NO_ACTIVE_CALL = "im.vector.riotredesign.core.services.CallService.NO_ACTIVE_CALL" + + private const val EXTRA_IS_VIDEO = "EXTRA_IS_VIDEO" + private const val EXTRA_ROOM_NAME = "EXTRA_ROOM_NAME" + private const val EXTRA_ROOM_ID = "EXTRA_ROOM_ID" + private const val EXTRA_MATRIX_ID = "EXTRA_MATRIX_ID" + private const val EXTRA_CALL_ID = "EXTRA_CALL_ID" + + fun onIncomingCall(context: Context, + isVideo: Boolean, + roomName: String, + roomId: String, + matrixId: String, + callId: String) { + val intent = Intent(context, CallService::class.java) + .apply { + action = ACTION_INCOMING_CALL + putExtra(EXTRA_IS_VIDEO, isVideo) + putExtra(EXTRA_ROOM_NAME, roomName) + putExtra(EXTRA_ROOM_ID, roomId) + putExtra(EXTRA_MATRIX_ID, matrixId) + putExtra(EXTRA_CALL_ID, callId) + } + + ContextCompat.startForegroundService(context, intent) + } + + fun onPendingCall(context: Context, + isVideo: Boolean, + roomName: String, + roomId: String, + matrixId: String, + callId: String) { + val intent = Intent(context, CallService::class.java) + .apply { + action = ACTION_PENDING_CALL + putExtra(EXTRA_IS_VIDEO, isVideo) + putExtra(EXTRA_ROOM_NAME, roomName) + putExtra(EXTRA_ROOM_ID, roomId) + putExtra(EXTRA_MATRIX_ID, matrixId) + putExtra(EXTRA_CALL_ID, callId) + } + + ContextCompat.startForegroundService(context, intent) + } + + fun onNoActiveCall(context: Context) { + val intent = Intent(context, CallService::class.java) + .apply { + action = ACTION_NO_ACTIVE_CALL + } + + ContextCompat.startForegroundService(context, intent) + } + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/services/EventStreamServiceX.kt b/vector/src/main/java/im/vector/riotredesign/core/services/EventStreamServiceX.kt new file mode 100644 index 0000000000..306063297e --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/services/EventStreamServiceX.kt @@ -0,0 +1,583 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.services + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import androidx.work.Constraints +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.riotredesign.R +import im.vector.riotredesign.features.notifications.NotifiableEventResolver +import im.vector.riotredesign.features.notifications.NotificationUtils +import org.koin.android.ext.android.inject +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * A service in charge of controlling whether the event stream is running or not. + * + * It manages messages notifications displayed to the end user. + */ +class EventStreamServiceX : VectorService() { + + /** + * Managed session (no multi session for Riot) + */ + private val mSession by inject() + + /** + * Set to true to simulate a push immediately when service is destroyed + */ + private var mSimulatePushImmediate = false + + /** + * The current state. + */ + private var serviceState = ServiceState.INIT + set(newServiceState) { + Timber.i("setServiceState from $field to $newServiceState") + field = newServiceState + } + + /** + * Push manager + */ + // TODO private var mPushManager: PushManager? = null + + private var mNotifiableEventResolver: NotifiableEventResolver? = null + + /** + * Live events listener + */ + /* TODO + private val mEventsListener = object : MXEventListener() { + override fun onBingEvent(event: Event, roomState: RoomState, bingRule: BingRule) { + if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { + Timber.i("%%%%%%%% MXEventListener: the event $event") + } + + Timber.i("prepareNotification : " + event.eventId + " in " + roomState.roomId) + val session = Matrix.getMXSession(applicationContext, event.matrixId) + + // invalid session ? + // should never happen. + // But it could be triggered because of multi accounts management. + // The dedicated account is removing but some pushes are still received. + if (null == session || !session.isAlive) { + Timber.i("prepareNotification : don't bing - no session") + return + } + + if (EventType.CALL_INVITE == event.type) { + handleCallInviteEvent(event) + return + } + + + val notifiableEvent = mNotifiableEventResolver!!.resolveEvent(event, roomState, bingRule, session) + if (notifiableEvent != null) { + VectorApp.getInstance().notificationDrawerManager.onNotifiableEventReceived(notifiableEvent) + } + } + + override fun onLiveEventsChunkProcessed(fromToken: String, toToken: String) { + Timber.i("%%%%%%%% MXEventListener: onLiveEventsChunkProcessed[$fromToken->$toToken]") + + VectorApp.getInstance().notificationDrawerManager.refreshNotificationDrawer(OutdatedEventDetector(this@EventStreamServiceX)) + + // do not suspend the application if there is some active calls + if (ServiceState.CATCHUP == serviceState) { + val hasActiveCalls = mSession?.mCallsManager?.hasActiveCalls() == true + + // if there are some active calls, the catchup should not be stopped. + // because an user could answer to a call from another device. + // there will no push because it is his own message. + // so, the client has no choice to catchup until the ring is shutdown + if (hasActiveCalls) { + Timber.i("onLiveEventsChunkProcessed : Catchup again because there are active calls") + catchup(false) + } else if (ServiceState.CATCHUP == serviceState) { + Timber.i("onLiveEventsChunkProcessed : no Active call") + CallsManager.getSharedInstance().checkDeadCalls() + stop() + } + } + } + } + */ + + /** + * Service internal state + */ + private enum class ServiceState { + // Initial state + INIT, + // Service is started for a Catchup. Once the catchup is finished the service will be stopped + CATCHUP, + // Service is started, and session is monitored + STARTED + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Cancel any previous worker + cancelAnySimulatedPushSchedule() + + // no intent : restarted by Android + if (null == intent) { + // Cannot happen anymore + Timber.e("onStartCommand : null intent") + myStopSelf() + return START_NOT_STICKY + } + + val action = intent.action + + Timber.i("onStartCommand with action : $action (current state $serviceState)") + + // Manage foreground notification + when (action) { + ACTION_BOOT_COMPLETE, + ACTION_APPLICATION_UPGRADE, + ACTION_SIMULATED_PUSH_RECEIVED -> { + // Display foreground notification + Timber.i("startForeground") + val notification = NotificationUtils.buildForegroundServiceNotification(this, R.string.notification_sync_in_progress) + startForeground(NotificationUtils.NOTIFICATION_ID_FOREGROUND_SERVICE, notification) + } + ACTION_GO_TO_FOREGROUND -> { + // Stop foreground notification display + Timber.i("stopForeground") + stopForeground(true) + } + } + + if (null == mSession) { + Timber.e("onStartCommand : no sessions") + myStopSelf() + return START_NOT_STICKY + } + + when (action) { + ACTION_START, + ACTION_GO_TO_FOREGROUND -> + when (serviceState) { + ServiceState.INIT -> + start(false) + ServiceState.CATCHUP -> + // A push has been received before, just change state, to avoid stopping the service when catchup is over + serviceState = ServiceState.STARTED + ServiceState.STARTED -> { + // Nothing to do + } + } + ACTION_STOP, + ACTION_GO_TO_BACKGROUND, + ACTION_LOGOUT -> + stop() + ACTION_PUSH_RECEIVED, + ACTION_SIMULATED_PUSH_RECEIVED -> + when (serviceState) { + ServiceState.INIT -> + start(true) + ServiceState.CATCHUP -> + catchup(true) + ServiceState.STARTED -> + // Nothing to do + Unit + } + ACTION_PUSH_UPDATE -> pushStatusUpdate() + ACTION_BOOT_COMPLETE -> { + // No FCM only + mSimulatePushImmediate = true + stop() + } + ACTION_APPLICATION_UPGRADE -> { + // FDroid only + catchup(true) + } + else -> { + // Should not happen + } + } + + // We don't want the service to be restarted automatically by the System + return START_NOT_STICKY + } + + override fun onDestroy() { + super.onDestroy() + + // Schedule worker? + scheduleSimulatedPushIfNeeded() + } + + /** + * Tell the WorkManager to cancel any schedule of push simulation + */ + private fun cancelAnySimulatedPushSchedule() { + WorkManager.getInstance().cancelAllWorkByTag(PUSH_SIMULATOR_REQUEST_TAG) + } + + /** + * Configure the WorkManager to schedule a simulated push, if necessary + */ + private fun scheduleSimulatedPushIfNeeded() { + if (shouldISimulatePush()) { + val delay = if (mSimulatePushImmediate) 0 else 60_000 // TODO mPushManager?.backgroundSyncDelay ?: let { 60_000 } + Timber.i("## service is schedule to restart in $delay millis, if network is connected") + + val pushSimulatorRequest = OneTimeWorkRequestBuilder() + .setInitialDelay(delay.toLong(), TimeUnit.MILLISECONDS) + .setConstraints(Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build()) + .addTag(PUSH_SIMULATOR_REQUEST_TAG) + .build() + + WorkManager.getInstance().let { + // Cancel any previous worker + it.cancelAllWorkByTag(PUSH_SIMULATOR_REQUEST_TAG) + it.enqueue(pushSimulatorRequest) + } + } + } + + /** + * Start the even stream. + * + * @param session the session + */ + private fun startEventStream(session: Session) { + /* TODO + // resume if it was only suspended + if (null != session.currentSyncToken) { + session.resumeEventStream() + } else { + session.startEventStream(store?.eventStreamToken) + } + */ + } + + /** + * Monitor the provided session. + * + * @param session the session + */ + private fun monitorSession(session: Session) { + /* TODO + session.dataHandler.addListener(mEventsListener) + CallsManager.getSharedInstance().addSession(session) + + val store = session.dataHandler.store + + // the store is ready (no data loading in progress...) + if (store!!.isReady) { + startEventStream(session, store) + } else { + // wait that the store is ready before starting the events stream + store.addMXStoreListener(object : MXStoreListener() { + override fun onStoreReady(accountId: String) { + startEventStream(session, store) + + store.removeMXStoreListener(this) + } + + override fun onStoreCorrupted(accountId: String, description: String) { + // start a new initial sync + if (null == store.eventStreamToken) { + startEventStream(session, store) + } else { + // the data are out of sync + Matrix.getInstance(applicationContext)!!.reloadSessions(applicationContext) + } + + store.removeMXStoreListener(this) + } + + override fun onStoreOOM(accountId: String, description: String) { + val uiHandler = Handler(mainLooper) + + uiHandler.post { + Toast.makeText(applicationContext, "$accountId : $description", Toast.LENGTH_LONG).show() + Matrix.getInstance(applicationContext)!!.reloadSessions(applicationContext) + } + } + }) + + store.open() + } + */ + } + + /** + * internal start. + */ + private fun start(forPush: Boolean) { + val applicationContext = applicationContext + // TODO mPushManager = Matrix.getInstance(applicationContext)!!.pushManager + mNotifiableEventResolver = NotifiableEventResolver(applicationContext) + + monitorSession(mSession!!) + + serviceState = if (forPush) { + ServiceState.CATCHUP + } else { + ServiceState.STARTED + } + } + + /** + * internal stop. + */ + private fun stop() { + Timber.i("## stop(): the service is stopped") + + /* TODO + if (null != mSession && mSession!!.isAlive) { + mSession!!.stopEventStream() + mSession!!.dataHandler.removeListener(mEventsListener) + CallsManager.getSharedInstance().removeSession(mSession) + } + mSession = null + */ + + // Stop the service + myStopSelf() + } + + /** + * internal catchup method. + * + * @param checkState true to check if the current state allow to perform a catchup + */ + private fun catchup(checkState: Boolean) { + var canCatchup = true + + if (!checkState) { + Timber.i("catchup without checking serviceState ") + } else { + Timber.i("catchup with serviceState " + serviceState + " CurrentActivity ") // TODO + VectorApp.getCurrentActivity()) + + /* TODO + // the catchup should only be done + // 1- the serviceState is in catchup : the event stream might have gone to sleep between two catchups + // 2- the thread is suspended + // 3- the application has been launched by a push so there is no displayed activity + canCatchup = (serviceState == ServiceState.CATCHUP + //|| (serviceState == ServiceState.PAUSE) + || ServiceState.STARTED == serviceState && null == VectorApp.getCurrentActivity()) + */ + } + + if (canCatchup) { + if (mSession != null) { + // TODO mSession!!.catchupEventStream() + } else { + Timber.i("catchup no session") + } + + serviceState = ServiceState.CATCHUP + } else { + Timber.i("No catchup is triggered because there is already a running event thread") + } + } + + /** + * The push status has been updated (i.e disabled or enabled). + * TODO Useless now? + */ + private fun pushStatusUpdate() { + Timber.i("## pushStatusUpdate") + } + + /* ========================================================================================== + * Push simulator + * ========================================================================================== */ + + /** + * @return true if the FCM is disable or not setup, user allowed background sync, user wants notification + */ + private fun shouldISimulatePush(): Boolean { + return false + + /* TODO + + if (Matrix.getInstance(applicationContext)?.defaultSession == null) { + Timber.i("## shouldISimulatePush: NO: no session") + + return false + } + + mPushManager?.let { pushManager -> + if (pushManager.useFcm() + && !TextUtils.isEmpty(pushManager.currentRegistrationToken) + && pushManager.isServerRegistered) { + // FCM is ok + Timber.i("## shouldISimulatePush: NO: FCM is up") + return false + } + + if (!pushManager.isBackgroundSyncAllowed) { + // User has disabled background sync + Timber.i("## shouldISimulatePush: NO: background sync not allowed") + return false + } + + if (!pushManager.areDeviceNotificationsAllowed()) { + // User does not want notifications + Timber.i("## shouldISimulatePush: NO: user does not want notification") + return false + } + } + + // Lets simulate push + Timber.i("## shouldISimulatePush: YES") + return true + */ + } + + + //================================================================================ + // Call management + //================================================================================ + + private fun handleCallInviteEvent(event: Event) { + /* + TODO + val session = Matrix.getMXSession(applicationContext, event.matrixId) + + // invalid session ? + // should never happen. + // But it could be triggered because of multi accounts management. + // The dedicated account is removing but some pushes are still received. + if (null == session || !session.isAlive) { + Timber.d("prepareCallNotification : don't bing - no session") + return + } + + val room: Room? = session.dataHandler.getRoom(event.roomId) + + // invalid room ? + if (null == room) { + Timber.i("prepareCallNotification : don't bing - the room does not exist") + return + } + + var callId: String? = null + var isVideo = false + + try { + callId = event.contentAsJsonObject?.get("call_id")?.asString + + // Check if it is a video call + val offer = event.contentAsJsonObject?.get("offer")?.asJsonObject + val sdp = offer?.get("sdp") + val sdpValue = sdp?.asString + + isVideo = sdpValue?.contains("m=video") == true + } catch (e: Exception) { + Timber.e("prepareNotification : getContentAsJsonObject " + e.message, e) + } + + if (!TextUtils.isEmpty(callId)) { + CallService.onIncomingCall(this, + isVideo, + room.getRoomDisplayName(this), + room.roomId, + session.myUserId!!, + callId!!) + } + */ + } + + companion object { + private const val PUSH_SIMULATOR_REQUEST_TAG = "PUSH_SIMULATOR_REQUEST_TAG" + + private const val ACTION_START = "im.vector.riotredesign.core.services.EventStreamServiceX.START" + private const val ACTION_LOGOUT = "im.vector.riotredesign.core.services.EventStreamServiceX.LOGOUT" + private const val ACTION_GO_TO_FOREGROUND = "im.vector.riotredesign.core.services.EventStreamServiceX.GO_TO_FOREGROUND" + private const val ACTION_GO_TO_BACKGROUND = "im.vector.riotredesign.core.services.EventStreamServiceX.GO_TO_BACKGROUND" + private const val ACTION_PUSH_UPDATE = "im.vector.riotredesign.core.services.EventStreamServiceX.PUSH_UPDATE" + private const val ACTION_PUSH_RECEIVED = "im.vector.riotredesign.core.services.EventStreamServiceX.PUSH_RECEIVED" + private const val ACTION_SIMULATED_PUSH_RECEIVED = "im.vector.riotredesign.core.services.EventStreamServiceX.SIMULATED_PUSH_RECEIVED" + private const val ACTION_STOP = "im.vector.riotredesign.core.services.EventStreamServiceX.STOP" + private const val ACTION_BOOT_COMPLETE = "im.vector.riotredesign.core.services.EventStreamServiceX.BOOT_COMPLETE" + private const val ACTION_APPLICATION_UPGRADE = "im.vector.riotredesign.core.services.EventStreamServiceX.APPLICATION_UPGRADE" + + /* ========================================================================================== + * Events sent to the service + * ========================================================================================== */ + + fun onApplicationStarted(context: Context) { + sendAction(context, ACTION_START) + } + + fun onLogout(context: Context) { + sendAction(context, ACTION_LOGOUT) + } + + fun onAppGoingToForeground(context: Context) { + sendAction(context, ACTION_GO_TO_FOREGROUND) + } + + fun onAppGoingToBackground(context: Context) { + sendAction(context, ACTION_GO_TO_BACKGROUND) + } + + fun onPushUpdate(context: Context) { + sendAction(context, ACTION_PUSH_UPDATE) + } + + fun onPushReceived(context: Context) { + sendAction(context, ACTION_PUSH_RECEIVED) + } + + fun onSimulatedPushReceived(context: Context) { + sendAction(context, ACTION_SIMULATED_PUSH_RECEIVED, true) + } + + fun onApplicationStopped(context: Context) { + sendAction(context, ACTION_STOP) + } + + fun onBootComplete(context: Context) { + sendAction(context, ACTION_BOOT_COMPLETE, true) + } + + fun onApplicationUpgrade(context: Context) { + sendAction(context, ACTION_APPLICATION_UPGRADE, true) + } + + private fun sendAction(context: Context, action: String, foreground: Boolean = false) { + Timber.i("sendAction $action") + + val intent = Intent(context, EventStreamServiceX::class.java) + intent.action = action + + if (foreground) { + ContextCompat.startForegroundService(context, intent) + } else { + context.startService(intent) + } + } + } +} diff --git a/vector/src/main/java/im/vector/riotredesign/core/services/PushSimulatorWorker.kt b/vector/src/main/java/im/vector/riotredesign/core/services/PushSimulatorWorker.kt new file mode 100644 index 0000000000..d3f93f3280 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/services/PushSimulatorWorker.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.services + +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters + +/** + * This class simulate push event when FCM is not working/disabled + */ +class PushSimulatorWorker(val context: Context, + workerParams: WorkerParameters) : Worker(context, workerParams) { + + override fun doWork(): Result { + // Simulate a Push + EventStreamServiceX.onSimulatedPushReceived(context) + + // Indicate whether the task finished successfully with the Result + return Result.success() + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/services/VectorService.kt b/vector/src/main/java/im/vector/riotredesign/core/services/VectorService.kt new file mode 100644 index 0000000000..4b4c500d01 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/services/VectorService.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.services + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import timber.log.Timber + +/** + * Parent class for all services + */ +abstract class VectorService : Service() { + + /** + * Tells if the service self destroyed. + */ + private var mIsSelfDestroyed = false + + override fun onCreate() { + super.onCreate() + + Timber.i("## onCreate() : $this") + } + + override fun onDestroy() { + Timber.i("## onDestroy() : $this") + + if (!mIsSelfDestroyed) { + Timber.w("## Destroy by the system : $this") + } + + super.onDestroy() + } + + protected fun myStopSelf() { + mIsSelfDestroyed = true + stopSelf() + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/utils/RingtoneUtils.kt b/vector/src/main/java/im/vector/riotredesign/core/utils/RingtoneUtils.kt new file mode 100644 index 0000000000..89c800d49e --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/utils/RingtoneUtils.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.core.utils + +import android.content.Context +import android.media.Ringtone +import android.media.RingtoneManager +import android.net.Uri +import android.preference.PreferenceManager +import androidx.core.content.edit +import im.vector.riotredesign.features.settings.PreferencesManager + +/** + * This file manages the sound ringtone for calls. + * It allows you to use the default Riot Ringtone, or the standard ringtone or set a different one from the available choices + * in Android. + */ + +/** + * Returns a Uri object that points to a specific Ringtone. + * + * If no Ringtone was explicitly set using Riot, it will return the Uri for the current system + * ringtone for calls. + * + * @return the [Uri] of the currently set [Ringtone] + * @see Ringtone + */ +fun getCallRingtoneUri(context: Context): Uri? { + val callRingtone: String? = PreferenceManager.getDefaultSharedPreferences(context) + .getString(PreferencesManager.SETTINGS_CALL_RINGTONE_URI_PREFERENCE_KEY, null) + + callRingtone?.let { + return Uri.parse(it) + } + + return try { + // Use current system notification sound for incoming calls per default (note that it can return null) + RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE) + } catch (e: SecurityException) { + // Ignore for now + null + } +} + +/** + * Returns a Ringtone object that can then be played. + * + * If no Ringtone was explicitly set using Riot, it will return the current system ringtone + * for calls. + * + * @return the currently set [Ringtone] + * @see Ringtone + */ +fun getCallRingtone(context: Context): Ringtone? { + getCallRingtoneUri(context)?.let { + // Note that it can also return null + return RingtoneManager.getRingtone(context, it) + } + + return null +} + +/** + * Returns a String with the name of the current Ringtone. + * + * If no Ringtone was explicitly set using Riot, it will return the name of the current system + * ringtone for calls. + * + * @return the name of the currently set [Ringtone], or null + * @see Ringtone + */ +fun getCallRingtoneName(context: Context): String? { + return getCallRingtone(context)?.getTitle(context) +} + +/** + * Sets the selected ringtone for riot calls. + * + * @param ringtoneUri + * @see Ringtone + */ +fun setCallRingtoneUri(context: Context, ringtoneUri: Uri) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit { + putString(PreferencesManager.SETTINGS_CALL_RINGTONE_URI_PREFERENCE_KEY, ringtoneUri.toString()) + } +} + +/** + * Set using Riot default ringtone + */ +fun useRiotDefaultRingtone(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(PreferencesManager.SETTINGS_CALL_RINGTONE_USE_RIOT_PREFERENCE_KEY, true) +} + +/** + * Ask if default Riot ringtone has to be used + */ +fun setUseRiotDefaultRingtone(context: Context, useRiotDefault: Boolean) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit { + putBoolean(PreferencesManager.SETTINGS_CALL_RINGTONE_USE_RIOT_PREFERENCE_KEY, useRiotDefault) + } +} + diff --git a/vector/src/main/java/im/vector/riotredesign/core/utils/SecretStoringUtils.kt b/vector/src/main/java/im/vector/riotredesign/core/utils/SecretStoringUtils.kt new file mode 100644 index 0000000000..e139ee61a4 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/utils/SecretStoringUtils.kt @@ -0,0 +1,576 @@ +package im.vector.riotredesign.core.utils + +import android.content.Context +import android.os.Build +import android.security.KeyPairGeneratorSpec +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.annotation.RequiresApi +import java.io.* +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.SecureRandom +import java.util.* +import javax.crypto.* +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec +import javax.security.auth.x500.X500Principal + + +/** + * Offers simple methods to securely store secrets in an Android Application. + * The encryption keys are randomly generated and securely managed by the key store, thus your secrets + * are safe. You only need to remember a key alias to perform encrypt/decrypt operations. + * + * Android M++ + * On android M+, the keystore can generates and store AES keys via API. But below API M this functionality + * is not available. + * + * Android [K-M[ + * For android >=KITKAT and Older androids + * For older androids as a fallback we generate an AES key from the alias using PBKDF2 with random salt. + * The salt and iv are stored with encrypted data. + * + * Sample usage: + * + * val secret = "The answer is 42" + * val KEncrypted = SecretStoringUtils.securelyStoreString(secret, "myAlias", context) + * //This can be stored anywhere e.g. encoded in b64 and stored in preference for example + * + * //to get back the secret, just call + * val kDecripted = SecretStoringUtils.loadSecureSecret(KEncrypted!!, "myAlias", context) + * + * + * You can also just use this utility to store a secret key, and use any encryption algorthim that you want. + * + * Important: Keys stored in the keystore can be wiped out (depends of the OS version, like for example if you + * add a pin or change the schema); So you might and with a useless pile of bytes. + */ +object SecretStoringUtils { + + private const val ANDROID_KEY_STORE = "AndroidKeyStore" + private const val AES_MODE = "AES/GCM/NoPadding"; + private const val RSA_MODE = "RSA/ECB/PKCS1Padding" + + const val FORMAT_API_M: Byte = 0 + const val FORMAT_1: Byte = 1 + const val FORMAT_2: Byte = 2 + + val keyStore: KeyStore by lazy { + KeyStore.getInstance(ANDROID_KEY_STORE).apply { + load(null) + } + } + + private val secureRandom = SecureRandom() + + /** + * Encrypt the given secret using the android Keystore. + * On android >= M, will directly use the keystore to generate a symetric key + * On KitKat >= KitKat and = Build.VERSION_CODES.M) { + return encryptStringM(secret, keyAlias) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return encryptStringJ(secret, keyAlias, context) + } else { + return encryptForOldDevicesNotGood(secret, keyAlias) + } + } + + /** + * Decrypt a secret that was encrypted by #securelyStoreString() + */ + @Throws(Exception::class) + fun loadSecureSecret(encrypted: ByteArray, keyAlias: String, context: Context): String? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return decryptStringM(encrypted, keyAlias) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return decryptStringJ(encrypted, keyAlias, context) + } else { + return decryptForOldDevicesNotGood(encrypted, keyAlias) + } + } + + fun securelyStoreObject(any: Any, keyAlias: String, output: OutputStream, context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + saveSecureObjectM(keyAlias, output, any) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return saveSecureObjectK(keyAlias, output, any, context) + } else { + return saveSecureObjectOldNotGood(keyAlias, output, any) + } + } + + fun loadSecureSecret(inputStream: InputStream, keyAlias: String, context: Context): T? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return loadSecureObjectM(keyAlias, inputStream) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return loadSecureObjectK(keyAlias, inputStream, context) + } else { + return loadSecureObjectOldNotGood(keyAlias, inputStream) + } + } + + + @RequiresApi(Build.VERSION_CODES.M) + fun getOrGenerateSymmetricKeyForAlias(alias: String): SecretKey { + val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry) + ?.secretKey + if (secretKeyEntry == null) { + //we generate it + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val keyGenSpec = KeyGenParameterSpec.Builder(alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(128) + .build() + generator.init(keyGenSpec) + return generator.generateKey() + } + return secretKeyEntry + } + + + /* + Symetric Key Generation is only available in M, so before M the idea is to: + - Generate a pair of RSA keys; + - Generate a random AES key; + - Encrypt the AES key using the RSA public key; + - Store the encrypted AES + Generate a key pair for encryption + */ + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + fun getOrGenerateKeyPairForAlias(alias: String, context: Context): KeyStore.PrivateKeyEntry { + val privateKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.PrivateKeyEntry) + + if (privateKeyEntry != null) return privateKeyEntry + + val start = Calendar.getInstance() + val end = Calendar.getInstance() + end.add(Calendar.YEAR, 30) + + val spec = KeyPairGeneratorSpec.Builder(context) + .setAlias(alias) + .setSubject(X500Principal("CN=$alias")) + .setSerialNumber(BigInteger.TEN) + //.setEncryptionRequired() requires that the phone as a pin/schema + .setStartDate(start.time) + .setEndDate(end.time) + .build() + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEY_STORE).run { + initialize(spec) + generateKeyPair() + } + return (keyStore.getEntry(alias, null) as KeyStore.PrivateKeyEntry) + + } + + + @RequiresApi(Build.VERSION_CODES.M) + fun encryptStringM(text: String, keyAlias: String): ByteArray? { + val secretKey = getOrGenerateSymmetricKeyForAlias(keyAlias) + + val cipher = Cipher.getInstance(AES_MODE) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + val iv = cipher.iv + //we happen the iv to the final result + val encryptedBytes: ByteArray = cipher.doFinal(text.toByteArray(Charsets.UTF_8)) + return formatMMake(iv, encryptedBytes) + } + + @RequiresApi(Build.VERSION_CODES.M) + fun decryptStringM(encryptedChunk: ByteArray, keyAlias: String): String { + val (iv, encryptedText) = formatMExtract(ByteArrayInputStream(encryptedChunk)) + + val secretKey = getOrGenerateSymmetricKeyForAlias(keyAlias) + + val cipher = Cipher.getInstance(AES_MODE) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + + return String(cipher.doFinal(encryptedText), Charsets.UTF_8) + } + + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + fun encryptStringJ(text: String, keyAlias: String, context: Context): ByteArray? { + //we generate a random symetric key + val key = ByteArray(16) + secureRandom.nextBytes(key) + val sKey = SecretKeySpec(key, "AES") + + //we encrypt this key thanks to the key store + val encryptedKey = rsaEncrypt(keyAlias, key, context) + + val cipher = Cipher.getInstance(AES_MODE) + cipher.init(Cipher.ENCRYPT_MODE, sKey) + val iv = cipher.iv + val encryptedBytes: ByteArray = cipher.doFinal(text.toByteArray(Charsets.UTF_8)) + + return format1Make(encryptedKey, iv, encryptedBytes) + } + + fun encryptForOldDevicesNotGood(text: String, keyAlias: String): ByteArray { + val salt = ByteArray(8) + secureRandom.nextBytes(salt) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val spec = PBEKeySpec(keyAlias.toCharArray(), salt, 10000, 128) + val tmp = factory.generateSecret(spec) + val sKey = SecretKeySpec(tmp.encoded, "AES") + + val cipher = Cipher.getInstance(AES_MODE) + cipher.init(Cipher.ENCRYPT_MODE, sKey) + val iv = cipher.iv + val encryptedBytes: ByteArray = cipher.doFinal(text.toByteArray(Charsets.UTF_8)) + + return format2Make(salt, iv, encryptedBytes) + } + + fun decryptForOldDevicesNotGood(data: ByteArray, keyAlias: String): String? { + + val (salt, iv, encrypted) = format2Extract(ByteArrayInputStream(data)) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val spec = PBEKeySpec(keyAlias.toCharArray(), salt, 10000, 128) + val tmp = factory.generateSecret(spec) + val sKey = SecretKeySpec(tmp.encoded, "AES") + + val cipher = Cipher.getInstance(AES_MODE) +// cipher.init(Cipher.ENCRYPT_MODE, sKey) +// val encryptedBytes: ByteArray = cipher.doFinal(text.toByteArray(Charsets.UTF_8)) + + val specIV = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) IvParameterSpec(iv) else GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, sKey, specIV) + + return String(cipher.doFinal(encrypted), Charsets.UTF_8) + } + + @RequiresApi(Build.VERSION_CODES.KITKAT) + fun decryptStringJ(data: ByteArray, keyAlias: String, context: Context): String? { + + val (encryptedKey, iv, encrypted) = format1Extract(ByteArrayInputStream(data)) + + //we need to decrypt the key + val sKeyBytes = rsaDecrypt(keyAlias, ByteArrayInputStream(encryptedKey), context) + val cipher = Cipher.getInstance(AES_MODE) + val spec = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) IvParameterSpec(iv) else GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sKeyBytes, "AES"), spec) + + return String(cipher.doFinal(encrypted), Charsets.UTF_8) + + } + + + @RequiresApi(Build.VERSION_CODES.M) + @Throws(IOException::class) + fun saveSecureObjectM(keyAlias: String, output: OutputStream, writeObject: Any) { + val secretKey = getOrGenerateSymmetricKeyForAlias(keyAlias) + + val cipher = Cipher.getInstance(AES_MODE) + cipher.init(Cipher.ENCRYPT_MODE, secretKey/*, spec*/) + val iv = cipher.iv + + val bos1 = ByteArrayOutputStream() + ObjectOutputStream(bos1).use { + it.writeObject(writeObject) + } + //Have to do it like that if i encapsulate the outputstream, the cipher could fail saying reuse IV + val doFinal = cipher.doFinal(bos1.toByteArray()) + output.write(FORMAT_API_M.toInt()) + output.write(iv.size) + output.write(iv) + output.write(doFinal) + } + + @RequiresApi(Build.VERSION_CODES.KITKAT) + fun saveSecureObjectK(keyAlias: String, output: OutputStream, writeObject: Any, context: Context) { + //we generate a random symetric key + val key = ByteArray(16) + secureRandom.nextBytes(key) + val sKey = SecretKeySpec(key, "AES") + + //we encrypt this key thanks to the key store + val encryptedKey = rsaEncrypt(keyAlias, key, context) + + val cipher = Cipher.getInstance(AES_MODE) + cipher.init(Cipher.ENCRYPT_MODE, sKey) + val iv = cipher.iv + + val bos1 = ByteArrayOutputStream() + val cos = CipherOutputStream(bos1, cipher) + ObjectOutputStream(cos).use { + it.writeObject(writeObject) + } + + output.write(FORMAT_1.toInt()) + output.write((encryptedKey.size and 0xFF00).shr(8)) + output.write(encryptedKey.size and 0x00FF) + output.write(encryptedKey) + output.write(iv.size) + output.write(iv) + output.write(bos1.toByteArray()) + } + + fun saveSecureObjectOldNotGood(keyAlias: String, output: OutputStream, writeObject: Any) { + val salt = ByteArray(8) + secureRandom.nextBytes(salt) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val tmp = factory.generateSecret(PBEKeySpec(keyAlias.toCharArray(), salt, 10000, 128)) + val secretKey = SecretKeySpec(tmp.encoded, "AES") + + + val cipher = Cipher.getInstance(AES_MODE) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + val iv = cipher.iv + + val bos1 = ByteArrayOutputStream() + ObjectOutputStream(bos1).use { + it.writeObject(writeObject) + } + //Have to do it like that if i encapsulate the outputstream, the cipher could fail saying reuse IV + val doFinal = cipher.doFinal(bos1.toByteArray()) + + output.write(FORMAT_2.toInt()) + output.write(salt.size) + output.write(salt) + output.write(iv.size) + output.write(iv) + output.write(doFinal) + } + +// @RequiresApi(Build.VERSION_CODES.M) +// @Throws(IOException::class) +// fun saveSecureObjectM(keyAlias: String, file: File, writeObject: Any) { +// FileOutputStream(file).use { +// saveSecureObjectM(keyAlias, it, writeObject) +// } +// } +// +// @RequiresApi(Build.VERSION_CODES.M) +// @Throws(IOException::class) +// fun loadSecureObjectM(keyAlias: String, file: File): T? { +// FileInputStream(file).use { +// return loadSecureObjectM(keyAlias, it) +// } +// } + + @RequiresApi(Build.VERSION_CODES.M) + @Throws(IOException::class) + fun loadSecureObjectM(keyAlias: String, inputStream: InputStream): T? { + val secretKey = getOrGenerateSymmetricKeyForAlias(keyAlias) + + val format = inputStream.read() + assert(format.toByte() == FORMAT_API_M) + + val ivSize = inputStream.read() + val iv = ByteArray(ivSize) + inputStream.read(iv, 0, ivSize) + val cipher = Cipher.getInstance(AES_MODE) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + + CipherInputStream(inputStream, cipher).use { cipherInputStream -> + ObjectInputStream(cipherInputStream).use { + val readObject = it.readObject() + return readObject as? T + } + } + + } + + @RequiresApi(Build.VERSION_CODES.KITKAT) + @Throws(IOException::class) + fun loadSecureObjectK(keyAlias: String, inputStream: InputStream, context: Context): T? { + + val (encryptedKey, iv, encrypted) = format1Extract(inputStream) + + //we need to decrypt the key + val sKeyBytes = rsaDecrypt(keyAlias, ByteArrayInputStream(encryptedKey), context) + val cipher = Cipher.getInstance(AES_MODE) + val spec = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) IvParameterSpec(iv) else GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sKeyBytes, "AES"), spec) + + val encIS = ByteArrayInputStream(encrypted) + + CipherInputStream(encIS, cipher).use { cipherInputStream -> + ObjectInputStream(cipherInputStream).use { + val readObject = it.readObject() + return readObject as? T + } + } + } + + @Throws(Exception::class) + fun loadSecureObjectOldNotGood(keyAlias: String, inputStream: InputStream): T? { + + val (salt, iv, encrypted) = format2Extract(inputStream) + + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val tmp = factory.generateSecret(PBEKeySpec(keyAlias.toCharArray(), salt, 10000, 128)) + val sKey = SecretKeySpec(tmp.encoded, "AES") + //we need to decrypt the key + + val cipher = Cipher.getInstance(AES_MODE) + val spec = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) IvParameterSpec(iv) else GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, sKey, spec) + + val encIS = ByteArrayInputStream(encrypted) + + CipherInputStream(encIS, cipher).use { + ObjectInputStream(it).use { + val readObject = it.readObject() + return readObject as? T + } + } + } + + + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + @Throws(Exception::class) + private fun rsaEncrypt(alias: String, secret: ByteArray, context: Context): ByteArray { + val privateKeyEntry = getOrGenerateKeyPairForAlias(alias, context) + // Encrypt the text + val inputCipher = Cipher.getInstance(RSA_MODE) + inputCipher.init(Cipher.ENCRYPT_MODE, privateKeyEntry.certificate.publicKey) + + val outputStream = ByteArrayOutputStream() + val cipherOutputStream = CipherOutputStream(outputStream, inputCipher) + cipherOutputStream.write(secret) + cipherOutputStream.close() + + return outputStream.toByteArray() + } + + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + @Throws(Exception::class) + private fun rsaDecrypt(alias: String, encrypted: InputStream, context: Context): ByteArray { + val privateKeyEntry = getOrGenerateKeyPairForAlias(alias, context) + val output = Cipher.getInstance(RSA_MODE) + output.init(Cipher.DECRYPT_MODE, privateKeyEntry.privateKey) + + val bos = ByteArrayOutputStream() + CipherInputStream(encrypted, output).use { + it.copyTo(bos) + } + + return bos.toByteArray() + } + + private fun formatMExtract(bis: InputStream): Pair { + val format = bis.read().toByte() + assert(format == FORMAT_API_M) + + val ivSize = bis.read() + val iv = ByteArray(ivSize) + bis.read(iv, 0, ivSize) + + + val bos = ByteArrayOutputStream() + var next = bis.read() + while (next != -1) { + bos.write(next) + next = bis.read() + } + val encrypted = bos.toByteArray() + return Pair(iv, encrypted) + } + + private fun formatMMake(iv: ByteArray, data: ByteArray): ByteArray { + val bos = ByteArrayOutputStream(2 + iv.size + data.size) + bos.write(FORMAT_API_M.toInt()) + bos.write(iv.size) + bos.write(iv) + bos.write(data) + return bos.toByteArray() + } + + private fun format1Extract(bis: InputStream): Triple { + + val format = bis.read() + assert(format.toByte() == FORMAT_1) + + val keySizeBig = bis.read() + val keySizeLow = bis.read() + val encryptedKeySize = keySizeBig.shl(8) + keySizeLow + val encryptedKey = ByteArray(encryptedKeySize) + bis.read(encryptedKey) + + val ivSize = bis.read() + val iv = ByteArray(ivSize) + bis.read(iv) + + val bos = ByteArrayOutputStream() + + var next = bis.read() + while (next != -1) { + bos.write(next) + next = bis.read() + } + val encrypted = bos.toByteArray() + return Triple(encryptedKey, iv, encrypted) + } + + private fun format1Make(encryptedKey: ByteArray, iv: ByteArray, encryptedBytes: ByteArray): ByteArray { + val bos = ByteArrayOutputStream(4 + encryptedKey.size + iv.size + encryptedBytes.size) + bos.write(FORMAT_1.toInt()) + bos.write((encryptedKey.size and 0xFF00).shr(8)) + bos.write(encryptedKey.size and 0x00FF) + bos.write(encryptedKey) + bos.write(iv.size) + bos.write(iv) + bos.write(encryptedBytes) + + return bos.toByteArray() + } + + private fun format2Make(salt: ByteArray, iv: ByteArray, encryptedBytes: ByteArray): ByteArray { + val bos = ByteArrayOutputStream(3 + salt.size + iv.size + encryptedBytes.size) + bos.write(FORMAT_2.toInt()) + bos.write(salt.size) + bos.write(salt) + bos.write(iv.size) + bos.write(iv) + bos.write(encryptedBytes) + + return bos.toByteArray() + } + + private fun format2Extract(bis: InputStream): Triple { + + val format = bis.read() + assert(format.toByte() == FORMAT_2) + + val saltSize = bis.read() + val salt = ByteArray(saltSize) + bis.read(salt) + + val ivSize = bis.read() + val iv = ByteArray(ivSize) + bis.read(iv) + + val bos = ByteArrayOutputStream() + + var next = bis.read() + while (next != -1) { + bos.write(next) + next = bis.read() + } + val encrypted = bos.toByteArray() + return Triple(salt, iv, encrypted) + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/core/utils/SystemUtils.kt b/vector/src/main/java/im/vector/riotredesign/core/utils/SystemUtils.kt index 5cf3ca492f..d21f0d7c19 100644 --- a/vector/src/main/java/im/vector/riotredesign/core/utils/SystemUtils.kt +++ b/vector/src/main/java/im/vector/riotredesign/core/utils/SystemUtils.kt @@ -26,6 +26,7 @@ import android.provider.Settings import android.widget.Toast import androidx.fragment.app.Fragment import im.vector.riotredesign.R +import im.vector.riotredesign.features.notifications.supportNotificationChannels import im.vector.riotredesign.features.settings.VectorLocale import timber.log.Timber import java.util.* @@ -124,10 +125,6 @@ fun startNotificationSettingsIntent(fragment: Fragment, requestCode: Int) { fragment.startActivityForResult(intent, requestCode) } -// TODO This comes from NotificationUtils -fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - - /** * Shows notification system settings for the given channel id. */ @@ -184,3 +181,8 @@ fun startImportTextFromFileIntent(fragment: Fragment, requestCode: Int) { fun Context.toast(resId: Int) { Toast.makeText(this, resId, Toast.LENGTH_SHORT).show() } + +// Not in KTX anymore +fun Context.toast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} diff --git a/vector/src/main/java/im/vector/riotredesign/features/badge/BadgeProxy.kt b/vector/src/main/java/im/vector/riotredesign/features/badge/BadgeProxy.kt new file mode 100644 index 0000000000..dd529ff5be --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/badge/BadgeProxy.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.features.badge + +import android.content.Context +import android.os.Build +import im.vector.matrix.android.api.session.Session +import me.leolin.shortcutbadger.ShortcutBadger +import timber.log.Timber + +/** + * Manage application badge (displayed in the launcher) + */ +object BadgeProxy { + + /** + * Badge is now managed by notification channel, so no need to use compatibility library in recent versions + * + * @return true if library ShortcutBadger can be used + */ + private fun useShortcutBadger() = Build.VERSION.SDK_INT < Build.VERSION_CODES.O + + /** + * Update the application badge value. + * + * @param context the context + * @param badgeValue the new badge value + */ + fun updateBadgeCount(context: Context, badgeValue: Int) { + if (!useShortcutBadger()) { + return + } + + try { + ShortcutBadger.setBadge(context, badgeValue) + } catch (e: Exception) { + Timber.e(e, "## updateBadgeCount(): Exception Msg=" + e.message) + } + + } + + /** + * Refresh the badge count for specific configurations.

+ * The refresh is only effective if the device is: + * * offline * does not support FCM + * * FCM registration failed + *

Notifications rooms are parsed to track the notification count value. + * + * @param aSession session value + * @param aContext App context + */ + fun specificUpdateBadgeUnreadCount(aSession: Session?, aContext: Context?) { + if (!useShortcutBadger()) { + return + } + + /* TODO + val dataHandler: MXDataHandler + + // sanity check + if (null == aContext || null == aSession) { + Timber.w("## specificUpdateBadgeUnreadCount(): invalid input null values") + } else { + dataHandler = aSession.dataHandler + + if (dataHandler == null) { + Timber.w("## specificUpdateBadgeUnreadCount(): invalid DataHandler instance") + } else { + if (aSession.isAlive) { + var isRefreshRequired: Boolean + val pushManager = Matrix.getInstance(aContext)!!.pushManager + + // update the badge count if the device is offline, FCM is not supported or FCM registration failed + isRefreshRequired = !Matrix.getInstance(aContext)!!.isConnected + isRefreshRequired = isRefreshRequired or (null != pushManager && (!pushManager.useFcm() || !pushManager.hasRegistrationToken())) + + if (isRefreshRequired) { + updateBadgeCount(aContext, dataHandler) + } + } + } + } + */ + } + + /** + * Update the badge count value according to the rooms content. + * + * @param aContext App context + * @param aDataHandler data handler instance + */ + private fun updateBadgeCount(aSession: Session?, aContext: Context?) { + if (!useShortcutBadger()) { + return + } + + /* TODO + //sanity check + if (null == aContext || null == aDataHandler) { + Timber.w("## updateBadgeCount(): invalid input null values") + } else if (null == aDataHandler.store) { + Timber.w("## updateBadgeCount(): invalid store instance") + } else { + val roomCompleteList = ArrayList(aDataHandler.store.rooms) + var unreadRoomsCount = 0 + + for (room in roomCompleteList) { + if (room.notificationCount > 0) { + unreadRoomsCount++ + } + } + + // update the badge counter + Timber.d("## updateBadgeCount(): badge update count=$unreadRoomsCount") + updateBadgeCount(aContext, unreadRoomsCount) + } + */ + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/riotredesign/features/home/HomeActivity.kt index c44b25ca79..206a7e8bc5 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/HomeActivity.kt @@ -37,6 +37,7 @@ import im.vector.riotredesign.core.platform.ToolbarConfigurable import im.vector.riotredesign.features.home.room.detail.LoadingRoomDetailFragment import im.vector.riotredesign.features.rageshake.BugReporter import im.vector.riotredesign.features.rageshake.VectorUncaughtExceptionHandler +import im.vector.riotredesign.features.settings.VectorSettingsActivity import kotlinx.android.synthetic.main.activity_home.* import org.koin.android.ext.android.inject import org.koin.android.scope.ext.android.bindScope @@ -101,12 +102,18 @@ class HomeActivity : RiotActivity(), ToolbarConfigurable { drawerToggle.syncState() } + override fun getMenuRes() = R.menu.home + override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { android.R.id.home -> { drawerLayout.openDrawer(GravityCompat.START) return true } + R.id.sliding_menu_settings -> { + startActivity(VectorSettingsActivity.getIntent(this, "TODO")) + return true + } } return true diff --git a/vector/src/main/java/im/vector/riotredesign/features/homeserver/ServerUrlsRepository.kt b/vector/src/main/java/im/vector/riotredesign/features/homeserver/ServerUrlsRepository.kt new file mode 100644 index 0000000000..2893dded74 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/homeserver/ServerUrlsRepository.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.features.homeserver + +import android.content.Context +import android.text.TextUtils +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import im.vector.riotredesign.R + +/** + * Object to store and retrieve home and identity server urls + */ +object ServerUrlsRepository { + + // Keys used to store default servers urls from the referrer + private const val DEFAULT_REFERRER_HOME_SERVER_URL_PREF = "default_referrer_home_server_url" + private const val DEFAULT_REFERRER_IDENTITY_SERVER_URL_PREF = "default_referrer_identity_server_url" + + // Keys used to store current home server url and identity url + const val HOME_SERVER_URL_PREF = "home_server_url" + const val IDENTITY_SERVER_URL_PREF = "identity_server_url" + + /** + * Save home and identity sever urls received by the Referrer receiver + */ + fun setDefaultUrlsFromReferrer(context: Context, homeServerUrl: String, identityServerUrl: String) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit { + if (!TextUtils.isEmpty(homeServerUrl)) { + putString(DEFAULT_REFERRER_HOME_SERVER_URL_PREF, homeServerUrl) + } + + if (!TextUtils.isEmpty(identityServerUrl)) { + putString(DEFAULT_REFERRER_IDENTITY_SERVER_URL_PREF, identityServerUrl) + } + } + } + + /** + * Save home and identity sever urls entered by the user. May be custom or default value + */ + fun saveServerUrls(context: Context, homeServerUrl: String, identityServerUrl: String) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit { + putString(HOME_SERVER_URL_PREF, homeServerUrl) + putString(IDENTITY_SERVER_URL_PREF, identityServerUrl) + } + } + + /** + * Return last used home server url, or the default one from referrer or the default one from resources + */ + fun getLastHomeServerUrl(context: Context): String { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + + return prefs.getString(HOME_SERVER_URL_PREF, + prefs.getString(DEFAULT_REFERRER_HOME_SERVER_URL_PREF, + getDefaultHomeServerUrl(context))) + } + + + /** + * Return last used identity server url, or the default one from referrer or the default one from resources + */ + fun getLastIdentityServerUrl(context: Context): String { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + + return prefs.getString(IDENTITY_SERVER_URL_PREF, + prefs.getString(DEFAULT_REFERRER_IDENTITY_SERVER_URL_PREF, + getDefaultIdentityServerUrl(context))) + } + + /** + * Return true if url is the default home server url form resources + */ + fun isDefaultHomeServerUrl(context: Context, url: String) = url == getDefaultHomeServerUrl(context) + + /** + * Return true if url is the default identity server url form resources + */ + fun isDefaultIdentityServerUrl(context: Context, url: String) = url == getDefaultIdentityServerUrl(context) + + /** + * Return default home server url from resources + */ + fun getDefaultHomeServerUrl(context: Context): String = context.getString(R.string.default_hs_server_url) + + /** + * Return default identity server url from resources + */ + fun getDefaultIdentityServerUrl(context: Context): String = context.getString(R.string.default_identity_server_url) +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/IconLoader.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/IconLoader.kt new file mode 100644 index 0000000000..61da3ea399 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/IconLoader.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.features.notifications + +import android.content.Context +import android.os.Handler +import android.os.HandlerThread +import androidx.annotation.WorkerThread +import androidx.core.graphics.drawable.IconCompat +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DecodeFormat +import com.bumptech.glide.request.RequestOptions +import timber.log.Timber + +/** + * FIXME It works, but it does not refresh the notification, when it's already displayed + */ +class IconLoader(val context: Context, + val listener: IconLoaderListener) { + + /** + * Avatar Url -> Icon + */ + private val cache = HashMap() + + // URLs to load + private val toLoad = HashSet() + + // Black list of URLs (broken URL, etc.) + private val blacklist = HashSet() + + private var uiHandler = Handler() + + private val handlerThread: HandlerThread = HandlerThread("IconLoader", Thread.MIN_PRIORITY) + private var backgroundHandler: Handler + + init { + handlerThread.start() + backgroundHandler = Handler(handlerThread.looper) + } + + /** + * Get icon of a user. + * If already in cache, use it, else load it and call IconLoaderListener.onIconsLoaded() when ready + */ + fun getUserIcon(path: String?): IconCompat? { + if (path == null) { + return null + } + + synchronized(cache) { + if (cache[path] != null) { + return cache[path] + } + + // Add to the queue, if not blacklisted + if (!blacklist.contains(path)) { + if (toLoad.contains(path)) { + // Wait + } else { + toLoad.add(path) + + backgroundHandler.post { + loadUserIcon(path) + } + } + } + } + + return null + } + + @WorkerThread + private fun loadUserIcon(path: String) { + val iconCompat = path.let { + try { + Glide.with(context) + .asBitmap() + .load(path) + .apply(RequestOptions.circleCropTransform() + .format(DecodeFormat.PREFER_ARGB_8888)) + .submit() + .get() + } catch (e: Exception) { + Timber.e(e, "decodeFile failed") + null + }?.let { bitmap -> + IconCompat.createWithBitmap(bitmap) + } + } + + synchronized(cache) { + if (iconCompat == null) { + // Add to the blacklist + blacklist.add(path) + } else { + cache[path] = iconCompat + } + + toLoad.remove(path) + + if (toLoad.isEmpty()) { + uiHandler.post { + listener.onIconsLoaded() + } + } + } + } + + + interface IconLoaderListener { + fun onIconsLoaded() + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/InviteNotifiableEvent.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/InviteNotifiableEvent.kt new file mode 100644 index 0000000000..34838de2eb --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/InviteNotifiableEvent.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.notifications + +import androidx.core.app.NotificationCompat + + +data class InviteNotifiableEvent( + override var matrixID: String?, + override val eventId: String, + var roomId: String, + override var noisy: Boolean, + override val title: String, + override val description: String, + override val type: String?, + override val timestamp: Long, + override var soundName: String?, + override var isPushGatewayEvent: Boolean = false) : NotifiableEvent { + + override var hasBeenDisplayed: Boolean = false + override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableEvent.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableEvent.kt new file mode 100644 index 0000000000..5e2cb667ec --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableEvent.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.notifications + +import java.io.Serializable + +interface NotifiableEvent : Serializable { + var matrixID: String? + val eventId: String + var noisy: Boolean + val title: String + val description: String? + val type: String? + val timestamp: Long + //NotificationCompat.VISIBILITY_PUBLIC , VISIBILITY_PRIVATE , VISIBILITY_SECRET + var lockScreenVisibility: Int + // Compat: Only for android <7, for newer version the sound is defined in the channel + var soundName: String? + var hasBeenDisplayed: Boolean + //Used to know if event should be replaced with the one coming from eventstream + var isPushGatewayEvent: Boolean +} + diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableEventResolver.kt new file mode 100644 index 0000000000..83860d232d --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableEventResolver.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.notifications + +import android.content.Context +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.riotredesign.core.preference.BingRule + +// TODO Remove +class RoomState { + +} + + +/** + * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event. + * It is used as a bridge between the Event Thread and the NotificationDrawerManager. + * The NotifiableEventResolver is the only aware of session/store, the NotificationDrawerManager has no knowledge of that, + * this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk. + */ +class NotifiableEventResolver(val context: Context) { + + //private val eventDisplay = RiotEventDisplay(context) + + fun resolveEvent(event: Event, roomState: RoomState?, bingRule: BingRule?, session: Session): NotifiableEvent? { + // TODO + return null + /* + val store = session.dataHandler.store + if (store == null) { + Log.e("## NotifiableEventResolver, unable to get store") + //TODO notify somehow that something did fail? + return null + } + + when (event.type) { + EventType.MESSAGE -> { + return resolveMessageEvent(event, bingRule, session, store) + } + EventType.ENCRYPTED -> { + val messageEvent = resolveMessageEvent(event, bingRule, session, store) + messageEvent?.lockScreenVisibility = NotificationCompat.VISIBILITY_PRIVATE + return messageEvent + } + EventType.STATE_ROOM_MEMBER -> { + return resolveStateRoomEvent(event, bingRule, session, store) + } + else -> { + + //If the event can be displayed, display it as is + eventDisplay.getTextualDisplay(event, roomState)?.toString()?.let { body -> + return SimpleNotifiableEvent( + session.myUserId, + eventId = event.eventId, + noisy = bingRule?.notificationSound != null, + timestamp = event.originServerTs, + description = body, + soundName = bingRule?.notificationSound, + title = context.getString(R.string.notification_unknown_new_event), + type = event.type) + } + + //Unsupported event + Timber.w("NotifiableEventResolver Received an unsupported event matching a bing rule") + return null + } + } + */ + } + + /* + private fun resolveMessageEvent(event: Event, bingRule: BingRule?, session: MXSession, store: IMXStore): NotifiableEvent? { + //If we are here, that means that the event should be notified to the user, we check now how it should be presented (sound) + val soundName = bingRule?.notificationSound + val noisy = bingRule?.notificationSound != null + + //The event only contains an eventId, and roomId (type is m.room.*) , we need to get the displayable content (names, avatar, text, etc...) + val room = store.getRoom(event.roomId /*roomID cannot be null (see Matrix SDK code)*/) + + if (room == null) { + Timber.e("## Unable to resolve room for eventId [${event.eventId}] and roomID [${event.roomId}]") + // Ok room is not known in store, but we can still display something + val body = eventDisplay.getTextualDisplay(event, null)?.toString() + ?: context.getString(R.string.notification_unknown_new_event) + val roomName = context.getString(R.string.notification_unknown_room_name) + val senderDisplayName = event.sender ?: "" + + val notifiableEvent = NotifiableMessageEvent( + eventId = event.eventId, + timestamp = event.originServerTs, + noisy = noisy, + senderName = senderDisplayName, + senderId = event.sender, + body = body, + roomId = event.roomId, + roomName = roomName) + + notifiableEvent.matrixID = session.myUserId + notifiableEvent.soundName = soundName + + return notifiableEvent + } else { + + val body = eventDisplay.getTextualDisplay(event, room.state)?.toString() + ?: context.getString(R.string.notification_unknown_new_event) + val roomName = room.getRoomDisplayName(context) + val senderDisplayName = room.state.getMemberName(event.sender) ?: event.sender ?: "" + + val notifiableEvent = NotifiableMessageEvent( + eventId = event.eventId, + timestamp = event.originServerTs, + noisy = noisy, + senderName = senderDisplayName, + senderId = event.sender, + body = body, + roomId = event.roomId, + roomName = roomName, + roomIsDirect = room.isDirect) + + notifiableEvent.matrixID = session.myUserId + notifiableEvent.soundName = soundName + + + val roomAvatarPath = session.mediaCache?.thumbnailCacheFile(room.avatarUrl, 50) + if (roomAvatarPath != null) { + notifiableEvent.roomAvatarPath = roomAvatarPath.path + } else { + // prepare for the next time + session.mediaCache?.loadAvatarThumbnail(session.homeServerConfig, ImageView(context), room.avatarUrl, 50) + } + + room.state.getMember(event.sender)?.avatarUrl?.let { + val size = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size) + val userAvatarUrlPath = session.mediaCache?.thumbnailCacheFile(it, size) + if (userAvatarUrlPath != null) { + notifiableEvent.senderAvatarPath = userAvatarUrlPath.path + } else { + // prepare for the next time + session.mediaCache?.loadAvatarThumbnail(session.homeServerConfig, ImageView(context), it, size) + } + } + + return notifiableEvent + } + } + + private fun resolveStateRoomEvent(event: Event, bingRule: BingRule?, session: MXSession, store: IMXStore): NotifiableEvent? { + if (RoomMember.MEMBERSHIP_INVITE == event.contentAsJsonObject?.getAsJsonPrimitive("membership")?.asString) { + val room = store.getRoom(event.roomId /*roomID cannot be null (see Matrix SDK code)*/) + val body = eventDisplay.getTextualDisplay(event, room.state)?.toString() + ?: context.getString(R.string.notification_new_invitation) + return InviteNotifiableEvent( + session.myUserId, + eventId = event.eventId, + roomId = event.roomId, + timestamp = event.originServerTs, + noisy = bingRule?.notificationSound != null, + title = context.getString(R.string.notification_new_invitation), + description = body, + soundName = bingRule?.notificationSound, + type = event.type, + isPushGatewayEvent = false) + } else { + Timber.e("## unsupported notifiable event for eventId [${event.eventId}]") + if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { + Timber.e("## unsupported notifiable event for event [${event}]") + } + //TODO generic handling? + } + return null + } + */ +} + diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableMessageEvent.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableMessageEvent.kt new file mode 100644 index 0000000000..784aac2bd1 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotifiableMessageEvent.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.notifications + +import androidx.core.app.NotificationCompat +import im.vector.matrix.android.api.session.events.model.EventType + +data class NotifiableMessageEvent( + override val eventId: String, + override var noisy: Boolean, + override val timestamp: Long, + var senderName: String?, + var senderId: String?, + var body: String?, + var roomId: String, + var roomName: String?, + var roomIsDirect: Boolean = false +) : NotifiableEvent { + + + override var matrixID: String? = null + override var soundName: String? = null + override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + override var hasBeenDisplayed: Boolean = false + + var roomAvatarPath: String? = null + var senderAvatarPath: String? = null + + override var isPushGatewayEvent: Boolean = false + + override val type: String + get() = EventType.MESSAGE + + override val description: String? + get() = body ?: "" + + override val title: String + get() = senderName ?: "" + + //This is used for >N notification, as the result of a smart reply + var outGoingMessage = false + var outGoingMessageFailed = false + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationBroadcastReceiver.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationBroadcastReceiver.kt new file mode 100644 index 0000000000..d7f7aaddbc --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationBroadcastReceiver.kt @@ -0,0 +1,182 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.features.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.RemoteInput +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.room.Room +import org.koin.standalone.KoinComponent +import org.koin.standalone.inject +import timber.log.Timber + +/** + * Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.) + */ +class NotificationBroadcastReceiver : BroadcastReceiver(), KoinComponent { + + private val notificationDrawerManager by inject() + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null || context == null) return + + Timber.d("ReplyNotificationBroadcastReceiver received : $intent") + + when (intent.action) { + NotificationUtils.SMART_REPLY_ACTION -> + handleSmartReply(intent, context) + NotificationUtils.DISMISS_ROOM_NOTIF_ACTION -> + intent.getStringExtra(KEY_ROOM_ID)?.let { + notificationDrawerManager.clearMessageEventOfRoom(it) + } + NotificationUtils.DISMISS_SUMMARY_ACTION -> + notificationDrawerManager.clearAllEvents() + NotificationUtils.MARK_ROOM_READ_ACTION -> + intent.getStringExtra(KEY_ROOM_ID)?.let { + notificationDrawerManager.clearMessageEventOfRoom(it) + handleMarkAsRead(context, it) + } + } + } + + private fun handleMarkAsRead(context: Context, roomId: String) { + /* + TODO + Matrix.getInstance(context)?.defaultSession?.let { session -> + session.dataHandler + ?.getRoom(roomId) + ?.markAllAsRead(object : SimpleApiCallback() { + override fun onSuccess(void: Void?) { + // Ignore + } + }) + } + */ + } + + private fun handleSmartReply(intent: Intent, context: Context) { + /* + TODO + val message = getReplyMessage(intent) + val roomId = intent.getStringExtra(KEY_ROOM_ID) + + if (TextUtils.isEmpty(message) || TextUtils.isEmpty(roomId)) { + //ignore this event + //Can this happen? should we update notification? + return + } + val matrixId = intent.getStringExtra(EXTRA_MATRIX_ID) + Matrix.getInstance(context)?.getSession(matrixId)?.let { session -> + session.dataHandler?.getRoom(roomId)?.let { room -> + sendMatrixEvent(message!!, session, roomId!!, room, context) + } + } + */ + } + + private fun sendMatrixEvent(message: String, session: Session, roomId: String, room: Room, context: Context?) { + /* + TODO + + val mxMessage = Message() + mxMessage.msgtype = Message.MSGTYPE_TEXT + mxMessage.body = message + + val event = Event(mxMessage, session.credentials.userId, roomId) + room.storeOutgoingEvent(event) + room.sendEvent(event, object : ApiCallback { + override fun onSuccess(info: Void?) { + Timber.d("Send message : onSuccess ") + val notifiableMessageEvent = NotifiableMessageEvent( + event.eventId, + false, + System.currentTimeMillis(), + session.myUser?.displayname + ?: context?.getString(R.string.notification_sender_me), + session.myUserId, + message, + roomId, + room.getRoomDisplayName(context), + room.isDirect) + notifiableMessageEvent.outGoingMessage = true + VectorApp.getInstance().notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent) + VectorApp.getInstance().notificationDrawerManager.refreshNotificationDrawer(null) + } + + override fun onNetworkError(e: Exception) { + Timber.d("Send message : onNetworkError " + e.message, e) + onSmartReplyFailed(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + Timber.d("Send message : onMatrixError " + e.message) + if (e is MXCryptoError) { + Toast.makeText(context, e.detailedErrorDescription, Toast.LENGTH_SHORT).show() + onSmartReplyFailed(e.detailedErrorDescription) + } else { + Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show() + onSmartReplyFailed(e.localizedMessage) + } + } + + override fun onUnexpectedError(e: Exception) { + Timber.e(e, "Send message : onUnexpectedError " + e.message) + onSmartReplyFailed(e.message) + } + + + fun onSmartReplyFailed(reason: String?) { + val notifiableMessageEvent = NotifiableMessageEvent( + event.eventId, + false, + System.currentTimeMillis(), + session.myUser?.displayname + ?: context?.getString(R.string.notification_sender_me), + session.myUserId, + message, + roomId, + room.getRoomDisplayName(context), + room.isDirect) + notifiableMessageEvent.outGoingMessage = true + notifiableMessageEvent.outGoingMessageFailed = true + + VectorApp.getInstance().notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent) + VectorApp.getInstance().notificationDrawerManager.refreshNotificationDrawer(null) + } + }) + */ + } + + + private fun getReplyMessage(intent: Intent?): String? { + if (intent != null) { + val remoteInput = RemoteInput.getResultsFromIntent(intent); + if (remoteInput != null) { + return remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() + } + } + return null + } + + companion object { + const val KEY_ROOM_ID = "roomID" + const val KEY_TEXT_REPLY = "key_text_reply" + const val EXTRA_MATRIX_ID = "EXTRA_MATRIX_ID" + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationDrawerManager.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationDrawerManager.kt new file mode 100644 index 0000000000..1e0e3c44f2 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationDrawerManager.kt @@ -0,0 +1,463 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.notifications + +import android.app.Notification +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.text.TextUtils +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import im.vector.matrix.android.api.session.Session +import im.vector.riotredesign.BuildConfig +import im.vector.riotredesign.R +import im.vector.riotredesign.core.utils.SecretStoringUtils +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream + +/** + * The NotificationDrawerManager receives notification events as they arrived (from event stream or fcm) and + * organise them in order to display them in the notification drawer. + * Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning. + */ +class NotificationDrawerManager(val context: Context) { + + //The first time the notification drawer is refreshed, we force re-render of all notifications + private var firstTime = true + + private var eventList = loadEventInfo() + private var myUserDisplayName: String = "" + private var myUserAvatarUrl: String = "" + + private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size) + + private var currentRoomId: String? = null + + private var iconLoader = IconLoader(context, + object : IconLoader.IconLoaderListener { + override fun onIconsLoaded() { + // Force refresh + refreshNotificationDrawer(null) + } + }) + + /** + * No multi session support for now + */ + private fun initWithSession(session: Session?) { + session?.let { + /* + myUserDisplayName = it.myUser?.displayname ?: it.myUserId + + // User Avatar + it.myUser?.avatarUrl?.let { avatarUrl -> + val userAvatarUrlPath = it.mediaCache?.thumbnailCacheFile(avatarUrl, avatarSize) + if (userAvatarUrlPath != null) { + myUserAvatarUrl = userAvatarUrlPath.path + } else { + // prepare for the next time + session.mediaCache?.loadAvatarThumbnail(session.homeServerConfig, ImageView(context), avatarUrl, avatarSize) + } + } + */ + } + } + + /** + Should be called as soon as a new event is ready to be displayed. + The notification corresponding to this event will not be displayed until + #refreshNotificationDrawer() is called. + Events might be grouped and there might not be one notification per event! + */ + fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + //If we support multi session, event list should be per userId + //Currently only manage single session + if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { + Timber.d("%%%%%%%% onNotifiableEventReceived $notifiableEvent") + } + synchronized(eventList) { + val existing = eventList.firstOrNull { it.eventId == notifiableEvent.eventId } + if (existing != null) { + if (existing.isPushGatewayEvent) { + //Use the event coming from the event stream as it may contains more info than + //the fcm one (like type/content/clear text) + // In this case the message has already been notified, and might have done some noise + // So we want the notification to be updated even if it has already been displayed + // But it should make no noise (e.g when an encrypted message from FCM should be + // update with clear text after a sync) + notifiableEvent.hasBeenDisplayed = false + notifiableEvent.noisy = false + eventList.remove(existing) + eventList.add(notifiableEvent) + + } else { + //keep the existing one, do not replace + } + } else { + eventList.add(notifiableEvent) + } + + } + } + + /** + Clear all known events and refresh the notification drawer + */ + fun clearAllEvents() { + synchronized(eventList) { + eventList.clear() + } + refreshNotificationDrawer(null) + } + + /** Clear all known message events for this room and refresh the notification drawer */ + fun clearMessageEventOfRoom(roomId: String?) { + Timber.d("clearMessageEventOfRoom $roomId") + + if (roomId != null) { + eventList.removeAll { e -> + if (e is NotifiableMessageEvent) { + return@removeAll e.roomId == roomId + } + return@removeAll false + } + NotificationUtils.cancelNotificationMessage(context, roomId, ROOM_MESSAGES_NOTIFICATION_ID) + } + refreshNotificationDrawer(null) + } + + /** + Should be called when the application is currently opened and showing timeline for the given roomId. + Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. + */ + fun setCurrentRoom(roomId: String?) { + var hasChanged: Boolean + synchronized(eventList) { + hasChanged = roomId != currentRoomId + currentRoomId = roomId + } + if (hasChanged) { + clearMessageEventOfRoom(roomId) + } + } + + fun homeActivityDidResume(matrixID: String?) { + synchronized(eventList) { + eventList.removeAll { e -> + return@removeAll e !is NotifiableMessageEvent //messages are cleared when entering room + } + } + } + + fun clearMemberShipNotificationForRoom(roomId: String) { + synchronized(eventList) { + eventList.removeAll { e -> + if (e is InviteNotifiableEvent) { + return@removeAll e.roomId == roomId + } + return@removeAll false + } + } + } + + + fun refreshNotificationDrawer(outdatedDetector: OutdatedEventDetector?) { + if (myUserDisplayName.isBlank()) { + // TODO + // initWithSession(Matrix.getInstance(context).defaultSession) + } + + if (myUserDisplayName.isBlank()) { + // Should not happen, but myUserDisplayName cannot be blank if used to create a Person + return + } + + synchronized(eventList) { + + Timber.d("%%%%%%%% REFRESH NOTIFICATION DRAWER ") + //TMP code + var hasNewEvent = false + var summaryIsNoisy = false + val summaryInboxStyle = NotificationCompat.InboxStyle() + + //group events by room to create a single MessagingStyle notif + val roomIdToEventMap: MutableMap> = HashMap() + val simpleEvents: ArrayList = ArrayList() + val notifications: ArrayList = ArrayList() + + val eventIterator = eventList.listIterator() + while (eventIterator.hasNext()) { + val event = eventIterator.next() + if (event is NotifiableMessageEvent) { + val roomId = event.roomId + var roomEvents = roomIdToEventMap[roomId] + if (roomEvents == null) { + roomEvents = ArrayList() + roomIdToEventMap[roomId] = roomEvents + } + + if (shouldIgnoreMessageEventInRoom(roomId) || outdatedDetector?.isMessageOutdated(event) == true) { + //forget this event + eventIterator.remove() + } else { + roomEvents.add(event) + } + } else { + simpleEvents.add(event) + } + } + + + Timber.d("%%%%%%%% REFRESH NOTIFICATION DRAWER ${roomIdToEventMap.size} room groups") + + var globalLastMessageTimestamp = 0L + + //events have been grouped + for ((roomId, events) in roomIdToEventMap) { + + if (events.isEmpty()) { + //Just clear this notification + Timber.d("%%%%%%%% REFRESH NOTIFICATION DRAWER $roomId has no more events") + NotificationUtils.cancelNotificationMessage(context, roomId, ROOM_MESSAGES_NOTIFICATION_ID) + continue + } + + val roomGroup = RoomEventGroupInfo(roomId) + roomGroup.hasNewEvent = false + roomGroup.shouldBing = false + roomGroup.isDirect = events[0].roomIsDirect + val roomName = events[0].roomName ?: events[0].senderName ?: "" + val style = NotificationCompat.MessagingStyle(Person.Builder() + .setName(myUserDisplayName) + .setIcon(iconLoader.getUserIcon(myUserAvatarUrl)) + .setKey(events[0].matrixID) + .build()) + roomGroup.roomDisplayName = roomName + + style.isGroupConversation = !roomGroup.isDirect + + if (!roomGroup.isDirect) { + style.conversationTitle = roomName + } + + val largeBitmap = getRoomBitmap(events) + + + for (event in events) { + //if all events in this room have already been displayed there is no need to update it + if (!event.hasBeenDisplayed) { + roomGroup.shouldBing = roomGroup.shouldBing || event.noisy + roomGroup.customSound = event.soundName + } + roomGroup.hasNewEvent = roomGroup.hasNewEvent || !event.hasBeenDisplayed + + val senderPerson = Person.Builder() + .setName(event.senderName) + .setIcon(iconLoader.getUserIcon(event.senderAvatarPath)) + .setKey(event.senderId) + .build() + + if (event.outGoingMessage && event.outGoingMessageFailed) { + style.addMessage(context.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson) + roomGroup.hasSmartReplyError = true + } else { + style.addMessage(event.body, event.timestamp, senderPerson) + } + event.hasBeenDisplayed = true //we can consider it as displayed + + //It is possible that this event was previously shown as an 'anonymous' simple notif. + //And now it will be merged in a single MessageStyle notif, so we can clean to be sure + NotificationUtils.cancelNotificationMessage(context, event.eventId, ROOM_EVENT_NOTIFICATION_ID) + } + + try { + val summaryLine = context.resources.getQuantityString( + R.plurals.notification_compat_summary_line_for_room, events.size, roomName, events.size) + summaryInboxStyle.addLine(summaryLine) + } catch (e: Throwable) { + //String not found or bad format + Timber.d("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string") + summaryInboxStyle.addLine(roomName) + } + + if (firstTime || roomGroup.hasNewEvent) { + //Should update displayed notification + Timber.d("%%%%%%%% REFRESH NOTIFICATION DRAWER $roomId need refresh") + val lastMessageTimestamp = events.last().timestamp + + if (globalLastMessageTimestamp < lastMessageTimestamp) { + globalLastMessageTimestamp = lastMessageTimestamp + } + + NotificationUtils.buildMessagesListNotification(context, style, roomGroup, largeBitmap, lastMessageTimestamp, myUserDisplayName) + ?.let { + //is there an id for this room? + notifications.add(it) + NotificationUtils.showNotificationMessage(context, roomId, ROOM_MESSAGES_NOTIFICATION_ID, it) + } + hasNewEvent = true + summaryIsNoisy = summaryIsNoisy || roomGroup.shouldBing + } else { + Timber.d("%%%%%%%% REFRESH NOTIFICATION DRAWER $roomId is up to date") + } + } + + + //Handle simple events + for (event in simpleEvents) { + //We build a simple event + if (firstTime || !event.hasBeenDisplayed) { + NotificationUtils.buildSimpleEventNotification(context, event, null, myUserDisplayName)?.let { + notifications.add(it) + NotificationUtils.showNotificationMessage(context, event.eventId, ROOM_EVENT_NOTIFICATION_ID, it) + event.hasBeenDisplayed = true //we can consider it as displayed + hasNewEvent = true + summaryIsNoisy = summaryIsNoisy || event.noisy + summaryInboxStyle.addLine(event.description) + } + } + } + + + //======== Build summary notification ========= + //On Android 7.0 (API level 24) and higher, the system automatically builds a summary for + // your group using snippets of text from each notification. The user can expand this + // notification to see each separate notification. + // To support older versions, which cannot show a nested group of notifications, + // you must create an extra notification that acts as the summary. + // This appears as the only notification and the system hides all the others. + // So this summary should include a snippet from all the other notifications, + // which the user can tap to open your app. + // The behavior of the group summary may vary on some device types such as wearables. + // To ensure the best experience on all devices and versions, always include a group summary when you create a group + // https://developer.android.com/training/notify-user/group + + if (eventList.isEmpty()) { + NotificationUtils.cancelNotificationMessage(context, null, SUMMARY_NOTIFICATION_ID) + } else { + val nbEvents = roomIdToEventMap.size + simpleEvents.size + val sumTitle = context.resources.getQuantityString( + R.plurals.notification_compat_summary_title, nbEvents, nbEvents) + summaryInboxStyle.setBigContentTitle(sumTitle) + NotificationUtils.buildSummaryListNotification( + context, + summaryInboxStyle, + sumTitle, + noisy = hasNewEvent && summaryIsNoisy, + lastMessageTimestamp = globalLastMessageTimestamp + )?.let { + NotificationUtils.showNotificationMessage(context, null, SUMMARY_NOTIFICATION_ID, it) + } + + if (hasNewEvent && summaryIsNoisy) { + try { + // turn the screen on for 3 seconds + /* + TODO + if (Matrix.getInstance(VectorApp.getInstance())!!.pushManager.isScreenTurnedOn) { + val pm = VectorApp.getInstance().getSystemService(Context.POWER_SERVICE) as PowerManager + val wl = pm.newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or PowerManager.ACQUIRE_CAUSES_WAKEUP, + NotificationDrawerManager::class.java.name) + wl.acquire(3000) + wl.release() + } + */ + } catch (e: Throwable) { + Timber.e(e, "## Failed to turn screen on") + } + + } + } + //notice that we can get bit out of sync with actual display but not a big issue + firstTime = false + } + } + + private fun getRoomBitmap(events: ArrayList): Bitmap? { + if (events.isEmpty()) return null + + //Use the last event (most recent?) + val roomAvatarPath = events[events.size - 1].roomAvatarPath + ?: events[events.size - 1].senderAvatarPath + if (!TextUtils.isEmpty(roomAvatarPath)) { + val options = BitmapFactory.Options() + options.inPreferredConfig = Bitmap.Config.ARGB_8888 + try { + return BitmapFactory.decodeFile(roomAvatarPath, options) + } catch (oom: OutOfMemoryError) { + Timber.e(oom, "decodeFile failed with an oom") + } + + } + return null + } + + private fun shouldIgnoreMessageEventInRoom(roomId: String?): Boolean { + return currentRoomId != null && roomId == currentRoomId + } + + + fun persistInfo() { + if (eventList.isEmpty()) { + deleteCachedRoomNotifications(context) + return + } + try { + val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) + if (!file.exists()) file.createNewFile() + FileOutputStream(file).use { + SecretStoringUtils.securelyStoreObject(eventList, "notificationMgr", it, this.context) + } + } catch (e: Throwable) { + Timber.e(e, "## Failed to save cached notification info") + } + } + + private fun loadEventInfo(): ArrayList { + try { + val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) + if (file.exists()) { + FileInputStream(file).use { + val events: ArrayList? = SecretStoringUtils.loadSecureSecret(it, "notificationMgr", this.context) + if (events != null) { + return ArrayList(events.mapNotNull { it as? NotifiableEvent }) + } + } + } + } catch (e: Throwable) { + Timber.e(e, "## Failed to load cached notification info") + } + return ArrayList() + } + + private fun deleteCachedRoomNotifications(context: Context) { + val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) + if (file.exists()) { + file.delete() + } + } + + companion object { + private const val SUMMARY_NOTIFICATION_ID = 0 + private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 + private const val ROOM_EVENT_NOTIFICATION_ID = 2 + + private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache" + } +} diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationUtils.kt new file mode 100755 index 0000000000..9596fdad42 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/NotificationUtils.kt @@ -0,0 +1,721 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.features.notifications + +import android.annotation.SuppressLint +import android.annotation.TargetApi +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.text.TextUtils +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import androidx.core.app.TaskStackBuilder +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import im.vector.riotredesign.BuildConfig +import im.vector.riotredesign.R +import im.vector.riotredesign.core.utils.startNotificationChannelSettingsIntent +import im.vector.riotredesign.features.home.HomeActivity +import im.vector.riotredesign.features.settings.PreferencesManager +import timber.log.Timber +import java.util.* + + +fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + +/** + * Util class for creating notifications. + */ +object NotificationUtils { + + /* ========================================================================================== + * IDs for notifications + * ========================================================================================== */ + + /** + * Identifier of the foreground notification used to keep the application alive + * when it runs in background. + * This notification, which is not removable by the end user, displays what + * the application is doing while in background. + */ + const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61 + + /* ========================================================================================== + * IDs for actions + * ========================================================================================== */ + + private const val JOIN_ACTION = "${BuildConfig.APPLICATION_ID}.NotificationActions.JOIN_ACTION" + private const val REJECT_ACTION = "${BuildConfig.APPLICATION_ID}.NotificationActions.REJECT_ACTION" + private const val QUICK_LAUNCH_ACTION = "${BuildConfig.APPLICATION_ID}.NotificationActions.QUICK_LAUNCH_ACTION" + const val MARK_ROOM_READ_ACTION = "${BuildConfig.APPLICATION_ID}.NotificationActions.MARK_ROOM_READ_ACTION" + const val SMART_REPLY_ACTION = "${BuildConfig.APPLICATION_ID}.NotificationActions.SMART_REPLY_ACTION" + const val DISMISS_SUMMARY_ACTION = "${BuildConfig.APPLICATION_ID}.NotificationActions.DISMISS_SUMMARY_ACTION" + const val DISMISS_ROOM_NOTIF_ACTION = "${BuildConfig.APPLICATION_ID}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION" + private const val TAP_TO_VIEW_ACTION = "${BuildConfig.APPLICATION_ID}.NotificationActions.TAP_TO_VIEW_ACTION" + + /* ========================================================================================== + * IDs for channels + * ========================================================================================== */ + + // on devices >= android O, we need to define a channel for each notifications + private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID" + + private const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID" + + private const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2" + private const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2" + + /* ========================================================================================== + * Channel names + * ========================================================================================== */ + + /** + * Create notification channels. + * + * @param context the context + */ + @TargetApi(Build.VERSION_CODES.O) + fun createNotificationChannels(context: Context) { + if (!supportNotificationChannels()) { + return + } + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + + //Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE + // + currentTimeMillis). + //Now the sound can only be change directly in system settings, so for app upgrading we are deleting this former channel + //Starting from this version the channel will not be dynamic + for (channel in notificationManager.notificationChannels) { + val channelId = channel.id + val legacyBaseName = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE" + if (channelId.startsWith(legacyBaseName)) { + notificationManager.deleteNotificationChannel(channelId) + } + } + //Migration - Remove deprecated channels + for (channelId in listOf("DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID", "CALL_NOTIFICATION_CHANNEL_ID")) { + notificationManager.getNotificationChannel(channelId)?.let { + notificationManager.deleteNotificationChannel(channelId) + } + } + + /** + * Default notification importance: shows everywhere, makes noise, but does not visually + * intrude. + */ + notificationManager.createNotificationChannel(NotificationChannel(NOISY_NOTIFICATION_CHANNEL_ID, + context.getString(R.string.notification_noisy_notifications), + NotificationManager.IMPORTANCE_DEFAULT) + .apply { + description = context.getString(R.string.notification_noisy_notifications) + enableVibration(true) + enableLights(true) + lightColor = accentColor + }) + + /** + * Low notification importance: shows everywhere, but is not intrusive. + */ + notificationManager.createNotificationChannel(NotificationChannel(SILENT_NOTIFICATION_CHANNEL_ID, + context.getString(R.string.notification_silent_notifications), + NotificationManager.IMPORTANCE_LOW) + .apply { + description = context.getString(R.string.notification_silent_notifications) + setSound(null, null) + enableLights(true) + lightColor = accentColor + }) + + notificationManager.createNotificationChannel(NotificationChannel(LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID, + context.getString(R.string.notification_listening_for_events), + NotificationManager.IMPORTANCE_MIN) + .apply { + description = context.getString(R.string.notification_listening_for_events) + setSound(null, null) + setShowBadge(false) + }) + + notificationManager.createNotificationChannel(NotificationChannel(CALL_NOTIFICATION_CHANNEL_ID, + context.getString(R.string.call), + NotificationManager.IMPORTANCE_HIGH) + .apply { + description = context.getString(R.string.call) + setSound(null, null) + enableLights(true) + lightColor = accentColor + }) + } + + /** + * Build a polling thread listener notification + * + * @param context Android context + * @param subTitleResId subtitle string resource Id of the notification + * @return the polling thread listener notification + */ + @SuppressLint("NewApi") + fun buildForegroundServiceNotification(context: Context, @StringRes subTitleResId: Int): Notification { + // build the pending intent go to the home screen if this is clicked. + val i = Intent(context, HomeActivity::class.java) + i.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + val pi = PendingIntent.getActivity(context, 0, i, 0) + + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + + val builder = NotificationCompat.Builder(context, LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID) + .setContentTitle(context.getString(subTitleResId)) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setSmallIcon(R.drawable.logo_transparent) + .setProgress(0, 0, true) + .setColor(accentColor) + .setContentIntent(pi) + + // hide the notification from the status bar + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + builder.priority = NotificationCompat.PRIORITY_MIN + } + + val notification = builder.build() + + notification.flags = notification.flags or Notification.FLAG_NO_CLEAR + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + // some devices crash if this field is not set + // even if it is deprecated + + // setLatestEventInfo() is deprecated on Android M, so we try to use + // reflection at runtime, to avoid compiler error: "Cannot resolve method.." + try { + val deprecatedMethod = notification.javaClass + .getMethod("setLatestEventInfo", + Context::class.java, + CharSequence::class.java, + CharSequence::class.java, + PendingIntent::class.java) + deprecatedMethod.invoke(notification, context, context.getString(R.string.app_name), context.getString(subTitleResId), pi) + } catch (ex: Exception) { + Timber.e(ex, "## buildNotification(): Exception - setLatestEventInfo() Msg=" + ex.message) + } + + } + return notification + } + + /** + * Build an incoming call notification. + * This notification starts the VectorHomeActivity which is in charge of centralizing the incoming call flow. + * + * @param context the context. + * @param isVideo true if this is a video call, false for voice call + * @param roomName the room name in which the call is pending. + * @param matrixId the matrix id + * @param callId the call id. + * @return the call notification. + */ + @SuppressLint("NewApi") + fun buildIncomingCallNotification(context: Context, + isVideo: Boolean, + roomName: String, + matrixId: String, + callId: String): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + + val builder = NotificationCompat.Builder(context, CALL_NOTIFICATION_CHANNEL_ID) + .setContentTitle(ensureTitleNotEmpty(context, roomName)) + .apply { + if (isVideo) { + setContentText(context.getString(R.string.incoming_video_call)) + } else { + setContentText(context.getString(R.string.incoming_voice_call)) + } + } + .setSmallIcon(R.drawable.incoming_call_notification_transparent) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setLights(accentColor, 500, 500) + + //Compat: Display the incoming call notification on the lock screen + builder.priority = NotificationCompat.PRIORITY_MAX + + // clear the activity stack to home activity + val intent = Intent(context, HomeActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + // TODO .putExtra(VectorHomeActivity.EXTRA_CALL_SESSION_ID, matrixId) + // TODO .putExtra(VectorHomeActivity.EXTRA_CALL_ID, callId) + + // Recreate the back stack + val stackBuilder = TaskStackBuilder.create(context) + .addParentStack(HomeActivity::class.java) + .addNextIntent(intent) + + + // android 4.3 issue + // use a generator for the private requestCode. + // When using 0, the intent is not created/launched when the user taps on the notification. + // + val pendingIntent = stackBuilder.getPendingIntent(Random().nextInt(1000), PendingIntent.FLAG_UPDATE_CURRENT) + + builder.setContentIntent(pendingIntent) + + return builder.build() + } + + /** + * Build a pending call notification + * + * @param context the context. + * @param isVideo true if this is a video call, false for voice call + * @param roomName the room name in which the call is pending. + * @param roomId the room Id + * @param matrixId the matrix id + * @param callId the call id. + * @return the call notification. + */ + @SuppressLint("NewApi") + fun buildPendingCallNotification(context: Context, + isVideo: Boolean, + roomName: String, + roomId: String, + matrixId: String, + callId: String): Notification { + + val builder = NotificationCompat.Builder(context, CALL_NOTIFICATION_CHANNEL_ID) + .setContentTitle(ensureTitleNotEmpty(context, roomName)) + .apply { + if (isVideo) { + setContentText(context.getString(R.string.video_call_in_progress)) + } else { + setContentText(context.getString(R.string.call_in_progress)) + } + } + .setSmallIcon(R.drawable.incoming_call_notification_transparent) + .setCategory(NotificationCompat.CATEGORY_CALL) + + // Display the pending call notification on the lock screen + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + builder.priority = NotificationCompat.PRIORITY_MAX + } + + /* TODO + // Build the pending intent for when the notification is clicked + val roomIntent = Intent(context, VectorRoomActivity::class.java) + .putExtra(VectorRoomActivity.EXTRA_ROOM_ID, roomId) + .putExtra(VectorRoomActivity.EXTRA_MATRIX_ID, matrixId) + .putExtra(VectorRoomActivity.EXTRA_START_CALL_ID, callId) + + // Recreate the back stack + val stackBuilder = TaskStackBuilder.create(context) + .addParentStack(VectorRoomActivity::class.java) + .addNextIntent(roomIntent) + + // android 4.3 issue + // use a generator for the private requestCode. + // When using 0, the intent is not created/launched when the user taps on the notification. + // + val pendingIntent = stackBuilder.getPendingIntent(Random().nextInt(1000), PendingIntent.FLAG_UPDATE_CURRENT) + + builder.setContentIntent(pendingIntent) + */ + + return builder.build() + } + + /** + * Build a temporary (because service will be stopped just after) notification for the CallService, when a call is ended + */ + fun buildCallEndedNotification(context: Context): Notification { + return NotificationCompat.Builder(context, CALL_NOTIFICATION_CHANNEL_ID) + .setContentTitle(context.getString(R.string.call_ended)) + .setSmallIcon(R.drawable.ic_material_call_end_grey) + .setCategory(NotificationCompat.CATEGORY_CALL) + .build() + } + + /** + * Build a notification for a Room + */ + fun buildMessagesListNotification(context: Context, + messageStyle: NotificationCompat.MessagingStyle, + roomInfo: RoomEventGroupInfo, + largeIcon: Bitmap?, + lastMessageTimestamp: Long, + senderDisplayNameForReplyCompat: String?): Notification? { + + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + // Build the pending intent for when the notification is clicked + val openRoomIntent = buildOpenRoomIntent(context, roomInfo.roomId) + val smallIcon = if (roomInfo.shouldBing) R.drawable.icon_notif_important else R.drawable.logo_transparent + + val channelID = if (roomInfo.shouldBing) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID + return NotificationCompat.Builder(context, channelID) + .setWhen(lastMessageTimestamp) + // MESSAGING_STYLE sets title and content for API 16 and above devices. + .setStyle(messageStyle) + + // A category allows groups of notifications to be ranked and filtered – per user or system settings. + // For example, alarm notifications should display before promo notifications, or message from known contact + // that can be displayed in not disturb mode if white listed (the later will need compat28.x) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + + // Title for API < 16 devices. + .setContentTitle(roomInfo.roomDisplayName) + // Content for API < 16 devices. + .setContentText(context.getString(R.string.notification_new_messages)) + + // Number of new notifications for API <24 (M and below) devices. + .setSubText(context + .resources + .getQuantityString(R.plurals.room_new_messages_notification, messageStyle.messages.size, messageStyle.messages.size) + ) + + // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+) + // devices and all Wear devices. But we want a custom grouping, so we specify the groupID + // TODO Group should be current user display name + .setGroup(context.getString(R.string.app_name)) + + //In order to avoid notification making sound twice (due to the summary notification) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + + .setSmallIcon(smallIcon) + + // Set primary color (important for Wear 2.0 Notifications). + .setColor(accentColor) + + // Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for + // 'importance' which is set in the NotificationChannel. The integers representing + // 'priority' are different from 'importance', so make sure you don't mix them. + .apply { + priority = NotificationCompat.PRIORITY_DEFAULT + if (roomInfo.shouldBing) { + //Compat + PreferencesManager.getNotificationRingTone(context)?.let { + setSound(it) + } + setLights(accentColor, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + + //Add actions and notification intents + // Mark room as read + val markRoomReadIntent = Intent(context, NotificationBroadcastReceiver::class.java) + markRoomReadIntent.action = MARK_ROOM_READ_ACTION + markRoomReadIntent.data = Uri.parse("foobar://${roomInfo.roomId}") + markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId) + val markRoomReadPendingIntent = PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), markRoomReadIntent, + PendingIntent.FLAG_UPDATE_CURRENT) + + addAction(NotificationCompat.Action( + R.drawable.ic_material_done_all_white, + context.getString(R.string.action_mark_room_read), + markRoomReadPendingIntent)) + + // Quick reply + if (!roomInfo.hasSmartReplyError) { + buildQuickReplyIntent(context, roomInfo.roomId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent -> + val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) + .setLabel(context.getString(R.string.action_quick_reply)) + .build() + NotificationCompat.Action.Builder(R.drawable.vector_notification_quick_reply, + context.getString(R.string.action_quick_reply), replyPendingIntent) + .addRemoteInput(remoteInput) + .build()?.let { + addAction(it) + } + } + } + + if (openRoomIntent != null) { + setContentIntent(openRoomIntent) + } + + if (largeIcon != null) { + setLargeIcon(largeIcon) + } + + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId) + intent.action = DISMISS_ROOM_NOTIF_ACTION + val pendingIntent = PendingIntent.getBroadcast(context.applicationContext, + System.currentTimeMillis().toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT) + setDeleteIntent(pendingIntent) + } + .build() + } + + + fun buildSimpleEventNotification(context: Context, simpleNotifiableEvent: NotifiableEvent, largeIcon: Bitmap?, matrixId: String): Notification? { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + // Build the pending intent for when the notification is clicked + val smallIcon = if (simpleNotifiableEvent.noisy) R.drawable.icon_notif_important else R.drawable.logo_transparent + + val channelID = if (simpleNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID + + return NotificationCompat.Builder(context, channelID) + .setContentTitle(context.getString(R.string.app_name)) + .setContentText(simpleNotifiableEvent.description) + .setGroup(context.getString(R.string.app_name)) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setSmallIcon(smallIcon) + .setColor(accentColor) + .apply { + if (simpleNotifiableEvent is InviteNotifiableEvent) { + /* + TODO + val roomId = simpleNotifiableEvent.roomId + // offer to type a quick reject button + val rejectIntent = JoinRoomActivity.getRejectRoomIntent(context, roomId, matrixId) + + // the action must be unique else the parameters are ignored + rejectIntent.action = REJECT_ACTION + rejectIntent.data = Uri.parse("foobar://$roomId&$matrixId") + addAction( + R.drawable.vector_notification_reject_invitation, + context.getString(R.string.reject), + PendingIntent.getActivity(context, System.currentTimeMillis().toInt(), rejectIntent, 0)) + + // offer to type a quick accept button + val joinIntent = JoinRoomActivity.getJoinRoomIntent(context, roomId, matrixId) + + // the action must be unique else the parameters are ignored + joinIntent.action = JOIN_ACTION + joinIntent.data = Uri.parse("foobar://$roomId&$matrixId") + addAction( + R.drawable.vector_notification_accept_invitation, + context.getString(R.string.join), + PendingIntent.getActivity(context, 0, joinIntent, 0)) + */ + } else { + setAutoCancel(true) + } + + val contentIntent = Intent(context, HomeActivity::class.java) + contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + //pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that + contentIntent.data = Uri.parse("foobar://" + simpleNotifiableEvent.eventId) + setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, 0)) + + if (largeIcon != null) { + setLargeIcon(largeIcon) + } + + if (simpleNotifiableEvent.noisy) { + //Compat + priority = NotificationCompat.PRIORITY_DEFAULT + PreferencesManager.getNotificationRingTone(context)?.let { + setSound(it) + } + setLights(accentColor, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + setAutoCancel(true) + } + .build() + } + + private fun buildOpenRoomIntent(context: Context, roomId: String): PendingIntent? { + // TODO + return null + /* + val roomIntentTap = Intent(context, VectorRoomActivity::class.java) + roomIntentTap.putExtra(VectorRoomActivity.EXTRA_ROOM_ID, roomId) + roomIntentTap.action = TAP_TO_VIEW_ACTION + //pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that + roomIntentTap.data = Uri.parse("foobar://openRoom?$roomId") + + // Recreate the back stack + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(Intent(context, VectorHomeActivity::class.java)) + .addNextIntent(roomIntentTap) + .getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT) + */ + } + + private fun buildOpenHomePendingIntentForSummary(context: Context): PendingIntent { + val intent = Intent(context, HomeActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + // TODO intent.putExtra(VectorHomeActivity.EXTRA_CLEAR_EXISTING_NOTIFICATION, true) + intent.data = Uri.parse("foobar://tapSummary") + return PendingIntent.getActivity(context, Random().nextInt(1000), intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /* + Direct reply is new in Android N, and Android already handles the UI, so the right pending intent + here will ideally be a Service/IntentService (for a long running background task) or a BroadcastReceiver, + which runs on the UI thread. It also works without unlocking, making the process really fluid for the user. + However, for Android devices running Marshmallow and below (API level 23 and below), + it will be more appropriate to use an activity. Since you have to provide your own UI. + */ + private fun buildQuickReplyIntent(context: Context, roomId: String, senderName: String?): PendingIntent? { + val intent: Intent + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = SMART_REPLY_ACTION + intent.data = Uri.parse("foobar://$roomId") + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + return PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), intent, + PendingIntent.FLAG_UPDATE_CURRENT) + } else { + /* + TODO + if (!LockScreenActivity.isDisplayingALockScreenActivity()) { + // start your activity for Android M and below + val quickReplyIntent = Intent(context, LockScreenActivity::class.java) + quickReplyIntent.putExtra(LockScreenActivity.EXTRA_ROOM_ID, roomId) + quickReplyIntent.putExtra(LockScreenActivity.EXTRA_SENDER_NAME, senderName ?: "") + + // the action must be unique else the parameters are ignored + quickReplyIntent.action = QUICK_LAUNCH_ACTION + quickReplyIntent.data = Uri.parse("foobar://$roomId") + return PendingIntent.getActivity(context, 0, quickReplyIntent, 0) + } + */ + } + return null + } + + //// Number of new notifications for API <24 (M and below) devices. + /** + * Build the summary notification + */ + fun buildSummaryListNotification(context: Context, + style: NotificationCompat.Style, + compatSummary: String, + noisy: Boolean, + lastMessageTimestamp: Long): Notification? { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + val smallIcon = if (noisy) R.drawable.icon_notif_important else R.drawable.logo_transparent + + return NotificationCompat.Builder(context, if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID) + // used in compat < N, after summary is built based on child notifications + .setWhen(lastMessageTimestamp) + .setStyle(style) + .setContentTitle(context.getString(R.string.app_name)) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setSmallIcon(smallIcon) + //set content text to support devices running API level < 24 + .setContentText(compatSummary) + .setGroup(context.getString(R.string.app_name)) + //set this notification as the summary for the group + .setGroupSummary(true) + .setColor(accentColor) + .apply { + if (noisy) { + //Compat + priority = NotificationCompat.PRIORITY_DEFAULT + PreferencesManager.getNotificationRingTone(context)?.let { + setSound(it) + } + setLights(accentColor, 500, 500) + } else { + //compat + priority = NotificationCompat.PRIORITY_LOW + } + } + .setContentIntent(buildOpenHomePendingIntentForSummary(context)) + .setDeleteIntent(getDismissSummaryPendingIntent(context)) + .build() + + } + + private fun getDismissSummaryPendingIntent(context: Context): PendingIntent { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = DISMISS_SUMMARY_ACTION + intent.data = Uri.parse("foobar://deleteSummary") + return PendingIntent.getBroadcast(context.applicationContext, + 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + fun showNotificationMessage(context: Context, tag: String?, id: Int, notification: Notification) { + with(NotificationManagerCompat.from(context)) { + notify(tag, id, notification) + } + } + + fun cancelNotificationMessage(context: Context, tag: String?, id: Int) { + NotificationManagerCompat.from(context) + .cancel(tag, id) + } + + /** + * Cancel the foreground notification service + */ + fun cancelNotificationForegroundService(context: Context) { + NotificationManagerCompat.from(context) + .cancel(NOTIFICATION_ID_FOREGROUND_SERVICE) + } + + /** + * Cancel all the notification + */ + fun cancelAllNotifications(context: Context) { + // Keep this try catch (reported by GA) + try { + NotificationManagerCompat.from(context) + .cancelAll() + } catch (e: Exception) { + Timber.e(e, "## cancelAllNotifications() failed " + e.message) + } + } + + /** + * Return true it the user has enabled the do not disturb mode + */ + fun isDoNotDisturbModeOn(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return false + } + + // We cannot use NotificationManagerCompat here. + val setting = (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).currentInterruptionFilter + + return setting == NotificationManager.INTERRUPTION_FILTER_NONE + || setting == NotificationManager.INTERRUPTION_FILTER_ALARMS + } + + private fun ensureTitleNotEmpty(context: Context, title: String?): CharSequence { + if (TextUtils.isEmpty(title)) { + return context.getString(R.string.app_name) + } + + return title!! + } + + fun openSystemSettingsForSilentCategory(fragment: Fragment) { + startNotificationChannelSettingsIntent(fragment, SILENT_NOTIFICATION_CHANNEL_ID) + } + + fun openSystemSettingsForNoisyCategory(fragment: Fragment) { + startNotificationChannelSettingsIntent(fragment, NOISY_NOTIFICATION_CHANNEL_ID) + } + + fun openSystemSettingsForCallCategory(fragment: Fragment) { + startNotificationChannelSettingsIntent(fragment, CALL_NOTIFICATION_CHANNEL_ID) + } +} diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/OutdatedEventDetector.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/OutdatedEventDetector.kt new file mode 100644 index 0000000000..b1fc55c82c --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/OutdatedEventDetector.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.notifications + +import android.content.Context + +class OutdatedEventDetector(val context: Context) { + + /** + * Returns true if the given event is outdated. + * Used to clean up notifications if a displayed message has been read on an + * other device. + */ + fun isMessageOutdated(notifiableEvent: NotifiableEvent): Boolean { + if (notifiableEvent is NotifiableMessageEvent) { + val eventID = notifiableEvent.eventId + val roomID = notifiableEvent.roomId + /* + TODO + Matrix.getMXSession(context.applicationContext, notifiableEvent.matrixID)?.let { session -> + //find the room + if (session.isAlive) { + session.dataHandler.getRoom(roomID)?.let { room -> + if (room.isEventRead(eventID)) { + Timber.d("Notifiable Event $eventID is read, and should be removed") + return true + } + } + } + } + */ + } + return false + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/RoomEventGroupInfo.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/RoomEventGroupInfo.kt new file mode 100644 index 0000000000..e1c4e58280 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/RoomEventGroupInfo.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.features.notifications + +/** + * Data class to hold information about a group of notifications for a room + */ +data class RoomEventGroupInfo( + val roomId: String +) { + var roomDisplayName: String = "" + var roomAvatarPath: String? = null + //An event in the list has not yet been display + var hasNewEvent: Boolean = false + //true if at least one on the not yet displayed event is noisy + var shouldBing: Boolean = false + var customSound: String? = null + var hasSmartReplyError = false + var isDirect = false +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/notifications/SimpleNotifiableEvent.kt b/vector/src/main/java/im/vector/riotredesign/features/notifications/SimpleNotifiableEvent.kt new file mode 100644 index 0000000000..b0226ca3f3 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/notifications/SimpleNotifiableEvent.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.notifications + +import androidx.core.app.NotificationCompat + +data class SimpleNotifiableEvent( + override var matrixID: String?, + override val eventId: String, + override var noisy: Boolean, + override val title: String, + override val description: String, + override val type: String?, + override val timestamp: Long, + override var soundName: String?, + override var isPushGatewayEvent: Boolean = false) : NotifiableEvent { + + override var hasBeenDisplayed: Boolean = false + override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + +} + diff --git a/vector/src/main/java/im/vector/riotredesign/features/settings/PreferencesManager.java b/vector/src/main/java/im/vector/riotredesign/features/settings/PreferencesManager.java new file mode 100755 index 0000000000..845af353fb --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/settings/PreferencesManager.java @@ -0,0 +1,861 @@ +/* + * Copyright 2016 OpenMarket Ltd + * Copyright 2017 Vector Creations Ltd + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.settings; + +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.media.RingtoneManager; +import android.net.Uri; +import android.provider.MediaStore; +import android.text.TextUtils; + +import java.io.File; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; +import im.vector.riotredesign.R; +import im.vector.riotredesign.features.homeserver.ServerUrlsRepository; +import im.vector.riotredesign.features.themes.ThemeUtils; +import timber.log.Timber; + +public class PreferencesManager { + + public static final String VERSION_BUILD = "VERSION_BUILD"; + + public static final String SETTINGS_MESSAGES_SENT_BY_BOT_PREFERENCE_KEY = "SETTINGS_MESSAGES_SENT_BY_BOT_PREFERENCE_KEY_2"; + public static final String SETTINGS_CHANGE_PASSWORD_PREFERENCE_KEY = "SETTINGS_CHANGE_PASSWORD_PREFERENCE_KEY"; + public static final String SETTINGS_VERSION_PREFERENCE_KEY = "SETTINGS_VERSION_PREFERENCE_KEY"; + public static final String SETTINGS_OLM_VERSION_PREFERENCE_KEY = "SETTINGS_OLM_VERSION_PREFERENCE_KEY"; + public static final String SETTINGS_LOGGED_IN_PREFERENCE_KEY = "SETTINGS_LOGGED_IN_PREFERENCE_KEY"; + public static final String SETTINGS_HOME_SERVER_PREFERENCE_KEY = "SETTINGS_HOME_SERVER_PREFERENCE_KEY"; + public static final String SETTINGS_IDENTITY_SERVER_PREFERENCE_KEY = "SETTINGS_IDENTITY_SERVER_PREFERENCE_KEY"; + public static final String SETTINGS_APP_TERM_CONDITIONS_PREFERENCE_KEY = "SETTINGS_APP_TERM_CONDITIONS_PREFERENCE_KEY"; + public static final String SETTINGS_PRIVACY_POLICY_PREFERENCE_KEY = "SETTINGS_PRIVACY_POLICY_PREFERENCE_KEY"; + public static final String SETTINGS_NOTIFICATION_PRIVACY_PREFERENCE_KEY = "SETTINGS_NOTIFICATION_PRIVACY_PREFERENCE_KEY"; + public static final String SETTINGS_NOTIFICATION_TROUBLESHOOT_PREFERENCE_KEY = "SETTINGS_NOTIFICATION_TROUBLESHOOT_PREFERENCE_KEY"; + public static final String SETTINGS_NOTIFICATION_ADVANCED_PREFERENCE_KEY = "SETTINGS_NOTIFICATION_ADVANCED_PREFERENCE_KEY"; + public static final String SETTINGS_THIRD_PARTY_NOTICES_PREFERENCE_KEY = "SETTINGS_THIRD_PARTY_NOTICES_PREFERENCE_KEY"; + public static final String SETTINGS_COPYRIGHT_PREFERENCE_KEY = "SETTINGS_COPYRIGHT_PREFERENCE_KEY"; + public static final String SETTINGS_CLEAR_CACHE_PREFERENCE_KEY = "SETTINGS_CLEAR_CACHE_PREFERENCE_KEY"; + public static final String SETTINGS_CLEAR_MEDIA_CACHE_PREFERENCE_KEY = "SETTINGS_CLEAR_MEDIA_CACHE_PREFERENCE_KEY"; + public static final String SETTINGS_USER_SETTINGS_PREFERENCE_KEY = "SETTINGS_USER_SETTINGS_PREFERENCE_KEY"; + public static final String SETTINGS_CONTACT_PREFERENCE_KEYS = "SETTINGS_CONTACT_PREFERENCE_KEYS"; + public static final String SETTINGS_NOTIFICATIONS_TARGETS_PREFERENCE_KEY = "SETTINGS_NOTIFICATIONS_TARGETS_PREFERENCE_KEY"; + public static final String SETTINGS_NOTIFICATIONS_TARGET_DIVIDER_PREFERENCE_KEY = "SETTINGS_NOTIFICATIONS_TARGET_DIVIDER_PREFERENCE_KEY"; + public static final String SETTINGS_IGNORED_USERS_PREFERENCE_KEY = "SETTINGS_IGNORED_USERS_PREFERENCE_KEY"; + public static final String SETTINGS_IGNORE_USERS_DIVIDER_PREFERENCE_KEY = "SETTINGS_IGNORE_USERS_DIVIDER_PREFERENCE_KEY"; + public static final String SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY"; + public static final String SETTINGS_BACKGROUND_SYNC_DIVIDER_PREFERENCE_KEY = "SETTINGS_BACKGROUND_SYNC_DIVIDER_PREFERENCE_KEY"; + public static final String SETTINGS_LABS_PREFERENCE_KEY = "SETTINGS_LABS_PREFERENCE_KEY"; + public static final String SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY"; + public static final String SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY"; + public static final String SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY"; + public static final String SETTINGS_CRYPTOGRAPHY_MANAGE_DIVIDER_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_MANAGE_DIVIDER_PREFERENCE_KEY"; + public static final String SETTINGS_DEVICES_LIST_PREFERENCE_KEY = "SETTINGS_DEVICES_LIST_PREFERENCE_KEY"; + public static final String SETTINGS_DEVICES_DIVIDER_PREFERENCE_KEY = "SETTINGS_DEVICES_DIVIDER_PREFERENCE_KEY"; + public static final String SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_PREFERENCE_KEY = "SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_PREFERENCE_KEY"; + public static final String SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_IS_ACTIVE_PREFERENCE_KEY + = "SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_IS_ACTIVE_PREFERENCE_KEY"; + public static final String SETTINGS_ENCRYPTION_INFORMATION_DEVICE_NAME_PREFERENCE_KEY = "SETTINGS_ENCRYPTION_INFORMATION_DEVICE_NAME_PREFERENCE_KEY"; + public static final String SETTINGS_ENCRYPTION_INFORMATION_DEVICE_ID_PREFERENCE_KEY = "SETTINGS_ENCRYPTION_INFORMATION_DEVICE_ID_PREFERENCE_KEY"; + public static final String SETTINGS_ENCRYPTION_EXPORT_E2E_ROOM_KEYS_PREFERENCE_KEY = "SETTINGS_ENCRYPTION_EXPORT_E2E_ROOM_KEYS_PREFERENCE_KEY"; + public static final String SETTINGS_ENCRYPTION_IMPORT_E2E_ROOM_KEYS_PREFERENCE_KEY = "SETTINGS_ENCRYPTION_IMPORT_E2E_ROOM_KEYS_PREFERENCE_KEY"; + public static final String SETTINGS_ENCRYPTION_NEVER_SENT_TO_PREFERENCE_KEY = "SETTINGS_ENCRYPTION_NEVER_SENT_TO_PREFERENCE_KEY"; + public static final String SETTINGS_ENCRYPTION_INFORMATION_DEVICE_KEY_PREFERENCE_KEY = "SETTINGS_ENCRYPTION_INFORMATION_DEVICE_KEY_PREFERENCE_KEY"; + + public static final String SETTINGS_SECURE_MESSAGE_RECOVERY_PREFERENCE_KEY = "SETTINGS_SECURE_MESSAGE_RECOVERY_PREFERENCE_KEY"; + + // user + public static final String SETTINGS_DISPLAY_NAME_PREFERENCE_KEY = "SETTINGS_DISPLAY_NAME_PREFERENCE_KEY"; + public static final String SETTINGS_PROFILE_PICTURE_PREFERENCE_KEY = "SETTINGS_PROFILE_PICTURE_PREFERENCE_KEY"; + + // contacts + public static final String SETTINGS_CONTACTS_PHONEBOOK_COUNTRY_PREFERENCE_KEY = "SETTINGS_CONTACTS_PHONEBOOK_COUNTRY_PREFERENCE_KEY"; + + // interface + public static final String SETTINGS_INTERFACE_LANGUAGE_PREFERENCE_KEY = "SETTINGS_INTERFACE_LANGUAGE_PREFERENCE_KEY"; + public static final String SETTINGS_INTERFACE_TEXT_SIZE_KEY = "SETTINGS_INTERFACE_TEXT_SIZE_KEY"; + public static final String SETTINGS_SHOW_URL_PREVIEW_KEY = "SETTINGS_SHOW_URL_PREVIEW_KEY"; + private static final String SETTINGS_SEND_TYPING_NOTIF_KEY = "SETTINGS_SEND_TYPING_NOTIF_KEY"; + private static final String SETTINGS_ENABLE_MARKDOWN_KEY = "SETTINGS_ENABLE_MARKDOWN_KEY"; + private static final String SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY = "SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY"; + private static final String SETTINGS_12_24_TIMESTAMPS_KEY = "SETTINGS_12_24_TIMESTAMPS_KEY"; + private static final String SETTINGS_SHOW_READ_RECEIPTS_KEY = "SETTINGS_SHOW_READ_RECEIPTS_KEY"; + private static final String SETTINGS_SHOW_JOIN_LEAVE_MESSAGES_KEY = "SETTINGS_SHOW_JOIN_LEAVE_MESSAGES_KEY"; + private static final String SETTINGS_SHOW_AVATAR_DISPLAY_NAME_CHANGES_MESSAGES_KEY = "SETTINGS_SHOW_AVATAR_DISPLAY_NAME_CHANGES_MESSAGES_KEY"; + private static final String SETTINGS_VIBRATE_ON_MENTION_KEY = "SETTINGS_VIBRATE_ON_MENTION_KEY"; + private static final String SETTINGS_SEND_MESSAGE_WITH_ENTER = "SETTINGS_SEND_MESSAGE_WITH_ENTER"; + + // home + private static final String SETTINGS_PIN_UNREAD_MESSAGES_PREFERENCE_KEY = "SETTINGS_PIN_UNREAD_MESSAGES_PREFERENCE_KEY"; + private static final String SETTINGS_PIN_MISSED_NOTIFICATIONS_PREFERENCE_KEY = "SETTINGS_PIN_MISSED_NOTIFICATIONS_PREFERENCE_KEY"; + + // flair + public static final String SETTINGS_GROUPS_FLAIR_KEY = "SETTINGS_GROUPS_FLAIR_KEY"; + + // notifications + public static final String SETTINGS_NOTIFICATIONS_KEY = "SETTINGS_NOTIFICATIONS_KEY"; + public static final String SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY = "SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY"; + public static final String SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY = "SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY"; + public static final String SETTINGS_TURN_SCREEN_ON_PREFERENCE_KEY = "SETTINGS_TURN_SCREEN_ON_PREFERENCE_KEY"; + public static final String SETTINGS_SYSTEM_CALL_NOTIFICATION_PREFERENCE_KEY = "SETTINGS_SYSTEM_CALL_NOTIFICATION_PREFERENCE_KEY"; + public static final String SETTINGS_SYSTEM_NOISY_NOTIFICATION_PREFERENCE_KEY = "SETTINGS_SYSTEM_NOISY_NOTIFICATION_PREFERENCE_KEY"; + public static final String SETTINGS_SYSTEM_SILENT_NOTIFICATION_PREFERENCE_KEY = "SETTINGS_SYSTEM_SILENT_NOTIFICATION_PREFERENCE_KEY"; + public static final String SETTINGS_NOTIFICATION_RINGTONE_PREFERENCE_KEY = "SETTINGS_NOTIFICATION_RINGTONE_PREFERENCE_KEY"; + public static final String SETTINGS_NOTIFICATION_RINGTONE_SELECTION_PREFERENCE_KEY = "SETTINGS_NOTIFICATION_RINGTONE_SELECTION_PREFERENCE_KEY"; + public static final String SETTINGS_CONTAINING_MY_DISPLAY_NAME_PREFERENCE_KEY = "SETTINGS_CONTAINING_MY_DISPLAY_NAME_PREFERENCE_KEY_2"; + public static final String SETTINGS_CONTAINING_MY_USER_NAME_PREFERENCE_KEY = "SETTINGS_CONTAINING_MY_USER_NAME_PREFERENCE_KEY_2"; + public static final String SETTINGS_MESSAGES_IN_ONE_TO_ONE_PREFERENCE_KEY = "SETTINGS_MESSAGES_IN_ONE_TO_ONE_PREFERENCE_KEY_2"; + public static final String SETTINGS_MESSAGES_IN_GROUP_CHAT_PREFERENCE_KEY = "SETTINGS_MESSAGES_IN_GROUP_CHAT_PREFERENCE_KEY_2"; + public static final String SETTINGS_INVITED_TO_ROOM_PREFERENCE_KEY = "SETTINGS_INVITED_TO_ROOM_PREFERENCE_KEY_2"; + public static final String SETTINGS_CALL_INVITATIONS_PREFERENCE_KEY = "SETTINGS_CALL_INVITATIONS_PREFERENCE_KEY_2"; + + // media + private static final String SETTINGS_DEFAULT_MEDIA_COMPRESSION_KEY = "SETTINGS_DEFAULT_MEDIA_COMPRESSION_KEY"; + private static final String SETTINGS_DEFAULT_MEDIA_SOURCE_KEY = "SETTINGS_DEFAULT_MEDIA_SOURCE_KEY"; + private static final String SETTINGS_PREVIEW_MEDIA_BEFORE_SENDING_KEY = "SETTINGS_PREVIEW_MEDIA_BEFORE_SENDING_KEY"; + private static final String SETTINGS_PLAY_SHUTTER_SOUND_KEY = "SETTINGS_PLAY_SHUTTER_SOUND_KEY"; + + // background sync + public static final String SETTINGS_START_ON_BOOT_PREFERENCE_KEY = "SETTINGS_START_ON_BOOT_PREFERENCE_KEY"; + public static final String SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY"; + public static final String SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY = "SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY"; + public static final String SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY = "SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY"; + + // Calls + public static final String SETTINGS_CALL_RINGTONE_USE_RIOT_PREFERENCE_KEY = "SETTINGS_CALL_RINGTONE_USE_RIOT_PREFERENCE_KEY"; + public static final String SETTINGS_CALL_RINGTONE_URI_PREFERENCE_KEY = "SETTINGS_CALL_RINGTONE_URI_PREFERENCE_KEY"; + + // labs + public static final String SETTINGS_LAZY_LOADING_PREFERENCE_KEY = "SETTINGS_LAZY_LOADING_PREFERENCE_KEY"; + public static final String SETTINGS_USER_REFUSED_LAZY_LOADING_PREFERENCE_KEY = "SETTINGS_USER_REFUSED_LAZY_LOADING_PREFERENCE_KEY"; + public static final String SETTINGS_DATA_SAVE_MODE_PREFERENCE_KEY = "SETTINGS_DATA_SAVE_MODE_PREFERENCE_KEY"; + private static final String SETTINGS_USE_JITSI_CONF_PREFERENCE_KEY = "SETTINGS_USE_JITSI_CONF_PREFERENCE_KEY"; + private static final String SETTINGS_USE_NATIVE_CAMERA_PREFERENCE_KEY = "SETTINGS_USE_NATIVE_CAMERA_PREFERENCE_KEY"; + private static final String SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY = "SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY"; + + // analytics + public static final String SETTINGS_USE_ANALYTICS_KEY = "SETTINGS_USE_ANALYTICS_KEY"; + public static final String SETTINGS_USE_RAGE_SHAKE_KEY = "SETTINGS_USE_RAGE_SHAKE_KEY"; + + // other + public static final String SETTINGS_MEDIA_SAVING_PERIOD_KEY = "SETTINGS_MEDIA_SAVING_PERIOD_KEY"; + private static final String SETTINGS_MEDIA_SAVING_PERIOD_SELECTED_KEY = "SETTINGS_MEDIA_SAVING_PERIOD_SELECTED_KEY"; + private static final String DID_ASK_TO_IGNORE_BATTERY_OPTIMIZATIONS_KEY = "DID_ASK_TO_IGNORE_BATTERY_OPTIMIZATIONS_KEY"; + private static final String DID_MIGRATE_TO_NOTIFICATION_REWORK = "DID_MIGRATE_TO_NOTIFICATION_REWORK"; + private static final String DID_ASK_TO_USE_ANALYTICS_TRACKING_KEY = "DID_ASK_TO_USE_ANALYTICS_TRACKING_KEY"; + public static final String SETTINGS_DEACTIVATE_ACCOUNT_KEY = "SETTINGS_DEACTIVATE_ACCOUNT_KEY"; + private static final String SETTINGS_DISPLAY_ALL_EVENTS_KEY = "SETTINGS_DISPLAY_ALL_EVENTS_KEY"; + + private static final int MEDIA_SAVING_3_DAYS = 0; + private static final int MEDIA_SAVING_1_WEEK = 1; + private static final int MEDIA_SAVING_1_MONTH = 2; + private static final int MEDIA_SAVING_FOREVER = 3; + + // some preferences keys must be kept after a logout + private static final List mKeysToKeepAfterLogout = Arrays.asList( + SETTINGS_DEFAULT_MEDIA_COMPRESSION_KEY, + SETTINGS_DEFAULT_MEDIA_SOURCE_KEY, + SETTINGS_PLAY_SHUTTER_SOUND_KEY, + + SETTINGS_SEND_TYPING_NOTIF_KEY, + SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY, + SETTINGS_12_24_TIMESTAMPS_KEY, + SETTINGS_SHOW_READ_RECEIPTS_KEY, + SETTINGS_SHOW_JOIN_LEAVE_MESSAGES_KEY, + SETTINGS_SHOW_AVATAR_DISPLAY_NAME_CHANGES_MESSAGES_KEY, + SETTINGS_MEDIA_SAVING_PERIOD_KEY, + SETTINGS_MEDIA_SAVING_PERIOD_SELECTED_KEY, + SETTINGS_PREVIEW_MEDIA_BEFORE_SENDING_KEY, + SETTINGS_SEND_MESSAGE_WITH_ENTER, + + SETTINGS_PIN_UNREAD_MESSAGES_PREFERENCE_KEY, + SETTINGS_PIN_MISSED_NOTIFICATIONS_PREFERENCE_KEY, + // Do not keep SETTINGS_LAZY_LOADING_PREFERENCE_KEY because the user may log in on a server which does not support lazy loading + SETTINGS_DATA_SAVE_MODE_PREFERENCE_KEY, + SETTINGS_START_ON_BOOT_PREFERENCE_KEY, + SETTINGS_INTERFACE_TEXT_SIZE_KEY, + SETTINGS_USE_JITSI_CONF_PREFERENCE_KEY, + SETTINGS_NOTIFICATION_RINGTONE_PREFERENCE_KEY, + SETTINGS_NOTIFICATION_RINGTONE_SELECTION_PREFERENCE_KEY, + + SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_PREFERENCE_KEY, + SETTINGS_CONTACTS_PHONEBOOK_COUNTRY_PREFERENCE_KEY, + SETTINGS_INTERFACE_LANGUAGE_PREFERENCE_KEY, + SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY, + SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY, + SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, + SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, + + SETTINGS_USE_RAGE_SHAKE_KEY + ); + + /** + * Clear the preferences. + * + * @param context the context + */ + public static void clearPreferences(Context context) { + Set keysToKeep = new HashSet<>(mKeysToKeepAfterLogout); + + // home server urls + keysToKeep.add(ServerUrlsRepository.HOME_SERVER_URL_PREF); + keysToKeep.add(ServerUrlsRepository.IDENTITY_SERVER_URL_PREF); + + // theme + keysToKeep.add(ThemeUtils.APPLICATION_THEME_KEY); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + SharedPreferences.Editor editor = preferences.edit(); + + // get all the existing keys + Set keys = preferences.getAll().keySet(); + // remove the one to keep + + keys.removeAll(keysToKeep); + + for (String key : keys) { + editor.remove(key); + } + + editor.apply(); + } + + /** + * Tells if we have already asked the user to disable battery optimisations on android >= M devices. + * + * @param context the context + * @return true if it was already requested + */ + public static boolean didAskUserToIgnoreBatteryOptimizations(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(DID_ASK_TO_IGNORE_BATTERY_OPTIMIZATIONS_KEY, false); + } + + /** + * Mark as requested the question to disable battery optimisations. + * + * @param context the context + */ + public static void setDidAskUserToIgnoreBatteryOptimizations(Context context) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(DID_ASK_TO_IGNORE_BATTERY_OPTIMIZATIONS_KEY, true) + .apply(); + } + + public static boolean didMigrateToNotificationRework(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(DID_MIGRATE_TO_NOTIFICATION_REWORK, false); + } + + public static void setDidMigrateToNotificationRework(Context context) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(DID_MIGRATE_TO_NOTIFICATION_REWORK, true) + .apply(); + } + + /** + * Tells if the timestamp must be displayed in 12h format + * + * @param context the context + * @return true if the time must be displayed in 12h format + */ + public static boolean displayTimeIn12hFormat(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_12_24_TIMESTAMPS_KEY, false); + } + + /** + * Tells if the join and leave membership events should be shown in the messages list. + * + * @param context the context + * @return true if the join and leave membership events should be shown in the messages list + */ + public static boolean showJoinLeaveMessages(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_SHOW_JOIN_LEAVE_MESSAGES_KEY, true); + } + + /** + * Tells if the avatar and display name events should be shown in the messages list. + * + * @param context the context + * @return true true if the avatar and display name events should be shown in the messages list. + */ + public static boolean showAvatarDisplayNameChangeMessages(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_SHOW_AVATAR_DISPLAY_NAME_CHANGES_MESSAGES_KEY, true); + } + + /** + * Tells the native camera to take a photo or record a video. + * + * @param context the context + * @return true to use the native camera app to record video or take photo. + */ + public static boolean useNativeCamera(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_USE_NATIVE_CAMERA_PREFERENCE_KEY, false); + } + + /** + * Tells if the send voice feature is enabled. + * + * @param context the context + * @return true if the send voice feature is enabled. + */ + public static boolean isSendVoiceFeatureEnabled(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY, false); + } + + /** + * Tells which compression level to use by default + * + * @param context the context + * @return the selected compression level + */ + public static int getSelectedDefaultMediaCompressionLevel(Context context) { + return Integer.parseInt(PreferenceManager.getDefaultSharedPreferences(context).getString(SETTINGS_DEFAULT_MEDIA_COMPRESSION_KEY, "0")); + } + + /** + * Tells which media source to use by default + * + * @param context the context + * @return the selected media source + */ + public static int getSelectedDefaultMediaSource(Context context) { + return Integer.parseInt(PreferenceManager.getDefaultSharedPreferences(context).getString(SETTINGS_DEFAULT_MEDIA_SOURCE_KEY, "0")); + } + + /** + * Tells whether to use shutter sound. + * + * @param context the context + * @return true if shutter sound should play + */ + public static boolean useShutterSound(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_PLAY_SHUTTER_SOUND_KEY, true); + } + + /** + * Update the notification ringtone + * + * @param context the context + * @param uri the new notification ringtone, or null for no RingTone + */ + public static void setNotificationRingTone(Context context, @Nullable Uri uri) { + SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit(); + + String value = ""; + + if (null != uri) { + value = uri.toString(); + + if (value.startsWith("file://")) { + // it should never happen + // else android.os.FileUriExposedException will be triggered. + // see https://github.com/vector-im/riot-android/issues/1725 + return; + } + } + + editor.putString(SETTINGS_NOTIFICATION_RINGTONE_PREFERENCE_KEY, value); + editor.apply(); + } + + /** + * Provides the selected notification ring tone + * + * @param context the context + * @return the selected ring tone or null for no RingTone + */ + @Nullable + public static Uri getNotificationRingTone(Context context) { + String url = PreferenceManager.getDefaultSharedPreferences(context).getString(SETTINGS_NOTIFICATION_RINGTONE_PREFERENCE_KEY, null); + + // the user selects "None" + if (TextUtils.equals(url, "")) { + return null; + } + + Uri uri = null; + + // https://github.com/vector-im/riot-android/issues/1725 + if ((null != url) && !url.startsWith("file://")) { + try { + uri = Uri.parse(url); + } catch (Exception e) { + Timber.e(e, "## getNotificationRingTone() : Uri.parse failed"); + } + } + + if (null == uri) { + uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); + } + + Timber.d("## getNotificationRingTone() returns " + uri); + return uri; + } + + /** + * Provide the notification ringtone filename + * + * @param context the context + * @return the filename or null if "None" is selected + */ + @Nullable + public static String getNotificationRingToneName(Context context) { + Uri toneUri = getNotificationRingTone(context); + + if (null == toneUri) { + return null; + } + + String name = null; + + Cursor cursor = null; + + try { + String[] proj = {MediaStore.Audio.Media.DATA}; + cursor = context.getContentResolver().query(toneUri, proj, null, null, null); + int column_index = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA); + cursor.moveToFirst(); + + File file = new File(cursor.getString(column_index)); + name = file.getName(); + + if (name.contains(".")) { + name = name.substring(0, name.lastIndexOf(".")); + } + } catch (Exception e) { + Timber.e(e, "## getNotificationRingToneName() failed() : " + e.getMessage()); + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return name; + } + + /** + * Enable or disable the lazy loading + * + * @param context the context + * @param newValue true to enable lazy loading, false to disable it + */ + public static void setUseLazyLoading(Context context, boolean newValue) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(SETTINGS_LAZY_LOADING_PREFERENCE_KEY, newValue) + .apply(); + } + + /** + * Tells if the lazy loading is enabled + * + * @param context the context + * @return true if the lazy loading of room members is enabled + */ + public static boolean useLazyLoading(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_LAZY_LOADING_PREFERENCE_KEY, false); + } + + /** + * User explicitly refuses the lazy loading. + * + * @param context the context + */ + public static void setUserRefuseLazyLoading(Context context) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(SETTINGS_USER_REFUSED_LAZY_LOADING_PREFERENCE_KEY, true) + .apply(); + } + + /** + * Tells if the user has explicitly refused the lazy loading + * + * @param context the context + * @return true if the user has explicitly refuse the lazy loading of room members + */ + public static boolean hasUserRefusedLazyLoading(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_USER_REFUSED_LAZY_LOADING_PREFERENCE_KEY, false); + } + + /** + * Tells if the data save mode is enabled + * + * @param context the context + * @return true if the data save mode is enabled + */ + public static boolean useDataSaveMode(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_DATA_SAVE_MODE_PREFERENCE_KEY, false); + } + + /** + * Tells if the conf calls must be done with Jitsi. + * + * @param context the context + * @return true if the conference call must be done with jitsi. + */ + public static boolean useJitsiConfCall(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_USE_JITSI_CONF_PREFERENCE_KEY, true); + } + + /** + * Tells if the application is started on boot + * + * @param context the context + * @return true if the application must be started on boot + */ + public static boolean autoStartOnBoot(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_START_ON_BOOT_PREFERENCE_KEY, true); + } + + /** + * Tells if the application is started on boot + * + * @param context the context + * @param value true to start the application on boot + */ + public static void setAutoStartOnBoot(Context context, boolean value) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(SETTINGS_START_ON_BOOT_PREFERENCE_KEY, value) + .apply(); + } + + /** + * Provides the selected saving period. + * + * @param context the context + * @return the selected period + */ + public static int getSelectedMediasSavingPeriod(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getInt(SETTINGS_MEDIA_SAVING_PERIOD_SELECTED_KEY, MEDIA_SAVING_1_WEEK); + } + + /** + * Updates the selected saving period. + * + * @param context the context + * @param index the selected period index + */ + public static void setSelectedMediasSavingPeriod(Context context, int index) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putInt(SETTINGS_MEDIA_SAVING_PERIOD_SELECTED_KEY, index) + .apply(); + } + + /** + * Provides the minimum last access time to keep a media file. + * + * @param context the context + * @return the min last access time (in seconds) + */ + public static long getMinMediasLastAccessTime(Context context) { + int selection = getSelectedMediasSavingPeriod(context); + + switch (selection) { + case MEDIA_SAVING_3_DAYS: + return (System.currentTimeMillis() / 1000) - (3 * 24 * 60 * 60); + case MEDIA_SAVING_1_WEEK: + return (System.currentTimeMillis() / 1000) - (7 * 24 * 60 * 60); + case MEDIA_SAVING_1_MONTH: + return (System.currentTimeMillis() / 1000) - (30 * 24 * 60 * 60); + case MEDIA_SAVING_FOREVER: + return 0; + } + + return 0; + } + + /** + * Provides the selected saving period. + * + * @param context the context + * @return the selected period + */ + public static String getSelectedMediasSavingPeriodString(Context context) { + int selection = getSelectedMediasSavingPeriod(context); + + switch (selection) { + case MEDIA_SAVING_3_DAYS: + return context.getString(R.string.media_saving_period_3_days); + case MEDIA_SAVING_1_WEEK: + return context.getString(R.string.media_saving_period_1_week); + case MEDIA_SAVING_1_MONTH: + return context.getString(R.string.media_saving_period_1_month); + case MEDIA_SAVING_FOREVER: + return context.getString(R.string.media_saving_period_forever); + } + return "?"; + } + + /** + * Fix some migration issues + */ + public static void fixMigrationIssues(Context context) { + // some key names have been updated to supported language switch + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + + if (preferences.contains(context.getString(R.string.settings_pin_missed_notifications))) { + preferences.edit() + .putBoolean(SETTINGS_PIN_MISSED_NOTIFICATIONS_PREFERENCE_KEY, + preferences.getBoolean(context.getString(R.string.settings_pin_missed_notifications), false)) + .remove(context.getString(R.string.settings_pin_missed_notifications)) + .apply(); + } + + if (preferences.contains(context.getString(R.string.settings_pin_unread_messages))) { + preferences.edit() + .putBoolean(SETTINGS_PIN_UNREAD_MESSAGES_PREFERENCE_KEY, + preferences.getBoolean(context.getString(R.string.settings_pin_unread_messages), false)) + .remove(context.getString(R.string.settings_pin_unread_messages)) + .apply(); + } + + if (preferences.contains("MARKDOWN_PREFERENCE_KEY")) { + preferences.edit() + .putBoolean(SETTINGS_ENABLE_MARKDOWN_KEY, preferences.getBoolean("MARKDOWN_PREFERENCE_KEY", true)) + .remove("MARKDOWN_PREFERENCE_KEY") + .apply(); + } + + if (preferences.contains("SETTINGS_DONT_SEND_TYPING_NOTIF_KEY")) { + preferences.edit() + .putBoolean(SETTINGS_SEND_TYPING_NOTIF_KEY, !preferences.getBoolean("SETTINGS_DONT_SEND_TYPING_NOTIF_KEY", true)) + .remove("SETTINGS_DONT_SEND_TYPING_NOTIF_KEY") + .apply(); + } + + if (preferences.contains("SETTINGS_DISABLE_MARKDOWN_KEY")) { + preferences.edit() + .putBoolean(SETTINGS_ENABLE_MARKDOWN_KEY, !preferences.getBoolean("SETTINGS_DISABLE_MARKDOWN_KEY", true)) + .remove("SETTINGS_DISABLE_MARKDOWN_KEY") + .apply(); + } + + if (preferences.contains("SETTINGS_HIDE_READ_RECEIPTS")) { + preferences.edit() + .putBoolean(SETTINGS_SHOW_READ_RECEIPTS_KEY, !preferences.getBoolean("SETTINGS_HIDE_READ_RECEIPTS", true)) + .remove("SETTINGS_HIDE_READ_RECEIPTS") + .apply(); + } + + if (preferences.contains("SETTINGS_HIDE_JOIN_LEAVE_MESSAGES_KEY")) { + preferences.edit() + .putBoolean(SETTINGS_SHOW_JOIN_LEAVE_MESSAGES_KEY, !preferences.getBoolean("SETTINGS_HIDE_JOIN_LEAVE_MESSAGES_KEY", true)) + .remove("SETTINGS_HIDE_JOIN_LEAVE_MESSAGES_KEY") + .apply(); + } + + if (preferences.contains("SETTINGS_HIDE_AVATAR_DISPLAY_NAME_CHANGES")) { + preferences.edit() + .putBoolean(SETTINGS_SHOW_AVATAR_DISPLAY_NAME_CHANGES_MESSAGES_KEY, + !preferences.getBoolean("SETTINGS_HIDE_AVATAR_DISPLAY_NAME_CHANGES", true)) + .remove("SETTINGS_HIDE_AVATAR_DISPLAY_NAME_CHANGES") + .apply(); + } + } + + /** + * Tells if the markdown is enabled + * + * @param context the context + * @return true if the markdown is enabled + */ + public static boolean isMarkdownEnabled(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_ENABLE_MARKDOWN_KEY, true); + } + + /** + * Update the markdown enable status. + * + * @param context the context + * @param isEnabled true to enable the markdown + */ + public static void setMarkdownEnabled(Context context, boolean isEnabled) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(SETTINGS_ENABLE_MARKDOWN_KEY, isEnabled) + .apply(); + } + + /** + * Tells if the read receipts should be shown + * + * @param context the context + * @return true if the read receipts should be shown + */ + public static boolean showReadReceipts(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_SHOW_READ_RECEIPTS_KEY, true); + } + + /** + * Tells if the message timestamps must be always shown + * + * @param context the context + * @return true if the message timestamps must be always shown + */ + public static boolean alwaysShowTimeStamps(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY, false); + } + + /** + * Tells if the typing notifications should be sent + * + * @param context the context + * @return true to send the typing notifs + */ + public static boolean sendTypingNotifs(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_SEND_TYPING_NOTIF_KEY, true); + } + + /** + * Tells of the missing notifications rooms must be displayed at left (home screen) + * + * @param context the context + * @return true to move the missed notifications to the left side + */ + public static boolean pinMissedNotifications(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_PIN_MISSED_NOTIFICATIONS_PREFERENCE_KEY, true); + } + + /** + * Tells of the unread rooms must be displayed at left (home screen) + * + * @param context the context + * @return true to move the unread room to the left side + */ + public static boolean pinUnreadMessages(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_PIN_UNREAD_MESSAGES_PREFERENCE_KEY, true); + } + + /** + * Tells if the phone must vibrate when mentioning + * + * @param context the context + * @return true + */ + public static boolean vibrateWhenMentioning(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_VIBRATE_ON_MENTION_KEY, false); + } + + /** + * Tells if a dialog has been displayed to ask to use the analytics tracking (piwik, matomo, etc.). + * + * @param context the context + * @return true if a dialog has been displayed to ask to use the analytics tracking + */ + public static boolean didAskToUseAnalytics(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(DID_ASK_TO_USE_ANALYTICS_TRACKING_KEY, false); + } + + /** + * To call if the user has been asked for analytics tracking. + * + * @param context the context + */ + public static void setDidAskToUseAnalytics(Context context) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(DID_ASK_TO_USE_ANALYTICS_TRACKING_KEY, true) + .apply(); + } + + /** + * Tells if the analytics tracking is authorized (piwik, matomo, etc.). + * + * @param context the context + * @return true if the analytics tracking is authorized + */ + public static boolean useAnalytics(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_USE_ANALYTICS_KEY, false); + } + + /** + * Enable or disable the analytics tracking. + * + * @param context the context + * @param useAnalytics true to enable the analytics tracking + */ + public static void setUseAnalytics(Context context, boolean useAnalytics) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(SETTINGS_USE_ANALYTICS_KEY, useAnalytics) + .apply(); + } + + /** + * Tells if media should be previewed before sending + * + * @param context the context + * @return true to preview media + */ + public static boolean previewMediaWhenSending(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_PREVIEW_MEDIA_BEFORE_SENDING_KEY, false); + } + + /** + * Tells if message should be send by pressing enter on the soft keyboard + * + * @param context the context + * @return true to send message with enter + */ + public static boolean sendMessageWithEnter(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_SEND_MESSAGE_WITH_ENTER, false); + } + + /** + * Tells if the rage shake is used. + * + * @param context the context + * @return true if the rage shake is used + */ + public static boolean useRageshake(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_USE_RAGE_SHAKE_KEY, true); + } + + /** + * Update the rage shake status. + * + * @param context the context + * @param isEnabled true to enable the rage shake + */ + public static void setUseRageshake(Context context, boolean isEnabled) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(SETTINGS_USE_RAGE_SHAKE_KEY, isEnabled) + .apply(); + } + + /** + * Tells if all the events must be displayed ie even the redacted events. + * + * @param context the context + * @return true to display all the events even the redacted ones. + */ + public static boolean displayAllEvents(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_DISPLAY_ALL_EVENTS_KEY, false); + } +} diff --git a/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsActivity.kt b/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsActivity.kt new file mode 100755 index 0000000000..f6279d1800 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsActivity.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.settings + +import android.content.Context +import android.content.Intent +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import im.vector.matrix.android.api.session.Session +import im.vector.riotredesign.R +import im.vector.riotredesign.core.platform.RiotActivity +import org.koin.android.ext.android.inject + +/** + * Displays the client settings. + */ +class VectorSettingsActivity : RiotActivity(), + PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, + FragmentManager.OnBackStackChangedListener, + VectorSettingsFragmentInteractionListener { + + private lateinit var vectorSettingsPreferencesFragment: VectorSettingsPreferencesFragment + + override fun getLayoutRes() = R.layout.activity_vector_settings + + override fun getTitleRes() = R.string.title_activity_settings + + private var keyToHighlight: String? = null + + private val session by inject() + + override fun initUiAndData() { + configureToolbar() + + if (isFirstCreation()) { + vectorSettingsPreferencesFragment = VectorSettingsPreferencesFragment.newInstance(session.sessionParams.credentials.userId) + // display the fragment + supportFragmentManager.beginTransaction() + .replace(R.id.vector_settings_page, vectorSettingsPreferencesFragment, FRAGMENT_TAG) + .commit() + } else { + vectorSettingsPreferencesFragment = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as VectorSettingsPreferencesFragment + } + + + supportFragmentManager.addOnBackStackChangedListener(this) + + } + + override fun onDestroy() { + supportFragmentManager.removeOnBackStackChangedListener(this) + super.onDestroy() + } + + override fun onBackStackChanged() { + if (0 == supportFragmentManager.backStackEntryCount) { + supportActionBar?.title = getString(getTitleRes()) + } + } + + override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat?, pref: Preference?): Boolean { + var oFragment: Fragment? = null + + if (PreferencesManager.SETTINGS_NOTIFICATION_TROUBLESHOOT_PREFERENCE_KEY == pref?.key) { + oFragment = VectorSettingsNotificationsTroubleshootFragment.newInstance(session.sessionParams.credentials.userId) + } else if (PreferencesManager.SETTINGS_NOTIFICATION_ADVANCED_PREFERENCE_KEY == pref?.key) { + oFragment = VectorSettingsAdvancedNotificationPreferenceFragment.newInstance(session.sessionParams.credentials.userId) + } + + if (oFragment != null) { + oFragment.setTargetFragment(caller, 0) + // Replace the existing Fragment with the new Fragment + supportFragmentManager.beginTransaction() + .setCustomAnimations(R.anim.anim_slide_in_bottom, R.anim.anim_slide_out_bottom, + R.anim.anim_slide_in_bottom, R.anim.anim_slide_out_bottom) + .replace(R.id.vector_settings_page, oFragment, pref?.title.toString()) + .addToBackStack(null) + .commit() + return true + } + return false + } + + + override fun requestHighlightPreferenceKeyOnResume(key: String?) { + keyToHighlight = key + } + + override fun requestedKeyToHighlight(): String? { + return keyToHighlight + } + + companion object { + fun getIntent(context: Context, userId: String) = Intent(context, VectorSettingsActivity::class.java) + .apply { + //putExtra(MXCActionBarActivity.EXTRA_MATRIX_ID, userId) + } + + private const val FRAGMENT_TAG = "VectorSettingsPreferencesFragment" + } +} diff --git a/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsAdvancedNotificationPreferenceFragment.kt b/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsAdvancedNotificationPreferenceFragment.kt new file mode 100644 index 0000000000..980859dd2b --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsAdvancedNotificationPreferenceFragment.kt @@ -0,0 +1,296 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.settings + +import android.app.Activity +import android.content.Intent +import android.media.RingtoneManager +import android.net.Uri +import android.os.Bundle +import android.os.Parcelable +import android.view.View +import androidx.core.content.edit +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceManager +import im.vector.matrix.android.api.session.Session +import im.vector.riotredesign.R +import im.vector.riotredesign.core.extensions.withArgs +import im.vector.riotredesign.core.platform.RiotActivity +import im.vector.riotredesign.core.preference.BingRule +import im.vector.riotredesign.core.preference.BingRulePreference +import im.vector.riotredesign.features.notifications.NotificationUtils +import im.vector.riotredesign.features.notifications.supportNotificationChannels +import org.koin.android.ext.android.inject + +class VectorSettingsAdvancedNotificationPreferenceFragment : PreferenceFragmentCompat() { + + // members + private val mSession by inject() + private var mLoadingView: View? = null + + // events listener + /* TODO + private val mEventsListener = object : MXEventListener() { + override fun onBingRulesUpdate() { + refreshPreferences() + refreshDisplay() + } + } + */ + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + // define the layout + addPreferencesFromResource(R.xml.vector_settings_notification_advanced_preferences) + + val callNotificationsSystemOptions = findPreference(PreferencesManager.SETTINGS_SYSTEM_CALL_NOTIFICATION_PREFERENCE_KEY) + if (supportNotificationChannels()) { + callNotificationsSystemOptions.onPreferenceClickListener = Preference.OnPreferenceClickListener { + NotificationUtils.openSystemSettingsForCallCategory(this) + false + } + } else { + callNotificationsSystemOptions.isVisible = false + } + + val noisyNotificationsSystemOptions = findPreference(PreferencesManager.SETTINGS_SYSTEM_NOISY_NOTIFICATION_PREFERENCE_KEY) + if (supportNotificationChannels()) { + noisyNotificationsSystemOptions.onPreferenceClickListener = Preference.OnPreferenceClickListener { + NotificationUtils.openSystemSettingsForNoisyCategory(this) + false + } + } else { + noisyNotificationsSystemOptions.isVisible = false + } + + val silentNotificationsSystemOptions = findPreference(PreferencesManager.SETTINGS_SYSTEM_SILENT_NOTIFICATION_PREFERENCE_KEY) + if (supportNotificationChannels()) { + silentNotificationsSystemOptions.onPreferenceClickListener = Preference.OnPreferenceClickListener { + NotificationUtils.openSystemSettingsForSilentCategory(this) + false + } + } else { + silentNotificationsSystemOptions.isVisible = false + } + + + // Ringtone + val ringtonePreference = findPreference(PreferencesManager.SETTINGS_NOTIFICATION_RINGTONE_SELECTION_PREFERENCE_KEY) + + if (supportNotificationChannels()) { + ringtonePreference.isVisible = false + } else { + ringtonePreference.summary = PreferencesManager.getNotificationRingToneName(activity) + ringtonePreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER) + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION) + + if (null != PreferencesManager.getNotificationRingTone(activity)) { + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, PreferencesManager.getNotificationRingTone(activity)) + } + + startActivityForResult(intent, REQUEST_NOTIFICATION_RINGTONE) + false + } + } + + for (preferenceKey in mPrefKeyToBingRuleId.keys) { + val preference = findPreference(preferenceKey) + if (null != preference) { + if (preference is BingRulePreference) { + //preference.isEnabled = null != rules && isConnected && pushManager.areDeviceNotificationsAllowed() + val rule: BingRule? = null // TODO mSession.dataHandler.pushRules()?.findDefaultRule(mPrefKeyToBingRuleId[preferenceKey]) + + if (rule == null) { + // The rule is not defined, hide the preference + preference.isVisible = false + } else { + preference.isVisible = true + preference.setBingRule(rule) + preference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val rule = preference.createRule(newValue as Int) + if (null != rule) { + /* + TODO + displayLoadingView() + mSession.dataHandler.bingRulesManager.updateRule(preference.rule, + rule, + object : BingRulesManager.onBingRuleUpdateListener { + private fun onDone() { + refreshDisplay() + hideLoadingView() + } + + override fun onBingRuleUpdateSuccess() { + onDone() + } + + override fun onBingRuleUpdateFailure(errorMessage: String) { + activity?.toast(errorMessage) + onDone() + } + }) + */ + } + false + } + } + } + } + } + } + + private fun refreshDisplay() { + listView?.adapter?.notifyDataSetChanged() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode == Activity.RESULT_OK) { + when (requestCode) { + REQUEST_NOTIFICATION_RINGTONE -> { + PreferencesManager.setNotificationRingTone(activity, + data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) as Uri?) + + // test if the selected ring tone can be played + val notificationRingToneName = PreferencesManager.getNotificationRingToneName(activity) + if (null != notificationRingToneName) { + PreferencesManager.setNotificationRingTone(activity, PreferencesManager.getNotificationRingTone(activity)) + findPreference(PreferencesManager.SETTINGS_NOTIFICATION_RINGTONE_SELECTION_PREFERENCE_KEY).summary = notificationRingToneName + } + } + } + } + } + + override fun onResume() { + super.onResume() + (activity as? RiotActivity)?.supportActionBar?.setTitle(R.string.settings_notification_advanced) + // find the view from parent activity + mLoadingView = activity!!.findViewById(R.id.vector_settings_spinner_views) + + /* TODO + if (mSession.isAlive) { + + mSession.dataHandler.addListener(mEventsListener) + + // refresh anything else + refreshPreferences() + refreshDisplay() + } + */ + } + + override fun onPause() { + super.onPause() + + /* TODO + if (mSession.isAlive) { + mSession.dataHandler.removeListener(mEventsListener) + } + */ + } + + /** + * Refresh the known information about the account + */ + private fun refreshPreferences() { + PreferenceManager.getDefaultSharedPreferences(activity).edit { + /* TODO + mSession.dataHandler.pushRules()?.let { + for (prefKey in mPrefKeyToBingRuleId.keys) { + val preference = findPreference(prefKey) + + if (null != preference && preference is SwitchPreference) { + val ruleId = mPrefKeyToBingRuleId[prefKey] + + val rule = it.findDefaultRule(ruleId) + var isEnabled = null != rule && rule.isEnabled + + if (TextUtils.equals(ruleId, BingRule.RULE_ID_DISABLE_ALL) || TextUtils.equals(ruleId, BingRule.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS)) { + isEnabled = !isEnabled + } else if (isEnabled) { + val actions = rule!!.actions + + // no action -> noting will be done + if (null == actions || actions.isEmpty()) { + isEnabled = false + } else if (1 == actions.size) { + try { + isEnabled = !TextUtils.equals(actions[0] as String, BingRule.ACTION_DONT_NOTIFY) + } catch (e: Exception) { + Timber.e(LOG_TAG, "## refreshPreferences failed " + e.message, e) + } + + } + }// check if the rule is only defined by don't notify + + putBoolean(prefKey, isEnabled) + } + } + } + */ + } + } + + + //============================================================================================================== + // Display methods + //============================================================================================================== + + /** + * Display the loading view. + */ + private fun displayLoadingView() { + if (null != mLoadingView) { + mLoadingView!!.visibility = View.VISIBLE + } + } + + /** + * Hide the loading view. + */ + private fun hideLoadingView() { + if (null != mLoadingView) { + mLoadingView!!.visibility = View.GONE + } + } + + + /* ========================================================================================== + * Companion + * ========================================================================================== */ + + companion object { + private const val REQUEST_NOTIFICATION_RINGTONE = 888 + + // preference name <-> rule Id + private var mPrefKeyToBingRuleId = mapOf( + PreferencesManager.SETTINGS_CONTAINING_MY_DISPLAY_NAME_PREFERENCE_KEY to BingRule.RULE_ID_CONTAIN_DISPLAY_NAME, + PreferencesManager.SETTINGS_CONTAINING_MY_USER_NAME_PREFERENCE_KEY to BingRule.RULE_ID_CONTAIN_USER_NAME, + PreferencesManager.SETTINGS_MESSAGES_IN_ONE_TO_ONE_PREFERENCE_KEY to BingRule.RULE_ID_ONE_TO_ONE_ROOM, + PreferencesManager.SETTINGS_MESSAGES_IN_GROUP_CHAT_PREFERENCE_KEY to BingRule.RULE_ID_ALL_OTHER_MESSAGES_ROOMS, + PreferencesManager.SETTINGS_INVITED_TO_ROOM_PREFERENCE_KEY to BingRule.RULE_ID_INVITE_ME, + PreferencesManager.SETTINGS_CALL_INVITATIONS_PREFERENCE_KEY to BingRule.RULE_ID_CALL, + PreferencesManager.SETTINGS_MESSAGES_SENT_BY_BOT_PREFERENCE_KEY to BingRule.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS + ) + + fun newInstance(matrixId: String) = VectorSettingsAdvancedNotificationPreferenceFragment() + .withArgs { + // putString(MXCActionBarActivity.EXTRA_MATRIX_ID, matrixId) + } + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsFragmentInteractionListener.kt b/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsFragmentInteractionListener.kt new file mode 100644 index 0000000000..f141f94908 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsFragmentInteractionListener.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.settings + +interface VectorSettingsFragmentInteractionListener { + + fun requestHighlightPreferenceKeyOnResume(key: String?) + + fun requestedKeyToHighlight(): String? + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsNotificationsTroubleshootFragment.kt b/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsNotificationsTroubleshootFragment.kt new file mode 100644 index 0000000000..c7ca4a656d --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsNotificationsTroubleshootFragment.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotredesign.features.settings + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.transition.TransitionManager +import butterknife.BindView +import im.vector.matrix.android.api.session.Session +import im.vector.push.fcm.NotificationTroubleshootTestManagerFactory +import im.vector.riotredesign.R +import im.vector.riotredesign.core.extensions.withArgs +import im.vector.riotredesign.core.platform.RiotActivity +import im.vector.riotredesign.core.platform.RiotFragment +import im.vector.riotredesign.features.rageshake.BugReporter +import im.vector.riotredesign.features.settings.troubleshoot.NotificationTroubleshootTestManager +import im.vector.riotredesign.features.settings.troubleshoot.TroubleshootTest +import org.koin.android.ext.android.inject + +class VectorSettingsNotificationsTroubleshootFragment : RiotFragment() { + + @BindView(R.id.troubleshoot_test_recycler_view) + lateinit var mRecyclerView: RecyclerView + @BindView(R.id.troubleshoot_bottom_view) + lateinit var mBottomView: ViewGroup + @BindView(R.id.toubleshoot_summ_description) + lateinit var mSummaryDescription: TextView + @BindView(R.id.troubleshoot_summ_button) + lateinit var mSummaryButton: Button + @BindView(R.id.troubleshoot_run_button) + lateinit var mRunButton: Button + + private var testManager: NotificationTroubleshootTestManager? = null + // members + private val mSession by inject() + + override fun getLayoutResId() = R.layout.fragment_settings_notifications_troubleshoot + + private var interactionListener: VectorSettingsFragmentInteractionListener? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val appContext = activity!!.applicationContext + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val layoutManager = LinearLayoutManager(context) + mRecyclerView.layoutManager = layoutManager + + val dividerItemDecoration = DividerItemDecoration(mRecyclerView.context, + layoutManager.orientation) + mRecyclerView.addItemDecoration(dividerItemDecoration) + + + mSummaryButton.setOnClickListener { + BugReporter.openBugReportScreen(activity!!) + } + + mRunButton.setOnClickListener { + testManager?.retry() + } + startUI() + } + + private fun startUI() { + + mSummaryDescription.text = getString(R.string.settings_troubleshoot_diagnostic_running_status, + 0, 0) + + testManager = NotificationTroubleshootTestManagerFactory.createTestManager(this, mSession) + + testManager?.statusListener = { troubleshootTestManager -> + if (isAdded) { + TransitionManager.beginDelayedTransition(mBottomView) + when (troubleshootTestManager.diagStatus) { + TroubleshootTest.TestStatus.NOT_STARTED -> { + mSummaryDescription.text = "" + mSummaryButton.visibility = View.GONE + mRunButton.visibility = View.VISIBLE + } + TroubleshootTest.TestStatus.RUNNING -> { + //Forces int type because it's breaking lint + val size: Int = troubleshootTestManager.testList.size + val currentTestIndex: Int = troubleshootTestManager.currentTestIndex + mSummaryDescription.text = getString( + R.string.settings_troubleshoot_diagnostic_running_status, + currentTestIndex, + size + ) + mSummaryButton.visibility = View.GONE + mRunButton.visibility = View.GONE + } + TroubleshootTest.TestStatus.FAILED -> { + //check if there are quick fixes + var hasQuickFix = false + testManager?.testList?.let { + for (test in it) { + if (test.status == TroubleshootTest.TestStatus.FAILED && test.quickFix != null) { + hasQuickFix = true + break + } + } + } + if (hasQuickFix) { + mSummaryDescription.text = getString(R.string.settings_troubleshoot_diagnostic_failure_status_with_quickfix) + } else { + mSummaryDescription.text = getString(R.string.settings_troubleshoot_diagnostic_failure_status_no_quickfix) + } + mSummaryButton.visibility = View.VISIBLE + mRunButton.visibility = View.VISIBLE + } + TroubleshootTest.TestStatus.SUCCESS -> { + mSummaryDescription.text = getString(R.string.settings_troubleshoot_diagnostic_success_status) + mSummaryButton.visibility = View.VISIBLE + mRunButton.visibility = View.VISIBLE + } + } + } + + } + mRecyclerView.adapter = testManager?.adapter + testManager?.runDiagnostic() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (resultCode == Activity.RESULT_OK && requestCode == NotificationTroubleshootTestManager.REQ_CODE_FIX) { + testManager?.retry() + return + } + super.onActivityResult(requestCode, resultCode, data) + } + + override fun onDetach() { + testManager?.cancel() + interactionListener = null + super.onDetach() + } + + override fun onResume() { + super.onResume() + (activity as? RiotActivity)?.supportActionBar?.setTitle(R.string.settings_notification_troubleshoot) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is VectorSettingsFragmentInteractionListener) { + interactionListener = context + } + } + + companion object { + // static constructor + fun newInstance(matrixId: String) = VectorSettingsNotificationsTroubleshootFragment() + .withArgs { + // TODO putString(MXCActionBarActivity.EXTRA_MATRIX_ID, matrixId) + } + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsPreferencesFragment.kt b/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsPreferencesFragment.kt new file mode 100755 index 0000000000..24df2bf57b --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/settings/VectorSettingsPreferencesFragment.kt @@ -0,0 +1,2927 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.features.settings + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.SharedPreferences +import android.media.RingtoneManager +import android.net.Uri +import android.os.AsyncTask +import android.os.Bundle +import android.provider.Settings +import android.text.Editable +import android.text.TextUtils +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.* +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.preference.* +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import im.vector.matrix.android.api.session.Session +import im.vector.riotredesign.R +import im.vector.riotredesign.core.extensions.showPassword +import im.vector.riotredesign.core.extensions.withArgs +import im.vector.riotredesign.core.platform.SimpleTextWatcher +import im.vector.riotredesign.core.preference.BingRule +import im.vector.riotredesign.core.preference.ProgressBarPreference +import im.vector.riotredesign.core.preference.UserAvatarPreference +import im.vector.riotredesign.core.preference.VectorPreference +import im.vector.riotredesign.core.utils.* +import im.vector.riotredesign.features.themes.ThemeUtils +import org.koin.android.ext.android.inject +import java.lang.ref.WeakReference +import java.util.* + +class VectorSettingsPreferencesFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener { + + // members + private val mSession by inject() + + // disable some updates if there is + // TODO private val mNetworkListener = IMXNetworkEventListener { refreshDisplay() } + // events listener + // TODO private val mEventsListener = object : MXEventListener() { + // TODO override fun onBingRulesUpdate() { + // TODO refreshPreferences() + // TODO refreshDisplay() + // TODO } + + // TODO override fun onAccountInfoUpdate(myUser: MyUser) { + // TODO // refresh the settings value + // TODO PreferenceManager.getDefaultSharedPreferences(VectorApp.getInstance().applicationContext).edit { + // TODO putString(PreferencesManager.SETTINGS_DISPLAY_NAME_PREFERENCE_KEY, myUser.displayname) + // TODO } + + // TODO refreshDisplay() + // TODO } + // TODO } + + private var mLoadingView: View? = null + + private var mDisplayedEmails = ArrayList() + private var mDisplayedPhoneNumber = ArrayList() + + // TODO private var mMyDeviceInfo: DeviceInfo? = null + + // TODO private var mDisplayedPushers = ArrayList() + + private var interactionListener: VectorSettingsFragmentInteractionListener? = null + + // devices: device IDs and device names + // TODO private var mDevicesNameList: List = ArrayList() + // used to avoid requesting to enter the password for each deletion + private var mAccountPassword: String? = null + + // current publicised group list + private var mPublicisedGroups: MutableSet? = null + + /* ========================================================================================== + * Preferences + * ========================================================================================== */ + + private val mUserSettingsCategory by lazy { + findPreference(PreferencesManager.SETTINGS_USER_SETTINGS_PREFERENCE_KEY) as PreferenceCategory + } + private val mUserAvatarPreference by lazy { + findPreference(PreferencesManager.SETTINGS_PROFILE_PICTURE_PREFERENCE_KEY) as UserAvatarPreference + } + private val mDisplayNamePreference by lazy { + findPreference(PreferencesManager.SETTINGS_DISPLAY_NAME_PREFERENCE_KEY) as EditTextPreference + } + private val mPasswordPreference by lazy { + findPreference(PreferencesManager.SETTINGS_CHANGE_PASSWORD_PREFERENCE_KEY) + } + + // Local contacts + private val mContactSettingsCategory by lazy { + findPreference(PreferencesManager.SETTINGS_CONTACT_PREFERENCE_KEYS) as PreferenceCategory + } + + private val mContactPhonebookCountryPreference by lazy { + findPreference(PreferencesManager.SETTINGS_CONTACTS_PHONEBOOK_COUNTRY_PREFERENCE_KEY) + } + + // Group Flairs + private val mGroupsFlairCategory by lazy { + findPreference(PreferencesManager.SETTINGS_GROUPS_FLAIR_KEY) as PreferenceCategory + } + + // cryptography + private val mCryptographyCategory by lazy { + findPreference(PreferencesManager.SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY) as PreferenceCategory + } + private val mCryptographyCategoryDivider by lazy { + findPreference(PreferencesManager.SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY) + } + // cryptography manage + private val mCryptographyManageCategory by lazy { + findPreference(PreferencesManager.SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY) as PreferenceCategory + } + private val mCryptographyManageCategoryDivider by lazy { + findPreference(PreferencesManager.SETTINGS_CRYPTOGRAPHY_MANAGE_DIVIDER_PREFERENCE_KEY) + } + // displayed pushers + private val mPushersSettingsDivider by lazy { + findPreference(PreferencesManager.SETTINGS_NOTIFICATIONS_TARGET_DIVIDER_PREFERENCE_KEY) + } + private val mPushersSettingsCategory by lazy { + findPreference(PreferencesManager.SETTINGS_NOTIFICATIONS_TARGETS_PREFERENCE_KEY) as PreferenceCategory + } + private val mDevicesListSettingsCategory by lazy { + findPreference(PreferencesManager.SETTINGS_DEVICES_LIST_PREFERENCE_KEY) as PreferenceCategory + } + private val mDevicesListSettingsCategoryDivider by lazy { + findPreference(PreferencesManager.SETTINGS_DEVICES_DIVIDER_PREFERENCE_KEY) + } + // displayed the ignored users list + private val mIgnoredUserSettingsCategoryDivider by lazy { + findPreference(PreferencesManager.SETTINGS_IGNORE_USERS_DIVIDER_PREFERENCE_KEY) + } + private val mIgnoredUserSettingsCategory by lazy { + findPreference(PreferencesManager.SETTINGS_IGNORED_USERS_PREFERENCE_KEY) as PreferenceCategory + } + // background sync category + private val mSyncRequestTimeoutPreference by lazy { + // ? Cause it can be removed + findPreference(PreferencesManager.SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY) as EditTextPreference? + } + private val mSyncRequestDelayPreference by lazy { + // ? Cause it can be removed + findPreference(PreferencesManager.SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY) as EditTextPreference? + } + private val mLabsCategory by lazy { + findPreference(PreferencesManager.SETTINGS_LABS_PREFERENCE_KEY) as PreferenceCategory + } + private val backgroundSyncCategory by lazy { + findPreference(PreferencesManager.SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY) + } + private val backgroundSyncDivider by lazy { + findPreference(PreferencesManager.SETTINGS_BACKGROUND_SYNC_DIVIDER_PREFERENCE_KEY) + } + private val backgroundSyncPreference by lazy { + findPreference(PreferencesManager.SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY) as SwitchPreference + } + private val mUseRiotCallRingtonePreference by lazy { + findPreference(PreferencesManager.SETTINGS_CALL_RINGTONE_USE_RIOT_PREFERENCE_KEY) as SwitchPreference + } + private val mCallRingtonePreference by lazy { + findPreference(PreferencesManager.SETTINGS_CALL_RINGTONE_URI_PREFERENCE_KEY) + } + private val notificationsSettingsCategory by lazy { + findPreference(PreferencesManager.SETTINGS_NOTIFICATIONS_KEY) as PreferenceCategory + } + private val mNotificationPrivacyPreference by lazy { + findPreference(PreferencesManager.SETTINGS_NOTIFICATION_PRIVACY_PREFERENCE_KEY) + } + private val selectedLanguagePreference by lazy { + findPreference(PreferencesManager.SETTINGS_INTERFACE_LANGUAGE_PREFERENCE_KEY) + } + private val textSizePreference by lazy { + findPreference(PreferencesManager.SETTINGS_INTERFACE_TEXT_SIZE_KEY) + } + private val cryptoInfoDeviceNamePreference by lazy { + findPreference(PreferencesManager.SETTINGS_ENCRYPTION_INFORMATION_DEVICE_NAME_PREFERENCE_KEY) as VectorPreference + } + private val cryptoInfoDeviceIdPreference by lazy { + findPreference(PreferencesManager.SETTINGS_ENCRYPTION_INFORMATION_DEVICE_ID_PREFERENCE_KEY) + } + + private val manageBackupPref by lazy { + findPreference(PreferencesManager.SETTINGS_SECURE_MESSAGE_RECOVERY_PREFERENCE_KEY) + } + + private val exportPref by lazy { + findPreference(PreferencesManager.SETTINGS_ENCRYPTION_EXPORT_E2E_ROOM_KEYS_PREFERENCE_KEY) + } + + private val importPref by lazy { + findPreference(PreferencesManager.SETTINGS_ENCRYPTION_IMPORT_E2E_ROOM_KEYS_PREFERENCE_KEY) + } + + private val cryptoInfoTextPreference by lazy { + findPreference(PreferencesManager.SETTINGS_ENCRYPTION_INFORMATION_DEVICE_KEY_PREFERENCE_KEY) + } + // encrypt to unverified devices + private val sendToUnverifiedDevicesPref by lazy { + findPreference(PreferencesManager.SETTINGS_ENCRYPTION_NEVER_SENT_TO_PREFERENCE_KEY) as SwitchPreference + } + + /* ========================================================================================== + * Life cycle + * ========================================================================================== */ + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val appContext = activity?.applicationContext + + // retrieve the arguments + /* + val sessionArg = Matrix.getInstance(appContext).getSession(arguments?.getString(ARG_MATRIX_ID)) + + // sanity checks + if (null == sessionArg || !sessionArg.isAlive) { + activity?.finish() + return + } + + mSession = sessionArg + */ + + // define the layout + addPreferencesFromResource(R.xml.vector_settings_preferences) + + // Avatar + mUserAvatarPreference.let { + it.setSession(mSession) + it.onPreferenceClickListener = Preference.OnPreferenceClickListener { + onUpdateAvatarClick() + false + } + } + + // Display name + mDisplayNamePreference.let { + it.summary = "TODO" // mSession.myUser.displayname + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + onDisplayNameClick(newValue?.let { (it as String).trim() }) + false + } + } + + // Password + mPasswordPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + onPasswordUpdateClick() + false + } + + // Add Email + (findPreference(ADD_EMAIL_PREFERENCE_KEY) as EditTextPreference).let { + // It does not work on XML, do it here + it.icon = activity?.let { + ThemeUtils.tintDrawable(it, + ContextCompat.getDrawable(it, R.drawable.ic_add_black)!!, R.attr.vctr_settings_icon_tint_color) + } + + // Unfortunately, this is not supported in lib v7 + // it.editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS + + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + addEmail((newValue as String).trim()) + false + } + } + + // Add phone number + findPreference(ADD_PHONE_NUMBER_PREFERENCE_KEY).let { + // It does not work on XML, do it here + it.icon = activity?.let { + ThemeUtils.tintDrawable(it, + ContextCompat.getDrawable(it, R.drawable.ic_add_black)!!, R.attr.vctr_settings_icon_tint_color) + } + + it.setOnPreferenceClickListener { + // TODO val intent = PhoneNumberAdditionActivity.getIntent(activity, mSession.credentials.userId) + // startActivityForResult(intent, REQUEST_NEW_PHONE_NUMBER) + true + } + } + + refreshEmailsList() + refreshPhoneNumbersList() + + // Contacts + setContactsPreferences() + + // user interface preferences + setUserInterfacePreferences() + + // Url preview + (findPreference(PreferencesManager.SETTINGS_SHOW_URL_PREVIEW_KEY) as SwitchPreference).let { + /* + TODO + it.isChecked = mSession.isURLPreviewEnabled + + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + if (null != newValue && newValue as Boolean != mSession.isURLPreviewEnabled) { + displayLoadingView() + mSession.setURLPreviewStatus(newValue, object : ApiCallback { + override fun onSuccess(info: Void?) { + it.isChecked = mSession.isURLPreviewEnabled + hideLoadingView() + } + + private fun onError(errorMessage: String) { + activity?.toast(errorMessage) + + onSuccess(null) + } + + override fun onNetworkError(e: Exception) { + onError(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + onError(e.localizedMessage) + } + + override fun onUnexpectedError(e: Exception) { + onError(e.localizedMessage) + } + }) + } + + false + } + */ + } + + // Themes + findPreference(ThemeUtils.APPLICATION_THEME_KEY) + .onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + if (newValue is String) { + // TODO VectorApp.updateApplicationTheme(newValue) + activity?.let { + it.startActivity(it.intent) + it.finish() + } + true + } else { + false + } + } + + // Flair + refreshGroupFlairsList() + + // push rules + + // Notification privacy + mNotificationPrivacyPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + // TODO startActivity(NotificationPrivacyActivity.getIntent(activity)) + true + } + refreshNotificationPrivacy() + + for (preferenceKey in mPrefKeyToBingRuleId.keys) { + val preference = findPreference(preferenceKey) + + if (null != preference) { + if (preference is SwitchPreference) { + preference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValueAsVoid -> + // on some old android APIs, + // the callback is called even if there is no user interaction + // so the value will be checked to ensure there is really no update. + onPushRuleClick(preference.key, newValueAsVoid as Boolean) + true + } + } + } + } + + // background sync tuning settings + // these settings are useless and hidden if the app is registered to the FCM push service + /* + TODO + val pushManager = Matrix.getInstance(appContext).pushManager + if (pushManager.useFcm() && pushManager.hasRegistrationToken()) { + // Hide the section + preferenceScreen.removePreference(backgroundSyncDivider) + preferenceScreen.removePreference(backgroundSyncCategory) + } else { + backgroundSyncPreference.let { + it.isChecked = pushManager.isBackgroundSyncAllowed + + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, aNewValue -> + val newValue = aNewValue as Boolean + + if (newValue != pushManager.isBackgroundSyncAllowed) { + pushManager.isBackgroundSyncAllowed = newValue + } + + displayLoadingView() + + Matrix.getInstance(activity)?.pushManager?.forceSessionsRegistration(object : ApiCallback { + override fun onSuccess(info: Void?) { + hideLoadingView() + } + + override fun onMatrixError(e: MatrixError?) { + hideLoadingView() + } + + override fun onNetworkError(e: java.lang.Exception?) { + hideLoadingView() + } + + override fun onUnexpectedError(e: java.lang.Exception?) { + hideLoadingView() + } + }) + + true + } + } + } + */ + + // Push target + refreshPushersList() + + // Ignore users + refreshIgnoredUsersList() + + // Lab + val useCryptoPref = findPreference(PreferencesManager.SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_PREFERENCE_KEY) as SwitchPreference + val cryptoIsEnabledPref = findPreference(PreferencesManager.SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_IS_ACTIVE_PREFERENCE_KEY) + + + if (mSession.isCryptoEnabled()) { + mLabsCategory.removePreference(useCryptoPref) + + cryptoIsEnabledPref.isEnabled = false + } else { + mLabsCategory.removePreference(cryptoIsEnabledPref) + + useCryptoPref.isChecked = false + + useCryptoPref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValueAsVoid -> + if (TextUtils.isEmpty(mSession.sessionParams.credentials.deviceId)) { + activity?.let { activity -> + AlertDialog.Builder(activity) + .setMessage(R.string.room_settings_labs_end_to_end_warnings) + .setPositiveButton(R.string.logout) { _, _ -> + // TODO CommonActivityUtils.logout(activity) + } + .setNegativeButton(R.string.cancel) { _, _ -> + useCryptoPref.isChecked = false + } + .setOnCancelListener { + useCryptoPref.isChecked = false + } + .show() + } + } else { + val newValue = newValueAsVoid as Boolean + + if (mSession.isCryptoEnabled() != newValue) { + /* TODO + displayLoadingView() + + mSession.enableCrypto(newValue, object : ApiCallback { + private fun refresh() { + activity?.runOnUiThread { + hideLoadingView() + useCryptoPref.isChecked = mSession.isCryptoEnabled + + if (mSession.isCryptoEnabled) { + mLabsCategory.removePreference(useCryptoPref) + mLabsCategory.addPreference(cryptoIsEnabledPref) + } + } + } + + override fun onSuccess(info: Void?) { + useCryptoPref.isEnabled = false + refresh() + } + + override fun onNetworkError(e: Exception) { + useCryptoPref.isChecked = false + } + + override fun onMatrixError(e: MatrixError) { + useCryptoPref.isChecked = false + } + + override fun onUnexpectedError(e: Exception) { + useCryptoPref.isChecked = false + } + }) + */ + } + } + + true + } + } + + // SaveMode Management + findPreference(PreferencesManager.SETTINGS_DATA_SAVE_MODE_PREFERENCE_KEY) + .onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + /* TODO + val sessions = Matrix.getMXSessions(activity) + for (session in sessions) { + session.setUseDataSaveMode(newValue as Boolean) + } + */ + + true + } + + // Device list + refreshDevicesList() + + // Advanced settings + + // user account + findPreference(PreferencesManager.SETTINGS_LOGGED_IN_PREFERENCE_KEY) + .summary = mSession.sessionParams.credentials.userId + + // home server + findPreference(PreferencesManager.SETTINGS_HOME_SERVER_PREFERENCE_KEY) + .summary = mSession.sessionParams.homeServerConnectionConfig.homeServerUri.toString() + + // identity server + findPreference(PreferencesManager.SETTINGS_IDENTITY_SERVER_PREFERENCE_KEY) + .summary = mSession.sessionParams.homeServerConnectionConfig.identityServerUri.toString() + + // Analytics + + // Analytics tracking management + (findPreference(PreferencesManager.SETTINGS_USE_ANALYTICS_KEY) as SwitchPreference).let { + // On if the analytics tracking is activated + it.isChecked = PreferencesManager.useAnalytics(appContext) + + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + PreferencesManager.setUseAnalytics(appContext, newValue as Boolean) + true + } + } + + // Rageshake Management + (findPreference(PreferencesManager.SETTINGS_USE_RAGE_SHAKE_KEY) as SwitchPreference).let { + it.isChecked = PreferencesManager.useRageshake(appContext) + + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + PreferencesManager.setUseRageshake(appContext, newValue as Boolean) + true + } + } + + // preference to start the App info screen, to facilitate App permissions access + findPreference(APP_INFO_LINK_PREFERENCE_KEY) + .onPreferenceClickListener = Preference.OnPreferenceClickListener { + + activity?.let { + val intent = Intent().apply { + action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + val uri = appContext?.let { Uri.fromParts("package", it.packageName, null) } + + data = uri + } + it.applicationContext.startActivity(intent) + } + + true + } + + // application version + (findPreference(PreferencesManager.SETTINGS_VERSION_PREFERENCE_KEY)).let { + it.summary = "TODO" // VectorUtils.getApplicationVersion(appContext) + + it.setOnPreferenceClickListener { + appContext?.let { + copyToClipboard(it, "TODO") //VectorUtils.getApplicationVersion(it)) + } + true + } + } + + // olm version + findPreference(PreferencesManager.SETTINGS_OLM_VERSION_PREFERENCE_KEY) + // TODO .summary = mSession.getCryptoVersion(appContext, false) + + // copyright + findPreference(PreferencesManager.SETTINGS_COPYRIGHT_PREFERENCE_KEY) + .onPreferenceClickListener = Preference.OnPreferenceClickListener { + // TODO VectorUtils.displayAppCopyright() + false + } + + // terms & conditions + findPreference(PreferencesManager.SETTINGS_APP_TERM_CONDITIONS_PREFERENCE_KEY) + .onPreferenceClickListener = Preference.OnPreferenceClickListener { + // TODO VectorUtils.displayAppTac() + false + } + + // privacy policy + findPreference(PreferencesManager.SETTINGS_PRIVACY_POLICY_PREFERENCE_KEY) + .onPreferenceClickListener = Preference.OnPreferenceClickListener { + // TODO VectorUtils.displayAppPrivacyPolicy() + false + } + + // third party notice + findPreference(PreferencesManager.SETTINGS_THIRD_PARTY_NOTICES_PREFERENCE_KEY) + .onPreferenceClickListener = Preference.OnPreferenceClickListener { + // TODO VectorUtils.displayThirdPartyLicenses() + false + } + + // update keep medias period + findPreference(PreferencesManager.SETTINGS_MEDIA_SAVING_PERIOD_KEY).let { + it.summary = PreferencesManager.getSelectedMediasSavingPeriodString(activity) + + it.onPreferenceClickListener = Preference.OnPreferenceClickListener { + context?.let { context: Context -> + AlertDialog.Builder(context) + .setSingleChoiceItems(R.array.media_saving_choice, + PreferencesManager.getSelectedMediasSavingPeriod(activity)) { d, n -> + PreferencesManager.setSelectedMediasSavingPeriod(activity, n) + d.cancel() + + it.summary = PreferencesManager.getSelectedMediasSavingPeriodString(activity) + } + .show() + } + + false + } + } + + // clear medias cache + findPreference(PreferencesManager.SETTINGS_CLEAR_MEDIA_CACHE_PREFERENCE_KEY).let { + /* + TODO + MXMediaCache.getCachesSize(activity, object : SimpleApiCallback() { + override fun onSuccess(size: Long) { + if (null != activity) { + it.summary = android.text.format.Formatter.formatFileSize(activity, size) + } + } + }) + + it.onPreferenceClickListener = Preference.OnPreferenceClickListener { + displayLoadingView() + + val task = ClearMediaCacheAsyncTask( + backgroundTask = { + mSession.mediaCache.clear() + activity?.let { it -> Glide.get(it).clearDiskCache() } + }, + onCompleteTask = { + hideLoadingView() + + MXMediaCache.getCachesSize(activity, object : SimpleApiCallback() { + override fun onSuccess(size: Long) { + it.summary = Formatter.formatFileSize(activity, size) + } + }) + } + ) + + try { + task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } catch (e: Exception) { + Timber.e(e, "## mSession.getMediaCache().clear() failed " + e.message) + task.cancel(true) + hideLoadingView() + } + + false + } + */ + } + + // Incoming call sounds + mUseRiotCallRingtonePreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + activity?.let { setUseRiotDefaultRingtone(it, mUseRiotCallRingtonePreference.isChecked) } + false + } + + mCallRingtonePreference.let { + activity?.let { activity -> it.summary = getCallRingtoneName(activity) } + it.onPreferenceClickListener = Preference.OnPreferenceClickListener { + displayRingtonePicker() + false + } + } + + // clear cache + findPreference(PreferencesManager.SETTINGS_CLEAR_CACHE_PREFERENCE_KEY).let { + /* + TODO + MXSession.getApplicationSizeCaches(activity, object : SimpleApiCallback() { + override fun onSuccess(size: Long) { + if (null != activity) { + it.summary = android.text.format.Formatter.formatFileSize(activity, size) + } + } + }) + */ + + it.onPreferenceClickListener = Preference.OnPreferenceClickListener { + displayLoadingView() + // TODO Matrix.getInstance(appContext).reloadSessions(appContext) + false + } + } + + // Deactivate account section + + // deactivate account + findPreference(PreferencesManager.SETTINGS_DEACTIVATE_ACCOUNT_KEY) + .onPreferenceClickListener = Preference.OnPreferenceClickListener { + activity?.let { + // TODO startActivity(DeactivateAccountActivity.getIntent(it)) + } + + false + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = super.onCreateView(inflater, container, savedInstanceState) + + view?.apply { + val listView = findViewById(android.R.id.list) + listView?.setPadding(0, 0, 0, 0) + } + + return view + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { + // if the user toggles the contacts book permission + /* TODO + if (TextUtils.equals(key, ContactsManager.CONTACTS_BOOK_ACCESS_KEY)) { + // reset the current snapshot + ContactsManager.getInstance().clearSnapshot() + } + */ + } + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is VectorSettingsFragmentInteractionListener) { + interactionListener = context + } + } + + override fun onDetach() { + interactionListener = null + super.onDetach() + } + + override fun onResume() { + super.onResume() + + // find the view from parent activity + // TODO mLoadingView = activity?.findViewById(R.id.vector_settings_spinner_views) + + /* TODO + if (mSession.isAlive) { + val context = activity?.applicationContext + + mSession.dataHandler.addListener(mEventsListener) + + Matrix.getInstance(context)?.addNetworkEventListener(mNetworkListener) + + mSession.myUser.refreshThirdPartyIdentifiers(object : SimpleApiCallback() { + override fun onSuccess(info: Void?) { + // ensure that the activity still exists + // and the result is called in the right thread + activity?.runOnUiThread { + refreshEmailsList() + refreshPhoneNumbersList() + } + } + }) + + Matrix.getInstance(context)?.pushManager?.refreshPushersList(Matrix.getInstance(context)?.sessions, object : SimpleApiCallback(activity) { + override fun onSuccess(info: Void?) { + refreshPushersList() + } + }) + + PreferenceManager.getDefaultSharedPreferences(context).registerOnSharedPreferenceChangeListener(this) + + // refresh anything else + refreshPreferences() + refreshNotificationPrivacy() + refreshDisplay() + refreshBackgroundSyncPrefs() + } + */ + + interactionListener?.requestedKeyToHighlight()?.let { key -> + interactionListener?.requestHighlightPreferenceKeyOnResume(null) + val preference = findPreference(key) + (preference as? VectorPreference)?.isHighlighted = true + } + } + + override fun onPause() { + super.onPause() + + val context = activity?.applicationContext + + /* TODO + if (mSession.isAlive) { + mSession.dataHandler.removeListener(mEventsListener) + Matrix.getInstance(context)?.removeNetworkEventListener(mNetworkListener) + } + */ + + PreferenceManager.getDefaultSharedPreferences(context).unregisterOnSharedPreferenceChangeListener(this) + } + + // TODO Test + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + /* TODO + if (allGranted(grantResults)) { + if (requestCode == PERMISSION_REQUEST_CODE_LAUNCH_CAMERA) { + changeAvatar() + } else if (requestCode == PERMISSION_REQUEST_CODE_EXPORT_KEYS) { + exportKeys() + } + } + */ + } + + //============================================================================================================== + // Display methods + //============================================================================================================== + + /** + * Display the loading view. + */ + private fun displayLoadingView() { + // search the loading view from the upper view + if (null == mLoadingView) { + var parent = view + + while (parent != null && mLoadingView == null) { + // TODO mLoadingView = parent.findViewById(R.id.vector_settings_spinner_views) + parent = parent.parent as View + } + } else { + mLoadingView?.visibility = View.VISIBLE + } + } + + /** + * Hide the loading view. + */ + private fun hideLoadingView() { + mLoadingView?.visibility = View.GONE + } + + /** + * Hide the loading view and refresh the preferences. + * + * @param refresh true to refresh the display + */ + private fun hideLoadingView(refresh: Boolean) { + mLoadingView?.visibility = View.GONE + + if (refresh) { + refreshDisplay() + } + } + + /** + * Refresh the preferences. + */ + private fun refreshDisplay() { + /* TODO + // If Matrix instance is null, then connection can't be there + val isConnected = Matrix.getInstance(activity)?.isConnected ?: false + val appContext = activity?.applicationContext + + val preferenceManager = preferenceManager + + // refresh the avatar + mUserAvatarPreference.refreshAvatar() + mUserAvatarPreference.isEnabled = isConnected + + // refresh the display name + mDisplayNamePreference.summary = mSession.myUser.displayname + mDisplayNamePreference.text = mSession.myUser.displayname + mDisplayNamePreference.isEnabled = isConnected + + // change password + mPasswordPreference.isEnabled = isConnected + + // update the push rules + val preferences = PreferenceManager.getDefaultSharedPreferences(appContext) + + val rules = mSession.dataHandler.pushRules() + + val pushManager = Matrix.getInstance(appContext)?.pushManager + + for (preferenceKey in mPrefKeyToBingRuleId.keys) { + val preference = preferenceManager.findPreference(preferenceKey) + + if (null != preference) { + + if (preference is SwitchPreference) { + when (preferenceKey) { + PreferencesManager.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY -> + preference.isChecked = pushManager?.areDeviceNotificationsAllowed() ?: true + + PreferencesManager.SETTINGS_TURN_SCREEN_ON_PREFERENCE_KEY -> { + preference.isChecked = pushManager?.isScreenTurnedOn ?: false + preference.isEnabled = pushManager?.areDeviceNotificationsAllowed() ?: true + } + else -> { + preference.isEnabled = null != rules && isConnected + preference.isChecked = preferences.getBoolean(preferenceKey, false) + } + } + } + } + } + + // If notifications are disabled for the current user account or for the current user device + // The others notifications settings have to be disable too + val areNotificationAllowed = rules?.findDefaultRule(BingRule.RULE_ID_DISABLE_ALL)?.isEnabled == true + + mNotificationPrivacyPreference.isEnabled = !areNotificationAllowed + && (pushManager?.areDeviceNotificationsAllowed() ?: true) && pushManager?.useFcm() ?: true + */ + } + + //============================================================================================================== + // Update items methods + //============================================================================================================== + + /** + * Update the password. + */ + private fun onPasswordUpdateClick() { + activity?.let { activity -> + val view: ViewGroup = activity.layoutInflater.inflate(R.layout.dialog_change_password, null) as ViewGroup + + val showPassword: ImageView = view.findViewById(R.id.change_password_show_passwords) + val oldPasswordTil: TextInputLayout = view.findViewById(R.id.change_password_old_pwd_til) + val oldPasswordText: TextInputEditText = view.findViewById(R.id.change_password_old_pwd_text) + val newPasswordText: TextInputEditText = view.findViewById(R.id.change_password_new_pwd_text) + val confirmNewPasswordTil: TextInputLayout = view.findViewById(R.id.change_password_confirm_new_pwd_til) + val confirmNewPasswordText: TextInputEditText = view.findViewById(R.id.change_password_confirm_new_pwd_text) + val changePasswordLoader: View = view.findViewById(R.id.change_password_loader) + + var passwordShown = false + + showPassword.setOnClickListener(object : View.OnClickListener { + override fun onClick(v: View?) { + passwordShown = !passwordShown + + oldPasswordText.showPassword(passwordShown) + newPasswordText.showPassword(passwordShown) + confirmNewPasswordText.showPassword(passwordShown) + + showPassword.setImageResource(if (passwordShown) R.drawable.ic_eye_closed_black else R.drawable.ic_eye_black) + } + }) + + val dialog = AlertDialog.Builder(activity) + .setView(view) + .setPositiveButton(R.string.settings_change_password_submit, null) + .setNegativeButton(R.string.cancel, null) + .setOnDismissListener { + val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.applicationWindowToken, 0) + } + .create() + + dialog.setOnShowListener { + val updateButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + updateButton.isEnabled = false + + fun updateUi() { + val oldPwd = oldPasswordText.text.toString().trim() + val newPwd = newPasswordText.text.toString().trim() + val newConfirmPwd = confirmNewPasswordText.text.toString().trim() + + updateButton.isEnabled = oldPwd.isNotEmpty() && newPwd.isNotEmpty() && TextUtils.equals(newPwd, newConfirmPwd) + + if (newPwd.isNotEmpty() && newConfirmPwd.isNotEmpty() && !TextUtils.equals(newPwd, newConfirmPwd)) { + confirmNewPasswordTil.error = getString(R.string.passwords_do_not_match) + } + } + + oldPasswordText.addTextChangedListener(object : SimpleTextWatcher() { + override fun afterTextChanged(s: Editable) { + oldPasswordTil.error = null + updateUi() + } + }) + + newPasswordText.addTextChangedListener(object : SimpleTextWatcher() { + override fun afterTextChanged(s: Editable) { + confirmNewPasswordTil.error = null + updateUi() + } + }) + + confirmNewPasswordText.addTextChangedListener(object : SimpleTextWatcher() { + override fun afterTextChanged(s: Editable) { + confirmNewPasswordTil.error = null + updateUi() + } + }) + + fun showPasswordLoadingView(toShow: Boolean) { + if (toShow) { + showPassword.isEnabled = false + oldPasswordText.isEnabled = false + newPasswordText.isEnabled = false + confirmNewPasswordText.isEnabled = false + changePasswordLoader.isVisible = true + updateButton.isEnabled = false + } else { + showPassword.isEnabled = true + oldPasswordText.isEnabled = true + newPasswordText.isEnabled = true + confirmNewPasswordText.isEnabled = true + changePasswordLoader.isVisible = false + updateButton.isEnabled = true + } + } + + updateButton.setOnClickListener { + if (passwordShown) { + // Hide passwords during processing + showPassword.performClick() + } + + val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.applicationWindowToken, 0) + + val oldPwd = oldPasswordText.text.toString().trim() + val newPwd = newPasswordText.text.toString().trim() + + /* TODO + showPasswordLoadingView(true) + + mSession.updatePassword(oldPwd, newPwd, object : ApiCallback { + private fun onDone(@StringRes textResId: Int) { + showPasswordLoadingView(false) + + if (textResId == R.string.settings_fail_to_update_password_invalid_current_password) { + oldPasswordTil.error = getString(textResId) + } else { + dialog.dismiss() + activity.toast(textResId, Toast.LENGTH_LONG) + } + } + + override fun onSuccess(info: Void?) { + onDone(R.string.settings_password_updated) + } + + override fun onNetworkError(e: Exception) { + onDone(R.string.settings_fail_to_update_password) + } + + override fun onMatrixError(e: MatrixError) { + if (e.error == "Invalid password") { + onDone(R.string.settings_fail_to_update_password_invalid_current_password) + } else { + dialog.dismiss() + onDone(R.string.settings_fail_to_update_password) + } + } + + override fun onUnexpectedError(e: Exception) { + onDone(R.string.settings_fail_to_update_password) + } + }) + */ + } + } + dialog.show() + } + } + + /** + * Update a push rule. + */ + + private fun onPushRuleClick(preferenceKey: String, newValue: Boolean) { + /* TODO + val matrixInstance = Matrix.getInstance(context) + val pushManager = matrixInstance.pushManager + + Timber.d("onPushRuleClick $preferenceKey : set to $newValue") + + when (preferenceKey) { + + PreferencesManager.SETTINGS_TURN_SCREEN_ON_PREFERENCE_KEY -> { + if (pushManager.isScreenTurnedOn != newValue) { + pushManager.isScreenTurnedOn = newValue + } + } + + PreferencesManager.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY -> { + val isConnected = matrixInstance.isConnected + val isAllowed = pushManager.areDeviceNotificationsAllowed() + + // avoid useless update + if (isAllowed == newValue) { + return + } + + pushManager.setDeviceNotificationsAllowed(!isAllowed) + + // when using FCM + // need to register on servers + if (isConnected && pushManager.useFcm() && (pushManager.isServerRegistered || pushManager.isServerUnRegistered)) { + val listener = object : ApiCallback { + + private fun onDone() { + activity?.runOnUiThread { + hideLoadingView(true) + refreshPushersList() + } + } + + override fun onSuccess(info: Void?) { + onDone() + } + + override fun onMatrixError(e: MatrixError?) { + // Set again the previous state + pushManager.setDeviceNotificationsAllowed(isAllowed) + onDone() + } + + override fun onNetworkError(e: java.lang.Exception?) { + // Set again the previous state + pushManager.setDeviceNotificationsAllowed(isAllowed) + onDone() + } + + override fun onUnexpectedError(e: java.lang.Exception?) { + // Set again the previous state + pushManager.setDeviceNotificationsAllowed(isAllowed) + onDone() + } + } + + displayLoadingView() + if (pushManager.isServerRegistered) { + pushManager.unregister(listener) + } else { + pushManager.register(listener) + } + } + } + + // check if there is an update + + // on some old android APIs, + // the callback is called even if there is no user interaction + // so the value will be checked to ensure there is really no update. + else -> { + + val ruleId = mPrefKeyToBingRuleId[preferenceKey] + val rule = mSession.dataHandler.pushRules()?.findDefaultRule(ruleId) + + // check if there is an update + var curValue = null != rule && rule.isEnabled + + if (TextUtils.equals(ruleId, BingRule.RULE_ID_DISABLE_ALL) || TextUtils.equals(ruleId, BingRule.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS)) { + curValue = !curValue + } + + // on some old android APIs, + // the callback is called even if there is no user interaction + // so the value will be checked to ensure there is really no update. + if (newValue == curValue) { + return + } + + if (null != rule) { + displayLoadingView() + mSession.dataHandler.bingRulesManager.updateEnableRuleStatus(rule, !rule.isEnabled, object : BingRulesManager.onBingRuleUpdateListener { + private fun onDone() { + refreshDisplay() + hideLoadingView() + } + + override fun onBingRuleUpdateSuccess() { + onDone() + } + + override fun onBingRuleUpdateFailure(errorMessage: String) { + activity?.toast(errorMessage) + onDone() + } + }) + } + } + } + */ + } + + /** + * Update the displayname. + */ + private fun onDisplayNameClick(value: String?) { + /* TODO + if (!TextUtils.equals(mSession.myUser.displayname, value)) { + displayLoadingView() + + mSession.myUser.updateDisplayName(value, object : ApiCallback { + override fun onSuccess(info: Void?) { + // refresh the settings value + PreferenceManager.getDefaultSharedPreferences(activity).edit { + putString(PreferencesManager.SETTINGS_DISPLAY_NAME_PREFERENCE_KEY, value) + } + + onCommonDone(null) + + refreshDisplay() + } + + override fun onNetworkError(e: Exception) { + onCommonDone(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + if (MatrixError.M_CONSENT_NOT_GIVEN == e.errcode) { + activity?.runOnUiThread { + hideLoadingView() + (activity as VectorAppCompatActivity).consentNotGivenHelper.displayDialog(e) + } + } else { + onCommonDone(e.localizedMessage) + } + } + + override fun onUnexpectedError(e: Exception) { + onCommonDone(e.localizedMessage) + } + }) + } + */ + } + + private fun displayRingtonePicker() { + val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply { + putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, getString(R.string.settings_call_ringtone_dialog_title)) + putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false) + putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) + putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE) + activity?.let { putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, getCallRingtoneUri(it)) } + } + startActivityForResult(intent, REQUEST_CALL_RINGTONE) + } + + /** + * Update the avatar. + */ + private fun onUpdateAvatarClick() { + /* TODO + if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) { + changeAvatar() + } + */ + } + + private fun changeAvatar() { + /* TODO + val intent = Intent(activity, VectorMediaPickerActivity::class.java) + intent.putExtra(VectorMediaPickerActivity.EXTRA_AVATAR_MODE, true) + startActivityForResult(intent, VectorUtils.TAKE_IMAGE) + */ + } + + /** + * Refresh the notification privacy setting + */ + private fun refreshNotificationPrivacy() { + /* TODO + val pushManager = Matrix.getInstance(activity).pushManager + + // this setting apply only with FCM for the moment + if (pushManager.useFcm()) { + val notificationPrivacyString = NotificationPrivacyActivity.getNotificationPrivacyString(activity, + pushManager.notificationPrivacy) + mNotificationPrivacyPreference.summary = notificationPrivacyString + } else { + notificationsSettingsCategory.removePreference(mNotificationPrivacyPreference) + } + */ + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (resultCode == Activity.RESULT_OK) { + when (requestCode) { + REQUEST_CALL_RINGTONE -> { + val callRingtoneUri: Uri? = data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) + val thisActivity = activity + if (callRingtoneUri != null && thisActivity != null) { + setCallRingtoneUri(thisActivity, callRingtoneUri) + mCallRingtonePreference.summary = getCallRingtoneName(thisActivity) + } + } + REQUEST_E2E_FILE_REQUEST_CODE -> importKeys(data) + REQUEST_NEW_PHONE_NUMBER -> refreshPhoneNumbersList() + REQUEST_PHONEBOOK_COUNTRY -> onPhonebookCountryUpdate(data) + REQUEST_LOCALE -> { + activity?.let { + startActivity(it.intent) + it.finish() + } + } + /* TODO + VectorUtils.TAKE_IMAGE -> { + val thumbnailUri = VectorUtils.getThumbnailUriFromIntent(activity, data, mSession.mediaCache) + + if (null != thumbnailUri) { + displayLoadingView() + + val resource = ResourceUtils.openResource(activity, thumbnailUri, null) + + if (null != resource) { + mSession.mediaCache.uploadContent(resource.mContentStream, null, resource.mMimeType, null, object : MXMediaUploadListener() { + + override fun onUploadError(uploadId: String?, serverResponseCode: Int, serverErrorMessage: String?) { + activity?.runOnUiThread { onCommonDone(serverResponseCode.toString() + " : " + serverErrorMessage) } + } + + override fun onUploadComplete(uploadId: String?, contentUri: String?) { + activity?.runOnUiThread { + mSession.myUser.updateAvatarUrl(contentUri, object : ApiCallback { + override fun onSuccess(info: Void?) { + onCommonDone(null) + refreshDisplay() + } + + override fun onNetworkError(e: Exception) { + onCommonDone(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + if (MatrixError.M_CONSENT_NOT_GIVEN == e.errcode) { + activity?.runOnUiThread { + hideLoadingView() + (activity as VectorAppCompatActivity).consentNotGivenHelper.displayDialog(e) + } + } else { + onCommonDone(e.localizedMessage) + } + } + + override fun onUnexpectedError(e: Exception) { + onCommonDone(e.localizedMessage) + } + }) + } + } + }) + } + } + } + */ + } + } + } + + /** + * Refresh the known information about the account + */ + private fun refreshPreferences() { + PreferenceManager.getDefaultSharedPreferences(activity).edit { + putString(PreferencesManager.SETTINGS_DISPLAY_NAME_PREFERENCE_KEY, "TODO") //mSession.myUser.displayname) + putString(PreferencesManager.SETTINGS_VERSION_PREFERENCE_KEY, "TODO") // VectorUtils.getApplicationVersion(activity)) + + /* TODO + mSession.dataHandler.pushRules()?.let { + for (preferenceKey in mPrefKeyToBingRuleId.keys) { + val preference = findPreference(preferenceKey) + + if (null != preference && preference is SwitchPreference) { + val ruleId = mPrefKeyToBingRuleId[preferenceKey] + + val rule = it.findDefaultRule(ruleId) + var isEnabled = null != rule && rule.isEnabled + + if (TextUtils.equals(ruleId, BingRule.RULE_ID_DISABLE_ALL) || TextUtils.equals(ruleId, BingRule.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS)) { + isEnabled = !isEnabled + } else if (isEnabled) { + val actions = rule?.actions + + // no action -> noting will be done + if (null == actions || actions.isEmpty()) { + isEnabled = false + } else if (1 == actions.size) { + try { + isEnabled = !TextUtils.equals(actions[0] as String, BingRule.ACTION_DONT_NOTIFY) + } catch (e: Exception) { + Timber.e(e, "## refreshPreferences failed " + e.message) + } + + } + }// check if the rule is only defined by don't notify + + putBoolean(preferenceKey, isEnabled) + } + } + } + */ + } + } + + /** + * Display a dialog which asks confirmation for the deletion of a 3pid + * + * @param pid the 3pid to delete + * @param preferenceSummary the displayed 3pid + */ + private fun displayDelete3PIDConfirmationDialog(/* TODO pid: ThirdPartyIdentifier,*/ preferenceSummary: CharSequence) { + val mediumFriendlyName = "TODO" // ThreePid.getMediumFriendlyName(pid.medium, activity).toLowerCase(VectorLocale.applicationLocale) + val dialogMessage = getString(R.string.settings_delete_threepid_confirmation, mediumFriendlyName, preferenceSummary) + + activity?.let { + AlertDialog.Builder(it) + .setTitle(R.string.dialog_title_confirmation) + .setMessage(dialogMessage) + .setPositiveButton(R.string.remove) { _, _ -> + /* TODO + displayLoadingView() + + mSession.myUser.delete3Pid(pid, object : ApiCallback { + override fun onSuccess(info: Void?) { + when (pid.medium) { + ThreePid.MEDIUM_EMAIL -> refreshEmailsList() + ThreePid.MEDIUM_MSISDN -> refreshPhoneNumbersList() + } + onCommonDone(null) + } + + override fun onNetworkError(e: Exception) { + onCommonDone(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + onCommonDone(e.localizedMessage) + } + + override fun onUnexpectedError(e: Exception) { + onCommonDone(e.localizedMessage) + } + }) + */ + } + .setNegativeButton(R.string.cancel, null) + .show() + } + } + + //============================================================================================================== + // ignored users list management + //============================================================================================================== + + /** + * Refresh the ignored users list + */ + private fun refreshIgnoredUsersList() { + val ignoredUsersList = mutableListOf() // TODO mSession.dataHandler.ignoredUserIds + + ignoredUsersList.sortWith(Comparator { u1, u2 -> + u1.toLowerCase(VectorLocale.applicationLocale).compareTo(u2.toLowerCase(VectorLocale.applicationLocale)) + }) + + val preferenceScreen = preferenceScreen + + preferenceScreen.removePreference(mIgnoredUserSettingsCategory) + preferenceScreen.removePreference(mIgnoredUserSettingsCategoryDivider) + mIgnoredUserSettingsCategory.removeAll() + + if (ignoredUsersList.size > 0) { + preferenceScreen.addPreference(mIgnoredUserSettingsCategoryDivider) + preferenceScreen.addPreference(mIgnoredUserSettingsCategory) + + for (userId in ignoredUsersList) { + val preference = Preference(activity) + + preference.title = userId + preference.key = IGNORED_USER_KEY_BASE + userId + + preference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + activity?.let { + AlertDialog.Builder(it) + .setMessage(getString(R.string.settings_unignore_user, userId)) + .setPositiveButton(R.string.yes) { _, _ -> + displayLoadingView() + + val idsList = ArrayList() + idsList.add(userId) + + /* TODO + mSession.unIgnoreUsers(idsList, object : ApiCallback { + override fun onSuccess(info: Void?) { + onCommonDone(null) + } + + override fun onNetworkError(e: Exception) { + onCommonDone(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + onCommonDone(e.localizedMessage) + } + + override fun onUnexpectedError(e: Exception) { + onCommonDone(e.localizedMessage) + } + }) + */ + } + .setNegativeButton(R.string.no, null) + .show() + } + + false + } + + mIgnoredUserSettingsCategory.addPreference(preference) + } + } + } + + //============================================================================================================== + // pushers list management + //============================================================================================================== + + /** + * Refresh the pushers list + */ + private fun refreshPushersList() { + activity?.let { activity -> + /* TODO + val pushManager = Matrix.getInstance(activity).pushManager + val pushersList = ArrayList(pushManager.mPushersList) + + if (pushersList.isEmpty()) { + preferenceScreen.removePreference(mPushersSettingsCategory) + preferenceScreen.removePreference(mPushersSettingsDivider) + return + } + + // check first if there is an update + var isNewList = true + if (pushersList.size == mDisplayedPushers.size) { + isNewList = !mDisplayedPushers.containsAll(pushersList) + } + + if (isNewList) { + // remove the displayed one + mPushersSettingsCategory.removeAll() + + // add new emails list + mDisplayedPushers = pushersList + + var index = 0 + + for (pusher in mDisplayedPushers) { + if (null != pusher.lang) { + val isThisDeviceTarget = TextUtils.equals(pushManager.currentRegistrationToken, pusher.pushkey) + + val preference = VectorPreference(activity).apply { + mTypeface = if (isThisDeviceTarget) Typeface.BOLD else Typeface.NORMAL + } + preference.title = pusher.deviceDisplayName + preference.summary = pusher.appDisplayName + preference.key = PUSHER_PREFERENCE_KEY_BASE + index + index++ + mPushersSettingsCategory.addPreference(preference) + + // the user cannot remove the self device target + if (!isThisDeviceTarget) { + preference.onPreferenceLongClickListener = object : VectorPreference.OnPreferenceLongClickListener { + override fun onPreferenceLongClick(preference: Preference): Boolean { + AlertDialog.Builder(activity) + .setTitle(R.string.dialog_title_confirmation) + .setMessage(R.string.settings_delete_notification_targets_confirmation) + .setPositiveButton(R.string.remove) + { _, _ -> + displayLoadingView() + pushManager.unregister(mSession, pusher, object : ApiCallback { + override fun onSuccess(info: Void?) { + refreshPushersList() + onCommonDone(null) + } + + override fun onNetworkError(e: Exception) { + onCommonDone(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + onCommonDone(e.localizedMessage) + } + + override fun onUnexpectedError(e: Exception) { + onCommonDone(e.localizedMessage) + } + }) + } + .setNegativeButton(R.string.cancel, null) + .show() + return true + } + } + } + } + } + } + */ + } + } + + //============================================================================================================== + // Email management + //============================================================================================================== + + /** + * Refresh the emails list + */ + private fun refreshEmailsList() { + val currentEmail3PID = emptyList() // TODO ArrayList(mSession.myUser.getlinkedEmails()) + + val newEmailsList = ArrayList() + for (identifier in currentEmail3PID) { + // TODO newEmailsList.add(identifier.address) + } + + // check first if there is an update + var isNewList = true + if (newEmailsList.size == mDisplayedEmails.size) { + isNewList = !mDisplayedEmails.containsAll(newEmailsList) + } + + if (isNewList) { + // remove the displayed one + run { + var index = 0 + while (true) { + val preference = mUserSettingsCategory.findPreference(EMAIL_PREFERENCE_KEY_BASE + index) + + if (null != preference) { + mUserSettingsCategory.removePreference(preference) + } else { + break + } + index++ + } + } + + // add new emails list + mDisplayedEmails = newEmailsList + + val addEmailBtn = mUserSettingsCategory.findPreference(ADD_EMAIL_PREFERENCE_KEY) + ?: return + + var order = addEmailBtn.order + + for ((index, email3PID) in currentEmail3PID.withIndex()) { + val preference = VectorPreference(activity!!) + + preference.title = getString(R.string.settings_email_address) + preference.summary = "TODO" // email3PID.address + preference.key = EMAIL_PREFERENCE_KEY_BASE + index + preference.order = order + + preference.onPreferenceClickListener = Preference.OnPreferenceClickListener { pref -> + displayDelete3PIDConfirmationDialog(/* TODO email3PID, */ pref.summary) + true + } + + preference.onPreferenceLongClickListener = object : VectorPreference.OnPreferenceLongClickListener { + override fun onPreferenceLongClick(preference: Preference): Boolean { + activity?.let { copyToClipboard(it, "TODO") } //email3PID.address) } + return true + } + } + + mUserSettingsCategory.addPreference(preference) + + order++ + } + + addEmailBtn.order = order + } + } + + /** + * A request has been processed. + * Display a toast if there is a an error message + * + * @param errorMessage the error message + */ + private fun onCommonDone(errorMessage: String?) { + activity?.runOnUiThread { + if (!TextUtils.isEmpty(errorMessage) && errorMessage != null) { + activity?.toast(errorMessage!!) + } + hideLoadingView() + } + } + + /** + * Attempt to add a new email to the account + * + * @param email the email to add. + */ + private fun addEmail(email: String) { + // check first if the email syntax is valid + // if email is null , then also its invalid email + if (TextUtils.isEmpty(email) || !android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) { + activity?.toast(R.string.auth_invalid_email) + return + } + + // check first if the email syntax is valid + if (mDisplayedEmails.indexOf(email) >= 0) { + activity?.toast(R.string.auth_email_already_defined) + return + } + + /* TODO + val pid = ThreePid(email, ThreePid.MEDIUM_EMAIL) + + displayLoadingView() + + mSession.myUser.requestEmailValidationToken(pid, object : ApiCallback { + override fun onSuccess(info: Void?) { + activity?.runOnUiThread { showEmailValidationDialog(pid) } + } + + override fun onNetworkError(e: Exception) { + onCommonDone(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + if (TextUtils.equals(MatrixError.THREEPID_IN_USE, e.errcode)) { + onCommonDone(getString(R.string.account_email_already_used_error)) + } else { + onCommonDone(e.localizedMessage) + } + } + + override fun onUnexpectedError(e: Exception) { + onCommonDone(e.localizedMessage) + } + }) + */ + } + + /** + * Show an email validation dialog to warn the user tho valid his email link. + * + * @param pid the used pid. + */ + /* TODO + private fun showEmailValidationDialog(pid: ThreePid) { + activity?.let { + AlertDialog.Builder(it) + .setTitle(R.string.account_email_validation_title) + .setMessage(R.string.account_email_validation_message) + .setPositiveButton(R.string._continue) { _, _ -> + mSession.myUser.add3Pid(pid, true, object : ApiCallback { + override fun onSuccess(info: Void?) { + it.runOnUiThread { + hideLoadingView() + refreshEmailsList() + } + } + + override fun onNetworkError(e: Exception) { + onCommonDone(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + if (TextUtils.equals(e.errcode, MatrixError.THREEPID_AUTH_FAILED)) { + it.runOnUiThread { + hideLoadingView() + it.toast(R.string.account_email_validation_error) + } + } else { + onCommonDone(e.localizedMessage) + } + } + + override fun onUnexpectedError(e: Exception) { + onCommonDone(e.localizedMessage) + } + }) + } + .setNegativeButton(R.string.cancel) { _, _ -> + hideLoadingView() + } + .show() + } + } + */ + + //============================================================================================================== + // Phone number management + //============================================================================================================== + + /** + * Refresh phone number list + */ + private fun refreshPhoneNumbersList() { + /* TODO + val currentPhoneNumber3PID = ArrayList(mSession.myUser.getlinkedPhoneNumbers()) + + val phoneNumberList = ArrayList() + for (identifier in currentPhoneNumber3PID) { + phoneNumberList.add(identifier.address) + } + + // check first if there is an update + var isNewList = true + if (phoneNumberList.size == mDisplayedPhoneNumber.size) { + isNewList = !mDisplayedPhoneNumber.containsAll(phoneNumberList) + } + + if (isNewList) { + // remove the displayed one + run { + var index = 0 + while (true) { + val preference = mUserSettingsCategory.findPreference(PHONE_NUMBER_PREFERENCE_KEY_BASE + index) + + if (null != preference) { + mUserSettingsCategory.removePreference(preference) + } else { + break + } + index++ + } + } + + // add new phone number list + mDisplayedPhoneNumber = phoneNumberList + + val addPhoneBtn = mUserSettingsCategory.findPreference(ADD_PHONE_NUMBER_PREFERENCE_KEY) + ?: return + + var order = addPhoneBtn.order + + for ((index, phoneNumber3PID) in currentPhoneNumber3PID.withIndex()) { + val preference = VectorPreference(activity!!) + + preference.title = getString(R.string.settings_phone_number) + var phoneNumberFormatted = phoneNumber3PID.address + try { + // Attempt to format phone number + val phoneNumber = PhoneNumberUtil.getInstance().parse("+$phoneNumberFormatted", null) + phoneNumberFormatted = PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL) + } catch (e: NumberParseException) { + // Do nothing, we will display raw version + } + + preference.summary = phoneNumberFormatted + preference.key = PHONE_NUMBER_PREFERENCE_KEY_BASE + index + preference.order = order + + preference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + displayDelete3PIDConfirmationDialog(phoneNumber3PID, preference.summary) + true + } + + preference.onPreferenceLongClickListener = object : VectorPreference.OnPreferenceLongClickListener { + override fun onPreferenceLongClick(preference: Preference): Boolean { + activity?.let { copyToClipboard(it, phoneNumber3PID.address) } + return true + } + } + + order++ + mUserSettingsCategory.addPreference(preference) + } + + addPhoneBtn.order = order + } + */ + } + + //============================================================================================================== + // contacts management + //============================================================================================================== + + private fun setContactsPreferences() { + /* TODO + // Permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // on Android >= 23, use the system one + mContactSettingsCategory.removePreference(findPreference(ContactsManager.CONTACTS_BOOK_ACCESS_KEY)) + } + // Phonebook country + mContactPhonebookCountryPreference.summary = PhoneNumberUtils.getHumanCountryCode(PhoneNumberUtils.getCountryCode(activity)) + + mContactPhonebookCountryPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + val intent = CountryPickerActivity.getIntent(activity, true) + startActivityForResult(intent, REQUEST_PHONEBOOK_COUNTRY) + true + } + */ + } + + private fun onPhonebookCountryUpdate(data: Intent?) { + /* TODO + if (data != null && data.hasExtra(CountryPickerActivity.EXTRA_OUT_COUNTRY_NAME) + && data.hasExtra(CountryPickerActivity.EXTRA_OUT_COUNTRY_CODE)) { + val countryCode = data.getStringExtra(CountryPickerActivity.EXTRA_OUT_COUNTRY_CODE) + if (!TextUtils.equals(countryCode, PhoneNumberUtils.getCountryCode(activity))) { + PhoneNumberUtils.setCountryCode(activity, countryCode) + mContactPhonebookCountryPreference.summary = data.getStringExtra(CountryPickerActivity.EXTRA_OUT_COUNTRY_NAME) + } + } + */ + } + + //============================================================================================================== + // user interface management + //============================================================================================================== + + private fun setUserInterfacePreferences() { + // Selected language + selectedLanguagePreference.summary = VectorLocale.localeToLocalisedString(VectorLocale.applicationLocale) + + selectedLanguagePreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + // TODO startActivityForResult(LanguagePickerActivity.getIntent(activity), REQUEST_LOCALE) + true + } + + // Text size + textSizePreference.summary = FontScale.getFontScaleDescription(activity!!) + + textSizePreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + activity?.let { displayTextSizeSelection(it) } + true + } + } + + private fun displayTextSizeSelection(activity: Activity) { + val inflater = activity.layoutInflater + val layout = inflater.inflate(R.layout.dialog_select_text_size, null) + + val dialog = AlertDialog.Builder(activity) + .setTitle(R.string.font_size) + .setView(layout) + .setPositiveButton(R.string.ok, null) + .setNegativeButton(R.string.cancel, null) + .show() + + val linearLayout = layout.findViewById(R.id.text_selection_group_view) + + val childCount = linearLayout.childCount + + val scaleText = FontScale.getFontScaleDescription(activity) + + for (i in 0 until childCount) { + val v = linearLayout.getChildAt(i) + + if (v is CheckedTextView) { + v.isChecked = TextUtils.equals(v.text, scaleText) + + v.setOnClickListener { + dialog.dismiss() + FontScale.updateFontScale(activity, v.text.toString()) + activity.startActivity(activity.intent) + activity.finish() + } + } + } + } + + //============================================================================================================== + // background sync management + //============================================================================================================== + + /** + * Convert a delay in seconds to string + * + * @param seconds the delay in seconds + * @return the text + */ + private fun secondsToText(seconds: Int): String { + return if (seconds > 1) { + seconds.toString() + " " + getString(R.string.settings_seconds) + } else { + seconds.toString() + " " + getString(R.string.settings_second) + } + } + + /** + * Refresh the background sync preference + */ + private fun refreshBackgroundSyncPrefs() { + /* TODO + activity?.let { activity -> + val pushManager = Matrix.getInstance(activity).pushManager + + val timeout = pushManager.backgroundSyncTimeOut / 1000 + val delay = pushManager.backgroundSyncDelay / 1000 + + // update the settings + PreferenceManager.getDefaultSharedPreferences(activity).edit { + putString(PreferencesManager.SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, timeout.toString() + "") + putString(PreferencesManager.SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, delay.toString() + "") + } + + mSyncRequestTimeoutPreference?.let { + it.summary = secondsToText(timeout) + it.text = timeout.toString() + "" + + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + var newTimeOut = timeout + + try { + newTimeOut = Integer.parseInt(newValue as String) + } catch (e: Exception) { + Timber.e(e, "## refreshBackgroundSyncPrefs : parseInt failed " + e.message) + } + + if (newTimeOut != timeout) { + pushManager.backgroundSyncTimeOut = newTimeOut * 1000 + + activity.runOnUiThread { refreshBackgroundSyncPrefs() } + } + + false + } + } + + mSyncRequestDelayPreference?.let { + it.summary = secondsToText(delay) + it.text = delay.toString() + "" + + it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + var newDelay = delay + + try { + newDelay = Integer.parseInt(newValue as String) + } catch (e: Exception) { + Timber.e(e, "## refreshBackgroundSyncPrefs : parseInt failed " + e.message) + } + + if (newDelay != delay) { + pushManager.backgroundSyncDelay = newDelay * 1000 + + activity.runOnUiThread { refreshBackgroundSyncPrefs() } + } + + false + } + } + } + */ + } + + //============================================================================================================== + // Cryptography + //============================================================================================================== + + private fun removeCryptographyPreference() { + preferenceScreen.let { + it.removePreference(mCryptographyCategory) + it.removePreference(mCryptographyCategoryDivider) + + // Also remove keys management section + it.removePreference(mCryptographyManageCategory) + it.removePreference(mCryptographyManageCategoryDivider) + } + } + + /** + * Build the cryptography preference section. + * + * @param aMyDeviceInfo the device info + */ + private fun refreshCryptographyPreference(aMyDeviceInfo: DeviceInfo?) { + val userId = mSession.sessionParams.credentials.userId + val deviceId = mSession.sessionParams.credentials.deviceId + + // device name + if (null != aMyDeviceInfo) { + cryptoInfoDeviceNamePreference.summary = "TODO" // aMyDeviceInfo.display_name + + cryptoInfoDeviceNamePreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + displayDeviceRenameDialog(aMyDeviceInfo) + true + } + + cryptoInfoDeviceNamePreference.onPreferenceLongClickListener = object : VectorPreference.OnPreferenceLongClickListener { + override fun onPreferenceLongClick(preference: Preference): Boolean { + activity?.let { copyToClipboard(it, "TODO") } //aMyDeviceInfo.display_name) } + return true + } + } + } + + // crypto section: device ID + if (!TextUtils.isEmpty(deviceId)) { + cryptoInfoDeviceIdPreference.summary = deviceId + + cryptoInfoDeviceIdPreference.setOnPreferenceClickListener { + activity?.let { copyToClipboard(it, deviceId!!) } + true + } + + + manageBackupPref.onPreferenceClickListener = Preference.OnPreferenceClickListener { + context?.let { + // TODO startActivity(KeysBackupManageActivity.intent(it, mSession.myUserId)) + } + false + } + + exportPref.onPreferenceClickListener = Preference.OnPreferenceClickListener { + exportKeys() + true + } + + importPref.onPreferenceClickListener = Preference.OnPreferenceClickListener { + importKeys() + true + } + } + + // crypto section: device key (fingerprint) + if (!TextUtils.isEmpty(deviceId) && !TextUtils.isEmpty(userId)) { + /* TODO + mSession.crypto?.getDeviceInfo(userId, deviceId, object : SimpleApiCallback() { + override fun onSuccess(deviceInfo: MXDeviceInfo?) { + if (null != deviceInfo && !TextUtils.isEmpty(deviceInfo.fingerprint()) && null != activity) { + cryptoInfoTextPreference.summary = deviceInfo.getFingerprintHumanReadable() + + cryptoInfoTextPreference.setOnPreferenceClickListener { + activity?.let { copyToClipboard(it, deviceInfo.fingerprint()) } + true + } + } + } + }) + */ + } + + sendToUnverifiedDevicesPref.isChecked = false + + /* TODO + mSession.crypto?.getGlobalBlacklistUnverifiedDevices(object : SimpleApiCallback() { + override fun onSuccess(status: Boolean) { + sendToUnverifiedDevicesPref.isChecked = status + } + }) + + sendToUnverifiedDevicesPref.onPreferenceClickListener = Preference.OnPreferenceClickListener { + mSession.crypto?.getGlobalBlacklistUnverifiedDevices(object : SimpleApiCallback() { + override fun onSuccess(status: Boolean) { + if (sendToUnverifiedDevicesPref.isChecked != status) { + mSession.crypto + ?.setGlobalBlacklistUnverifiedDevices(sendToUnverifiedDevicesPref.isChecked, object : SimpleApiCallback() { + override fun onSuccess(info: Void?) { + + } + }) + } + } + }) + + true + } + */ + } + + //============================================================================================================== + // devices list + //============================================================================================================== + + private fun removeDevicesPreference() { + preferenceScreen.let { + it.removePreference(mDevicesListSettingsCategory) + it.removePreference(mDevicesListSettingsCategoryDivider) + } + } + + /** + * Force the refresh of the devices list.

+ * The devices list is the list of the devices where the user as looged in. + * It can be any mobile device, as any browser. + */ + private fun refreshDevicesList() { + if (mSession.isCryptoEnabled() && !TextUtils.isEmpty(mSession.sessionParams.credentials.deviceId)) { + // display a spinner while loading the devices list + if (0 == mDevicesListSettingsCategory.preferenceCount) { + activity?.let { + val preference = ProgressBarPreference(it) + mDevicesListSettingsCategory.addPreference(preference) + } + } + + /* TODO + mSession.getDevicesList(object : ApiCallback { + override fun onSuccess(info: DevicesListResponse) { + if (info.devices.isEmpty()) { + removeDevicesPreference() + } else { + buildDevicesSettings(info.devices) + } + } + + override fun onNetworkError(e: Exception) { + removeDevicesPreference() + onCommonDone(e.message) + } + + override fun onMatrixError(e: MatrixError) { + removeDevicesPreference() + onCommonDone(e.message) + } + + override fun onUnexpectedError(e: Exception) { + removeDevicesPreference() + onCommonDone(e.message) + } + }) + */ + } else { + removeDevicesPreference() + removeCryptographyPreference() + } + } + + /** + * Build the devices portion of the settings.

+ * Each row correspond to a device ID and its corresponding device name. Clicking on the row + * display a dialog containing: the device ID, the device name and the "last seen" information. + * + * @param aDeviceInfoList the list of the devices + */ + private fun buildDevicesSettings(aDeviceInfoList: List) { + var preference: VectorPreference + var typeFaceHighlight: Int + var isNewList = true + val myDeviceId = mSession.sessionParams.credentials.deviceId + + /* TODO + if (aDeviceInfoList.size == mDevicesNameList.size) { + isNewList = !mDevicesNameList.containsAll(aDeviceInfoList) + } + + if (isNewList) { + var prefIndex = 0 + mDevicesNameList = aDeviceInfoList + + // sort before display: most recent first + DeviceInfo.sortByLastSeen(mDevicesNameList) + + // start from scratch: remove the displayed ones + mDevicesListSettingsCategory.removeAll() + + for (deviceInfo in mDevicesNameList) { + // set bold to distinguish current device ID + if (null != myDeviceId && myDeviceId == deviceInfo.device_id) { + mMyDeviceInfo = deviceInfo + typeFaceHighlight = Typeface.BOLD + } else { + typeFaceHighlight = Typeface.NORMAL + } + + // add the edit text preference + preference = VectorPreference(activity!!).apply { + mTypeface = typeFaceHighlight + } + + if (null == deviceInfo.device_id && null == deviceInfo.display_name) { + continue + } else { + if (null != deviceInfo.device_id) { + preference.title = deviceInfo.device_id + } + + // display name parameter can be null (new JSON API) + if (null != deviceInfo.display_name) { + preference.summary = deviceInfo.display_name + } + } + + preference.key = DEVICES_PREFERENCE_KEY_BASE + prefIndex + prefIndex++ + + // onClick handler: display device details dialog + preference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + displayDeviceDetailsDialog(deviceInfo) + true + } + + mDevicesListSettingsCategory.addPreference(preference) + } + + refreshCryptographyPreference(mMyDeviceInfo) + } + */ + } + + /** + * Display a dialog containing the device ID, the device name and the "last seen" information.<> + * This dialog allow to delete the corresponding device (see [.displayDeviceDeletionDialog]) + * + * @param aDeviceInfo the device information + */ + private fun displayDeviceDetailsDialog(aDeviceInfo: DeviceInfo) { + + activity?.let { + + val builder = AlertDialog.Builder(it) + val inflater = it.layoutInflater + val layout = inflater.inflate(R.layout.dialog_device_details, null) + var textView = layout.findViewById(R.id.device_id) + + textView.text = "TODO"//aDeviceInfo.device_id + + // device name + textView = layout.findViewById(R.id.device_name) + val displayName = "TODO" // if (TextUtils.isEmpty(aDeviceInfo.display_name)) LABEL_UNAVAILABLE_DATA else aDeviceInfo.display_name + textView.text = displayName + + // last seen info + textView = layout.findViewById(R.id.device_last_seen) + /* TODO + if (!TextUtils.isEmpty(aDeviceInfo.last_seen_ip)) { + val lastSeenIp = aDeviceInfo.last_seen_ip + val dateFormatTime = SimpleDateFormat("HH:mm:ss") + val time = dateFormatTime.format(Date(aDeviceInfo.last_seen_ts)) + val dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.getDefault()) + val lastSeenTime = dateFormat.format(Date(aDeviceInfo.last_seen_ts)) + ", " + time + val lastSeenInfo = getString(R.string.devices_details_last_seen_format, lastSeenIp, lastSeenTime) + textView.text = lastSeenInfo + } else { + // hide last time seen section + layout.findViewById(R.id.device_last_seen_title).visibility = View.GONE + textView.visibility = View.GONE + } + */ + + // title & icon + builder.setTitle(R.string.devices_details_dialog_title) + .setIcon(android.R.drawable.ic_dialog_info) + .setView(layout) + .setPositiveButton(R.string.rename) { _, _ -> displayDeviceRenameDialog(aDeviceInfo) } + + /* TODO + // disable the deletion for our own device + if (!TextUtils.equals(mSession.crypto?.myDevice?.deviceId, aDeviceInfo.device_id)) { + builder.setNegativeButton(R.string.delete) { _, _ -> displayDeviceDeletionDialog(aDeviceInfo) } + } + */ + + builder.setNeutralButton(R.string.cancel, null) + .setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event -> + if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + dialog.cancel() + return@OnKeyListener true + } + false + }) + .show() + } + } + + /** + * Display an alert dialog to rename a device + * + * @param aDeviceInfoToRename device info + */ + private fun displayDeviceRenameDialog(aDeviceInfoToRename: DeviceInfo) { + activity?.let { + val inflater = it.layoutInflater + val layout = inflater.inflate(R.layout.dialog_base_edit_text, null) + + val input = layout.findViewById(R.id.edit_text) + /* TODO + input.setText(aDeviceInfoToRename.display_name) + + AlertDialog.Builder(it) + .setTitle(R.string.devices_details_device_name) + .setView(layout) + .setPositiveButton(R.string.ok) { _, _ -> + displayLoadingView() + + val newName = input.text.toString() + + mSession.setDeviceName(aDeviceInfoToRename.device_id, newName, object : ApiCallback { + override fun onSuccess(info: Void?) { + hideLoadingView() + + // search which preference is updated + val count = mDevicesListSettingsCategory.preferenceCount + + for (i in 0 until count) { + val pref = mDevicesListSettingsCategory.getPreference(i) + + if (TextUtils.equals(aDeviceInfoToRename.device_id, pref.title)) { + pref.summary = newName + } + } + + // detect if the updated device is the current account one + if (TextUtils.equals(cryptoInfoDeviceIdPreference.summary, aDeviceInfoToRename.device_id)) { + cryptoInfoDeviceNamePreference.summary = newName + } + + // Also change the display name in aDeviceInfoToRename, in case of multiple renaming + aDeviceInfoToRename.display_name = newName + } + + override fun onNetworkError(e: Exception) { + onCommonDone(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + onCommonDone(e.localizedMessage) + } + + override fun onUnexpectedError(e: Exception) { + onCommonDone(e.localizedMessage) + } + }) + } + .setNegativeButton(R.string.cancel, null) + .show() + */ + } + } + + /** + * Try to delete a device. + * + * @param deviceId the device id + */ + private fun deleteDevice(deviceId: String) { + displayLoadingView() + /* TODO + mSession.deleteDevice(deviceId, mAccountPassword, object : ApiCallback { + override fun onSuccess(info: Void?) { + hideLoadingView() + refreshDevicesList() // force settings update + } + + private fun onError(message: String) { + mAccountPassword = null + onCommonDone(message) + } + + override fun onNetworkError(e: Exception) { + onError(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + onError(e.localizedMessage) + } + + override fun onUnexpectedError(e: Exception) { + onError(e.localizedMessage) + } + }) + */ + } + + /** + * Display a delete confirmation dialog to remove a device.

+ * The user is invited to enter his password to confirm the deletion. + * + * @param aDeviceInfoToDelete device info + */ + private fun displayDeviceDeletionDialog(aDeviceInfoToDelete: DeviceInfo) { + /* + TODO + if (aDeviceInfoToDelete.device_id != null) { + if (!TextUtils.isEmpty(mAccountPassword)) { + deleteDevice(aDeviceInfoToDelete.device_id) + } else { + activity?.let { + val inflater = it.layoutInflater + val layout = inflater.inflate(R.layout.dialog_device_delete, null) + val passwordEditText = layout.findViewById(R.id.delete_password) + + AlertDialog.Builder(it) + .setIcon(android.R.drawable.ic_dialog_alert) + .setTitle(R.string.devices_delete_dialog_title) + .setView(layout) + .setPositiveButton(R.string.devices_delete_submit_button_label, DialogInterface.OnClickListener { _, _ -> + if (TextUtils.isEmpty(passwordEditText.toString())) { + it.toast(R.string.error_empty_field_your_password) + return@OnClickListener + } + mAccountPassword = passwordEditText.text.toString() + deleteDevice(aDeviceInfoToDelete.device_id) + }) + .setNegativeButton(R.string.cancel) { _, _ -> + hideLoadingView() + } + .setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event -> + if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + dialog.cancel() + hideLoadingView() + return@OnKeyListener true + } + false + }) + .show() + } + } + } else { + Timber.e("## displayDeviceDeletionDialog(): sanity check failure") + } + */ + } + + /** + * Manage the e2e keys export. + */ + private fun exportKeys() { + // We need WRITE_EXTERNAL permission + /* + TODO + if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_EXPORT_KEYS)) { + activity?.let { activity -> + ExportKeysDialog().show(activity, object : ExportKeysDiaLog.ExportKeyDialogListener { + override fun onPassphrase(passphrase: String) { + displayLoadingView() + + CommonActivityUtils.exportKeys(mSession, passphrase, object : SimpleApiCallback(activity) { + override fun onSuccess(filename: String) { + hideLoadingView() + + AlertDialog.Builder(activity) + .setMessage(getString(R.string.encryption_export_saved_as, filename)) + .setCancelable(false) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun onNetworkError(e: Exception) { + super.onNetworkError(e) + hideLoadingView() + } + + override fun onMatrixError(e: MatrixError) { + super.onMatrixError(e) + hideLoadingView() + } + + override fun onUnexpectedError(e: Exception) { + super.onUnexpectedError(e) + hideLoadingView() + } + }) + } + }) + } + } + */ + } + + /** + * Manage the e2e keys import. + */ + @SuppressLint("NewApi") + private fun importKeys() { + // TODO activity?.let { openFileSelection(it, this, false, REQUEST_E2E_FILE_REQUEST_CODE) } + } + + /** + * Manage the e2e keys import. + * + * @param intent the intent result + */ + private fun importKeys(intent: Intent?) { + // sanity check + if (null == intent) { + return + } + + /* + TODO + val sharedDataItems = ArrayList(RoomMediaMessage.listRoomMediaMessages(intent)) + val thisActivity = activity + + if (sharedDataItems.isNotEmpty() && thisActivity != null) { + val sharedDataItem = sharedDataItems[0] + val dialogLayout = thisActivity.layoutInflater.inflate(R.layout.dialog_import_e2e_keys, null) + val builder = AlertDialog.Builder(thisActivity) + .setTitle(R.string.encryption_import_room_keys) + .setView(dialogLayout) + + val passPhraseEditText = dialogLayout.findViewById(R.id.dialog_e2e_keys_passphrase_edit_text) + val importButton = dialogLayout.findViewById