Import some stuff about Push and notification from Element Android - WIP
This commit is contained in:
parent
aa2bb224e5
commit
64f4740029
70 changed files with 5158 additions and 2 deletions
|
|
@ -214,10 +214,12 @@ dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
|
||||||
implementation(libs.appyx.core)
|
implementation(libs.appyx.core)
|
||||||
implementation(libs.androidx.splash)
|
implementation(libs.androidx.splash)
|
||||||
|
implementation(libs.androidx.core)
|
||||||
implementation(libs.androidx.corektx)
|
implementation(libs.androidx.corektx)
|
||||||
implementation(libs.androidx.lifecycle.runtime)
|
implementation(libs.androidx.lifecycle.runtime)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.startup)
|
implementation(libs.androidx.startup)
|
||||||
|
implementation(libs.androidx.preference)
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
|
|
||||||
implementation(platform(libs.network.okhttp.bom))
|
implementation(platform(libs.network.okhttp.bom))
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
package io.element.android.x.di
|
package io.element.android.x.di
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.content.res.Resources
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
|
@ -25,6 +28,7 @@ import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.di.DefaultPreferences
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.x.BuildConfig
|
import io.element.android.x.BuildConfig
|
||||||
import io.element.android.x.R
|
import io.element.android.x.R
|
||||||
|
|
@ -47,6 +51,11 @@ object AppModule {
|
||||||
return File(context.filesDir, "sessions")
|
return File(context.filesDir, "sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun providesResources(@ApplicationContext context: Context): Resources {
|
||||||
|
return context.resources
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
fun providesAppCoroutineScope(): CoroutineScope {
|
fun providesAppCoroutineScope(): CoroutineScope {
|
||||||
|
|
@ -69,6 +78,13 @@ object AppModule {
|
||||||
okHttpLoggingLevel = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC,
|
okHttpLoggingLevel = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
@DefaultPreferences
|
||||||
|
fun providesDefaultSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
|
||||||
|
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
fun providesCoroutineDispatchers(): CoroutineDispatchers {
|
fun providesCoroutineDispatchers(): CoroutineDispatchers {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ molecule = "0.8.0"
|
||||||
|
|
||||||
# AndroidX
|
# AndroidX
|
||||||
material = "1.8.0"
|
material = "1.8.0"
|
||||||
corektx = "1.9.0"
|
core = "1.9.0"
|
||||||
datastore = "1.0.0"
|
datastore = "1.0.0"
|
||||||
constraintlayout = "2.1.4"
|
constraintlayout = "2.1.4"
|
||||||
recyclerview = "1.3.0"
|
recyclerview = "1.3.0"
|
||||||
|
|
@ -60,7 +60,8 @@ google_firebase_bom = "com.google.firebase:firebase-bom:31.2.3"
|
||||||
|
|
||||||
# AndroidX
|
# AndroidX
|
||||||
androidx_material = { module = "com.google.android.material:material", version.ref = "material" }
|
androidx_material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||||
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "corektx" }
|
androidx_core = { module = "androidx.core:core", version.ref = "core" }
|
||||||
|
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
|
||||||
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||||
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
||||||
|
|
@ -73,6 +74,7 @@ androidx_security_crypto = "androidx.security:security-crypto:1.0.0"
|
||||||
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
|
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
|
||||||
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||||
androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" }
|
androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" }
|
||||||
|
androidx_preference = "androidx.preference:preference:1.2.0"
|
||||||
|
|
||||||
androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }
|
androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }
|
||||||
|
|
||||||
|
|
|
||||||
41
libraries/core/src/main/kotlin/io/element/android/libraries/core/cache/CircularCache.kt
vendored
Normal file
41
libraries/core/src/main/kotlin/io/element/android/libraries/core/cache/CircularCache.kt
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.core.cache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A FIFO circular buffer of T.
|
||||||
|
* This class is not thread safe.
|
||||||
|
*/
|
||||||
|
class CircularCache<T : Any>(cacheSize: Int, factory: (Int) -> Array<T?>) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
inline fun <reified T : Any> create(cacheSize: Int) = CircularCache(cacheSize) { Array<T?>(cacheSize) { null } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cache = factory(cacheSize)
|
||||||
|
private var writeIndex = 0
|
||||||
|
|
||||||
|
fun contains(value: T): Boolean = cache.contains(value)
|
||||||
|
|
||||||
|
fun put(value: T) {
|
||||||
|
if (writeIndex == cache.size) {
|
||||||
|
writeIndex = 0
|
||||||
|
}
|
||||||
|
cache[writeIndex] = value
|
||||||
|
writeIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.di
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier annotation class DefaultPreferences
|
||||||
28
libraries/push/api/build.gradle.kts
Normal file
28
libraries/push/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.push.api"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.corektx)
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
}
|
||||||
64
libraries/push/api/src/main/AndroidManifest.xml
Normal file
64
libraries/push/api/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright (c) 2023 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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
|
||||||
|
<!-- Firebase components -->
|
||||||
|
<meta-data
|
||||||
|
android:name="firebase_analytics_collection_deactivated"
|
||||||
|
android:value="true" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="VectorFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- UnifiedPush -->
|
||||||
|
<receiver
|
||||||
|
android:name="VectorUnifiedPushMessagingReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.unifiedpush.android.connector.MESSAGE" />
|
||||||
|
<action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
|
||||||
|
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
|
||||||
|
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
|
||||||
|
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name="KeepInternalDistributor"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<!--
|
||||||
|
This action is checked to track installed and uninstalled distributors.
|
||||||
|
We declare it to keep the background sync as an internal
|
||||||
|
unifiedpush distributor.
|
||||||
|
-->
|
||||||
|
<action android:name="org.unifiedpush.android.distributor.REGISTER" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.api
|
||||||
|
|
||||||
|
interface PushService {
|
||||||
|
fun setCurrentRoom(roomId: String?)
|
||||||
|
fun setCurrentThread(threadId: String?)
|
||||||
|
fun notificationStyleChanged()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.api.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Different strategies for Background sync, only applicable to F-Droid version of the app.
|
||||||
|
*/
|
||||||
|
enum class BackgroundSyncMode {
|
||||||
|
/**
|
||||||
|
* In this mode background syncs are scheduled via Workers, meaning that the system will have control on the periodicity
|
||||||
|
* of syncs when battery is low or when the phone is idle (sync will occur in allowed maintenance windows). After completion
|
||||||
|
* the sync work will schedule another one.
|
||||||
|
*/
|
||||||
|
FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This mode requires the app to be exempted from battery optimization. Alarms will be launched and will wake up the app
|
||||||
|
* in order to perform the background sync as a foreground service. After completion the service will schedule another alarm
|
||||||
|
*/
|
||||||
|
FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app won't sync in background.
|
||||||
|
*/
|
||||||
|
FDROID_BACKGROUND_SYNC_MODE_DISABLED;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_SYNC_DELAY_SECONDS = 60
|
||||||
|
const val DEFAULT_SYNC_TIMEOUT_SECONDS = 6
|
||||||
|
|
||||||
|
fun fromString(value: String?): BackgroundSyncMode = values().firstOrNull { it.name == value }
|
||||||
|
?: FDROID_BACKGROUND_SYNC_MODE_DISABLED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.api.store
|
||||||
|
|
||||||
|
import io.element.android.libraries.push.api.model.BackgroundSyncMode
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface PushDataStore {
|
||||||
|
val pushCounterFlow: Flow<Int>
|
||||||
|
|
||||||
|
fun areNotificationEnabledForDevice(): Boolean
|
||||||
|
fun setNotificationEnabledForDevice(enabled: Boolean)
|
||||||
|
|
||||||
|
fun backgroundSyncTimeOut(): Int
|
||||||
|
fun setBackgroundSyncTimeout(timeInSecond: Int)
|
||||||
|
fun backgroundSyncDelay(): Int
|
||||||
|
fun setBackgroundSyncDelay(timeInSecond: Int)
|
||||||
|
fun isBackgroundSyncEnabled(): Boolean
|
||||||
|
fun setFdroidSyncBackgroundMode(mode: BackgroundSyncMode)
|
||||||
|
fun getFdroidSyncBackgroundMode(): BackgroundSyncMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true if Pin code is disabled, or if user set the settings to see full notification content.
|
||||||
|
*/
|
||||||
|
fun useCompleteNotificationFormat(): Boolean
|
||||||
|
}
|
||||||
64
libraries/push/impl/build.gradle.kts
Normal file
64
libraries/push/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
|
kotlin("plugin.serialization") version "1.8.10"
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.push.impl"
|
||||||
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.dagger)
|
||||||
|
implementation(libs.androidx.corektx)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
|
implementation(libs.serialization.json)
|
||||||
|
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.analytics.api)
|
||||||
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.di)
|
||||||
|
implementation(projects.libraries.androidutils)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.push.api)
|
||||||
|
|
||||||
|
implementation(projects.services.toolbox.api)
|
||||||
|
|
||||||
|
|
||||||
|
api("me.gujun.android:span:1.7") {
|
||||||
|
exclude(group = "com.android.support", module = "support-annotations")
|
||||||
|
}
|
||||||
|
|
||||||
|
implementation(platform(libs.google.firebase.bom))
|
||||||
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
|
|
||||||
|
// UnifiedPush
|
||||||
|
api("com.github.UnifiedPush:android-connector:2.1.1")
|
||||||
|
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(libs.coroutines.test)
|
||||||
|
}
|
||||||
64
libraries/push/impl/src/main/AndroidManifest.xml
Normal file
64
libraries/push/impl/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright (c) 2023 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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
|
||||||
|
<!-- Firebase components -->
|
||||||
|
<meta-data
|
||||||
|
android:name="firebase_analytics_collection_deactivated"
|
||||||
|
android:value="true" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="VectorFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- UnifiedPush -->
|
||||||
|
<receiver
|
||||||
|
android:name="VectorUnifiedPushMessagingReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.unifiedpush.android.connector.MESSAGE" />
|
||||||
|
<action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
|
||||||
|
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
|
||||||
|
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
|
||||||
|
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name="KeepInternalDistributor"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<!--
|
||||||
|
This action is checked to track installed and uninstalled distributors.
|
||||||
|
We declare it to keep the background sync as an internal
|
||||||
|
unifiedpush distributor.
|
||||||
|
-->
|
||||||
|
<action android:name="org.unifiedpush.android.distributor.REGISTER" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
// TODO Move away
|
||||||
|
/**
|
||||||
|
* This interface defines 2 flags so you can handle auto accept invites.
|
||||||
|
* At the moment we only have [CompileTimeAutoAcceptInvites] implementation.
|
||||||
|
*/
|
||||||
|
interface AutoAcceptInvites {
|
||||||
|
/**
|
||||||
|
* Enable auto-accept invites. It means, as soon as you got an invite from the sync, it will try to join it.
|
||||||
|
*/
|
||||||
|
val isEnabled: Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide invites from the UI (from notifications, notification count and room list). By default invites are hidden when [isEnabled] is true
|
||||||
|
*/
|
||||||
|
val hideInvites: Boolean
|
||||||
|
get() = isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fun AutoAcceptInvites.showInvites() = !hideInvites
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple compile time implementation of AutoAcceptInvites flags.
|
||||||
|
*/
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class CompileTimeAutoAcceptInvites @Inject constructor() : AutoAcceptInvites {
|
||||||
|
override val isEnabled = false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.push.api.PushService
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultPushService @Inject constructor(
|
||||||
|
private val notificationDrawerManager: NotificationDrawerManager,
|
||||||
|
) : PushService {
|
||||||
|
override fun setCurrentRoom(roomId: String?) {
|
||||||
|
notificationDrawerManager.setCurrentRoom(roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCurrentThread(threadId: String?) {
|
||||||
|
notificationDrawerManager.setCurrentThread(threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun notificationStyleChanged() {
|
||||||
|
notificationDrawerManager.notificationStyleChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class EnsureFcmTokenIsRetrievedUseCase @Inject constructor(
|
||||||
|
private val unifiedPushHelper: UnifiedPushHelper,
|
||||||
|
private val fcmHelper: FcmHelper,
|
||||||
|
// private val activeSessionHolder: ActiveSessionHolder,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun execute(pushersManager: PushersManager, registerPusher: Boolean) {
|
||||||
|
if (unifiedPushHelper.isEmbeddedDistributor()) {
|
||||||
|
fcmHelper.ensureFcmTokenIsRetrieved(pushersManager, shouldAddHttpPusher(registerPusher))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldAddHttpPusher(registerPusher: Boolean) = if (registerPusher) {
|
||||||
|
/*
|
||||||
|
TODO EAx
|
||||||
|
val currentSession = activeSessionHolder.getActiveSession()
|
||||||
|
val currentPushers = currentSession.pushersService().getPushers()
|
||||||
|
currentPushers.none { it.deviceId == currentSession.sessionParams.deviceId }
|
||||||
|
*/
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
interface FcmHelper {
|
||||||
|
fun isFirebaseAvailable(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the FCM registration token.
|
||||||
|
*
|
||||||
|
* @return the FCM token or null if not received from FCM.
|
||||||
|
*/
|
||||||
|
fun getFcmToken(): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store FCM token to the SharedPrefs.
|
||||||
|
*
|
||||||
|
* @param token the token to store.
|
||||||
|
*/
|
||||||
|
fun storeFcmToken(token: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onNewToken may not be called on application upgrade, so ensure my shared pref is set.
|
||||||
|
*
|
||||||
|
* @param pushersManager the instance to register the pusher on.
|
||||||
|
* @param registerPusher whether the pusher should be registered.
|
||||||
|
*/
|
||||||
|
fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean)
|
||||||
|
|
||||||
|
/*
|
||||||
|
fun onEnterForeground(activeSessionHolder: ActiveSessionHolder)
|
||||||
|
|
||||||
|
fun onEnterBackground(activeSessionHolder: ActiveSessionHolder)
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
/*
|
||||||
|
* 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import com.google.android.gms.common.ConnectionResult
|
||||||
|
import com.google.android.gms.common.GoogleApiAvailability
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.di.DefaultPreferences
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class store the FCM token in SharedPrefs and ensure this token is retrieved.
|
||||||
|
* It has an alter ego in the fdroid variant.
|
||||||
|
*/
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class GoogleFcmHelper @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
@DefaultPreferences private val sharedPrefs: SharedPreferences,
|
||||||
|
) : FcmHelper {
|
||||||
|
override fun isFirebaseAvailable(): Boolean = true
|
||||||
|
|
||||||
|
override fun getFcmToken(): String? {
|
||||||
|
return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun storeFcmToken(token: String?) {
|
||||||
|
sharedPrefs.edit {
|
||||||
|
putString(PREFS_KEY_FCM_TOKEN, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean) {
|
||||||
|
// 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features'
|
||||||
|
if (checkPlayServices(context)) {
|
||||||
|
try {
|
||||||
|
FirebaseMessaging.getInstance().token
|
||||||
|
.addOnSuccessListener { token ->
|
||||||
|
storeFcmToken(token)
|
||||||
|
if (registerPusher) {
|
||||||
|
pushersManager.enqueueRegisterPusherWithFcmKey(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnFailureListener { e ->
|
||||||
|
Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed")
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, StringR.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 fun checkPlayServices(context: Context): Boolean {
|
||||||
|
val apiAvailability = GoogleApiAvailability.getInstance()
|
||||||
|
val resultCode = apiAvailability.isGooglePlayServicesAvailable(context)
|
||||||
|
return resultCode == ConnectionResult.SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
override fun onEnterForeground(activeSessionHolder: ActiveSessionHolder) {
|
||||||
|
// No op
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEnterBackground(activeSessionHolder: ActiveSessionHolder) {
|
||||||
|
// No op
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_KEY_FCM_TOKEN = "FCM_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface GuardServiceStarter {
|
||||||
|
fun start() {}
|
||||||
|
fun stop() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class NoopGuardServiceStarter @Inject constructor() : GuardServiceStarter {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedPush lib tracks an action to check installed and uninstalled distributors.
|
||||||
|
* We declare it to keep the background sync as an internal unifiedpush distributor.
|
||||||
|
* This class is used to declare this action.
|
||||||
|
*/
|
||||||
|
class KeepInternalDistributor : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
/*
|
||||||
|
* 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import io.element.android.libraries.push.impl.config.PushConfig
|
||||||
|
import io.element.android.libraries.toolbox.api.appname.AppNameProvider
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
internal const val DEFAULT_PUSHER_FILE_TAG = "mobile"
|
||||||
|
|
||||||
|
// TODO EAx Communicate with the SDK
|
||||||
|
class PushersManager @Inject constructor(
|
||||||
|
private val unifiedPushHelper: UnifiedPushHelper,
|
||||||
|
// private val activeSessionHolder: ActiveSessionHolder,
|
||||||
|
// private val localeProvider: LocaleProvider,
|
||||||
|
private val appNameProvider: AppNameProvider,
|
||||||
|
// private val getDeviceInfoUseCase: GetDeviceInfoUseCase,
|
||||||
|
) {
|
||||||
|
suspend fun testPush() {
|
||||||
|
/*
|
||||||
|
val currentSession = activeSessionHolder.getActiveSession()
|
||||||
|
|
||||||
|
currentSession.pushersService().testPush(
|
||||||
|
unifiedPushHelper.getPushGateway() ?: return,
|
||||||
|
PushConfig.pusher_app_id,
|
||||||
|
unifiedPushHelper.getEndpointOrToken().orEmpty(),
|
||||||
|
TEST_EVENT_ID
|
||||||
|
)
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enqueueRegisterPusherWithFcmKey(pushKey: String): UUID {
|
||||||
|
return enqueueRegisterPusher(pushKey, PushConfig.pusher_http_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enqueueRegisterPusher(
|
||||||
|
pushKey: String,
|
||||||
|
gateway: String
|
||||||
|
): UUID {
|
||||||
|
/*
|
||||||
|
val currentSession = activeSessionHolder.getActiveSession()
|
||||||
|
val pusher = createHttpPusher(pushKey, gateway)
|
||||||
|
return currentSession.pushersService().enqueueAddHttpPusher(pusher)
|
||||||
|
|
||||||
|
*/
|
||||||
|
// TODO EAx
|
||||||
|
TODO()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createHttpPusher(
|
||||||
|
pushKey: String,
|
||||||
|
gateway: String
|
||||||
|
): Any = TODO()
|
||||||
|
/*
|
||||||
|
HttpPusher(
|
||||||
|
pushkey = pushKey,
|
||||||
|
appId = PushConfig.pusher_app_id,
|
||||||
|
profileTag = DEFAULT_PUSHER_FILE_TAG + "_" + abs(activeSessionHolder.getActiveSession().myUserId.hashCode()),
|
||||||
|
lang = localeProvider.current().language,
|
||||||
|
appDisplayName = appNameProvider.getAppName(),
|
||||||
|
deviceDisplayName = getDeviceInfoUseCase.execute().displayName().orEmpty(),
|
||||||
|
url = gateway,
|
||||||
|
enabled = true,
|
||||||
|
deviceId = activeSessionHolder.getActiveSession().sessionParams.deviceId ?: "MOBILE",
|
||||||
|
append = false,
|
||||||
|
withEventIdOnly = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
suspend fun registerEmailForPush(email: String) {
|
||||||
|
TODO()
|
||||||
|
/*
|
||||||
|
val currentSession = activeSessionHolder.getActiveSession()
|
||||||
|
val appName = appNameProvider.getAppName()
|
||||||
|
currentSession.pushersService().addEmailPusher(
|
||||||
|
email = email,
|
||||||
|
lang = localeProvider.current().language,
|
||||||
|
emailBranding = appName,
|
||||||
|
appDisplayName = appName,
|
||||||
|
deviceDisplayName = currentSession.sessionParams.deviceId ?: "MOBILE"
|
||||||
|
)
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPusherForCurrentSession() {}/*: Pusher? {
|
||||||
|
val session = activeSessionHolder.getSafeActiveSession() ?: return null
|
||||||
|
val deviceId = session.sessionParams.deviceId
|
||||||
|
return session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId }
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun unregisterEmailPusher(email: String) {
|
||||||
|
// val currentSession = activeSessionHolder.getSafeActiveSession() ?: return
|
||||||
|
// currentSession.pushersService().removeEmailPusher(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun unregisterPusher(pushKey: String) {
|
||||||
|
// val currentSession = activeSessionHolder.getSafeActiveSession() ?: return
|
||||||
|
// currentSession.pushersService().removeHttpPusher(pushKey, PushConfig.pusher_app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TEST_EVENT_ID = "\$THIS_IS_A_FAKE_EVENT_ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.push.impl.config.PushConfig
|
||||||
|
import org.unifiedpush.android.connector.UnifiedPush
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class RegisterUnifiedPushUseCase @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
sealed interface RegisterUnifiedPushResult {
|
||||||
|
object Success : RegisterUnifiedPushResult
|
||||||
|
object NeedToAskUserForDistributor : RegisterUnifiedPushResult
|
||||||
|
}
|
||||||
|
|
||||||
|
fun execute(distributor: String = ""): RegisterUnifiedPushResult {
|
||||||
|
if (distributor.isNotEmpty()) {
|
||||||
|
saveAndRegisterApp(distributor)
|
||||||
|
return RegisterUnifiedPushResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PushConfig.allowExternalUnifiedPushDistributors) {
|
||||||
|
saveAndRegisterApp(context.packageName)
|
||||||
|
return RegisterUnifiedPushResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UnifiedPush.getDistributor(context).isNotEmpty()) {
|
||||||
|
registerApp()
|
||||||
|
return RegisterUnifiedPushResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
val distributors = UnifiedPush.getDistributors(context)
|
||||||
|
|
||||||
|
return if (distributors.size == 1) {
|
||||||
|
saveAndRegisterApp(distributors.first())
|
||||||
|
RegisterUnifiedPushResult.Success
|
||||||
|
} else {
|
||||||
|
RegisterUnifiedPushResult.NeedToAskUserForDistributor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAndRegisterApp(distributor: String) {
|
||||||
|
UnifiedPush.saveDistributor(context, distributor)
|
||||||
|
registerApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerApp() {
|
||||||
|
UnifiedPush.registerApp(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.element.android.libraries.androidutils.system.getApplicationLabel
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.push.impl.config.PushConfig
|
||||||
|
import io.element.android.libraries.toolbox.api.strings.StringProvider
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.unifiedpush.android.connector.UnifiedPush
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.net.URL
|
||||||
|
import javax.inject.Inject
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
class UnifiedPushHelper @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val unifiedPushStore: UnifiedPushStore,
|
||||||
|
// private val matrix: Matrix,
|
||||||
|
private val fcmHelper: FcmHelper,
|
||||||
|
private val stringProvider: StringProvider,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/* TODO EAx
|
||||||
|
@MainThread
|
||||||
|
fun showSelectDistributorDialog(
|
||||||
|
context: Context,
|
||||||
|
onDistributorSelected: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val internalDistributorName = stringProvider.getString(
|
||||||
|
if (fcmHelper.isFirebaseAvailable()) {
|
||||||
|
StringR.string.unifiedpush_distributor_fcm_fallback
|
||||||
|
} else {
|
||||||
|
StringR.string.unifiedpush_distributor_background_sync
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val distributors = UnifiedPush.getDistributors(context)
|
||||||
|
val distributorsName = distributors.map {
|
||||||
|
if (it == context.packageName) {
|
||||||
|
internalDistributorName
|
||||||
|
} else {
|
||||||
|
context.getApplicationLabel(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(context)
|
||||||
|
.setTitle(stringProvider.getString(StringR.string.unifiedpush_getdistributors_dialog_title))
|
||||||
|
.setItems(distributorsName.toTypedArray()) { _, which ->
|
||||||
|
val distributor = distributors[which]
|
||||||
|
onDistributorSelected(distributor)
|
||||||
|
}
|
||||||
|
.setOnCancelListener {
|
||||||
|
// we do not want to change the distributor on behalf of the user
|
||||||
|
if (UnifiedPush.getDistributor(context).isEmpty()) {
|
||||||
|
// By default, use internal solution (fcm/background sync)
|
||||||
|
onDistributorSelected(context.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setCancelable(true)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class DiscoveryResponse(
|
||||||
|
@SerialName("unifiedpush") val unifiedpush: DiscoveryUnifiedPush = DiscoveryUnifiedPush()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class DiscoveryUnifiedPush(
|
||||||
|
@SerialName("gateway") val gateway: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun storeCustomOrDefaultGateway(
|
||||||
|
endpoint: String,
|
||||||
|
onDoneRunnable: Runnable? = null
|
||||||
|
) {
|
||||||
|
// if we use the embedded distributor,
|
||||||
|
// register app_id type upfcm on sygnal
|
||||||
|
// the pushkey if FCM key
|
||||||
|
if (UnifiedPush.getDistributor(context) == context.packageName) {
|
||||||
|
unifiedPushStore.storePushGateway(PushConfig.pusher_http_url)
|
||||||
|
onDoneRunnable?.run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* TODO EAx UnifiedPush
|
||||||
|
// else, unifiedpush, and pushkey is an endpoint
|
||||||
|
val gateway = PushConfig.default_push_gateway_http_url
|
||||||
|
val parsed = URL(endpoint)
|
||||||
|
val custom = "${parsed.protocol}://${parsed.host}/_matrix/push/v1/notify"
|
||||||
|
Timber.i("Testing $custom")
|
||||||
|
try {
|
||||||
|
val response = matrix.rawService().getUrl(custom, CacheStrategy.NoCache)
|
||||||
|
tryOrNull { Json.decodeFromString<DiscoveryResponse>(response) }
|
||||||
|
?.let { discoveryResponse ->
|
||||||
|
if (discoveryResponse.unifiedpush.gateway == "matrix") {
|
||||||
|
Timber.d("Using custom gateway")
|
||||||
|
unifiedPushStore.storePushGateway(custom)
|
||||||
|
onDoneRunnable?.run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Timber.d(e, "Cannot try custom gateway")
|
||||||
|
}
|
||||||
|
unifiedPushStore.storePushGateway(gateway)
|
||||||
|
onDoneRunnable?.run()
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getExternalDistributors(): List<String> {
|
||||||
|
return UnifiedPush.getDistributors(context)
|
||||||
|
.filterNot { it == context.packageName }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentDistributorName(): String {
|
||||||
|
return when {
|
||||||
|
isEmbeddedDistributor() -> stringProvider.getString(StringR.string.unifiedpush_distributor_fcm_fallback)
|
||||||
|
isBackgroundSync() -> stringProvider.getString(StringR.string.unifiedpush_distributor_background_sync)
|
||||||
|
else -> context.getApplicationLabel(UnifiedPush.getDistributor(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isEmbeddedDistributor(): Boolean {
|
||||||
|
return isInternalDistributor() && fcmHelper.isFirebaseAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isBackgroundSync(): Boolean {
|
||||||
|
return isInternalDistributor() && !fcmHelper.isFirebaseAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isInternalDistributor(): Boolean {
|
||||||
|
return UnifiedPush.getDistributor(context).isEmpty() ||
|
||||||
|
UnifiedPush.getDistributor(context) == context.packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPrivacyFriendlyUpEndpoint(): String? {
|
||||||
|
val endpoint = getEndpointOrToken()
|
||||||
|
if (endpoint.isNullOrEmpty()) return null
|
||||||
|
if (isEmbeddedDistributor()) {
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val parsed = URL(endpoint)
|
||||||
|
"${parsed.protocol}://${parsed.host}/***"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Error parsing unifiedpush endpoint")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getEndpointOrToken(): String? {
|
||||||
|
return if (isEmbeddedDistributor()) fcmHelper.getFcmToken()
|
||||||
|
else unifiedPushStore.getEndpoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPushGateway(): String? {
|
||||||
|
return if (isEmbeddedDistributor()) PushConfig.pusher_http_url
|
||||||
|
else unifiedPushStore.getPushGateway()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.di.DefaultPreferences
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO EAx Store in BDD (for multisession)
|
||||||
|
*/
|
||||||
|
class UnifiedPushStore @Inject constructor(
|
||||||
|
@ApplicationContext val context: Context,
|
||||||
|
@DefaultPreferences private val defaultPrefs: SharedPreferences,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Retrieves the UnifiedPush Endpoint.
|
||||||
|
*
|
||||||
|
* @return the UnifiedPush Endpoint or null if not received
|
||||||
|
*/
|
||||||
|
fun getEndpoint(): String? {
|
||||||
|
return defaultPrefs.getString(PREFS_ENDPOINT_OR_TOKEN, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store UnifiedPush Endpoint to the SharedPrefs.
|
||||||
|
*
|
||||||
|
* @param endpoint the endpoint to store
|
||||||
|
*/
|
||||||
|
fun storeUpEndpoint(endpoint: String?) {
|
||||||
|
defaultPrefs.edit {
|
||||||
|
putString(PREFS_ENDPOINT_OR_TOKEN, endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the Push Gateway.
|
||||||
|
*
|
||||||
|
* @return the Push Gateway or null if not defined
|
||||||
|
*/
|
||||||
|
fun getPushGateway(): String? {
|
||||||
|
return defaultPrefs.getString(PREFS_PUSH_GATEWAY, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store Push Gateway to the SharedPrefs.
|
||||||
|
*
|
||||||
|
* @param gateway the push gateway to store
|
||||||
|
*/
|
||||||
|
fun storePushGateway(gateway: String?) {
|
||||||
|
defaultPrefs.edit {
|
||||||
|
putString(PREFS_PUSH_GATEWAY, gateway)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_ENDPOINT_OR_TOKEN = "UP_ENDPOINT_OR_TOKEN"
|
||||||
|
private const val PREFS_PUSH_GATEWAY = "PUSH_GATEWAY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.push.api.model.BackgroundSyncMode
|
||||||
|
import io.element.android.libraries.push.api.store.PushDataStore
|
||||||
|
import org.unifiedpush.android.connector.UnifiedPush
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class UnregisterUnifiedPushUseCase @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val pushDataStore: PushDataStore,
|
||||||
|
private val unifiedPushStore: UnifiedPushStore,
|
||||||
|
private val unifiedPushHelper: UnifiedPushHelper,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun execute(pushersManager: PushersManager?) {
|
||||||
|
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
|
||||||
|
pushDataStore.setFdroidSyncBackgroundMode(mode)
|
||||||
|
try {
|
||||||
|
unifiedPushHelper.getEndpointOrToken()?.let {
|
||||||
|
Timber.d("Removing $it")
|
||||||
|
pushersManager?.unregisterPusher(it)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.d(e, "Probably unregistering a non existing pusher")
|
||||||
|
}
|
||||||
|
unifiedPushStore.storeUpEndpoint(null)
|
||||||
|
unifiedPushStore.storePushGateway(null)
|
||||||
|
UnifiedPush.unregisterApp(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
|
import io.element.android.libraries.push.api.store.PushDataStore
|
||||||
|
import io.element.android.libraries.push.impl.config.PushConfig
|
||||||
|
import io.element.android.libraries.push.impl.di.FirebaseMessagingServiceBindings
|
||||||
|
import io.element.android.libraries.push.impl.parser.PushParser
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private val loggerTag = LoggerTag("Push", LoggerTag.SYNC)
|
||||||
|
|
||||||
|
class VectorFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
@Inject lateinit var fcmHelper: FcmHelper
|
||||||
|
@Inject lateinit var pushDataStore: PushDataStore
|
||||||
|
// @Inject lateinit var activeSessionHolder: ActiveSessionHolder
|
||||||
|
@Inject lateinit var pushersManager: PushersManager
|
||||||
|
@Inject lateinit var pushParser: PushParser
|
||||||
|
@Inject lateinit var vectorPushHandler: VectorPushHandler
|
||||||
|
@Inject lateinit var unifiedPushHelper: UnifiedPushHelper
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
applicationContext.bindings<FirebaseMessagingServiceBindings>().inject(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
Timber.tag(loggerTag.value).d("New Firebase token")
|
||||||
|
fcmHelper.storeFcmToken(token)
|
||||||
|
if (
|
||||||
|
pushDataStore.areNotificationEnabledForDevice() &&
|
||||||
|
// TODO EAx activeSessionHolder.hasActiveSession() &&
|
||||||
|
unifiedPushHelper.isEmbeddedDistributor()
|
||||||
|
) {
|
||||||
|
pushersManager.enqueueRegisterPusher(token, PushConfig.pusher_http_url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
Timber.tag(loggerTag.value).d("New Firebase message")
|
||||||
|
pushParser.parsePushDataFcm(message.data).let {
|
||||||
|
vectorPushHandler.handle(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
import io.element.android.libraries.androidutils.network.WifiDetector
|
||||||
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.push.api.store.PushDataStore
|
||||||
|
import io.element.android.libraries.push.impl.model.PushData
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
|
||||||
|
import io.element.android.libraries.push.impl.store.DefaultPushDataStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private val loggerTag = LoggerTag("Push", LoggerTag.SYNC)
|
||||||
|
|
||||||
|
class VectorPushHandler @Inject constructor(
|
||||||
|
private val notificationDrawerManager: NotificationDrawerManager,
|
||||||
|
private val notifiableEventResolver: NotifiableEventResolver,
|
||||||
|
// private val activeSessionHolder: ActiveSessionHolder,
|
||||||
|
private val pushDataStore: PushDataStore,
|
||||||
|
private val defaultPushDataStore: DefaultPushDataStore,
|
||||||
|
private val actionIds: NotificationActionIds,
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val buildMeta: BuildMeta
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val coroutineScope = CoroutineScope(SupervisorJob())
|
||||||
|
private val wifiDetector: WifiDetector = WifiDetector(context)
|
||||||
|
|
||||||
|
// UI handler
|
||||||
|
private val mUIHandler by lazy {
|
||||||
|
Handler(Looper.getMainLooper())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when message is received.
|
||||||
|
*
|
||||||
|
* @param pushData the data received in the push.
|
||||||
|
*/
|
||||||
|
fun handle(pushData: PushData) {
|
||||||
|
Timber.tag(loggerTag.value).d("## handling pushData")
|
||||||
|
|
||||||
|
if (buildMeta.lowPrivacyLoggingEnabled) {
|
||||||
|
Timber.tag(loggerTag.value).d("## pushData: $pushData")
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
defaultPushDataStore.incrementPushCounter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic Push
|
||||||
|
if (pushData.eventId == PushersManager.TEST_EVENT_ID) {
|
||||||
|
val intent = Intent(actionIds.push)
|
||||||
|
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pushDataStore.areNotificationEnabledForDevice()) {
|
||||||
|
Timber.tag(loggerTag.value).i("Notification are disabled for this device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mUIHandler.post {
|
||||||
|
if (ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||||
|
// we are in foreground, let the sync do the things?
|
||||||
|
Timber.tag(loggerTag.value).d("PUSH received in a foreground state, ignore")
|
||||||
|
} else {
|
||||||
|
coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal receive method.
|
||||||
|
*
|
||||||
|
* @param pushData Object containing message data.
|
||||||
|
*/
|
||||||
|
private suspend fun handleInternal(pushData: PushData) {
|
||||||
|
try {
|
||||||
|
if (buildMeta.lowPrivacyLoggingEnabled) {
|
||||||
|
Timber.tag(loggerTag.value).d("## handleInternal() : $pushData")
|
||||||
|
} else {
|
||||||
|
Timber.tag(loggerTag.value).d("## handleInternal()")
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO EAx
|
||||||
|
val session = activeSessionHolder.getOrInitializeSession()
|
||||||
|
|
||||||
|
if (session == null) {
|
||||||
|
Timber.tag(loggerTag.value).w("## Can't sync from push, no current session")
|
||||||
|
} else {
|
||||||
|
if (isEventAlreadyKnown(pushData)) {
|
||||||
|
Timber.tag(loggerTag.value).d("Ignoring push, event already known")
|
||||||
|
} else {
|
||||||
|
// Try to get the Event content faster
|
||||||
|
Timber.tag(loggerTag.value).d("Requesting event in fast lane")
|
||||||
|
getEventFastLane(session, pushData)
|
||||||
|
|
||||||
|
Timber.tag(loggerTag.value).d("Requesting background sync")
|
||||||
|
session.syncService().requireBackgroundSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO EAx
|
||||||
|
private suspend fun getEventFastLane(session: Session, pushData: PushData) {
|
||||||
|
pushData.roomId ?: return
|
||||||
|
pushData.eventId ?: return
|
||||||
|
|
||||||
|
if (wifiDetector.isConnectedToWifi().not()) {
|
||||||
|
Timber.tag(loggerTag.value).d("No WiFi network, do not get Event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.tag(loggerTag.value).d("Fast lane: start request")
|
||||||
|
val event = tryOrNull { session.eventService().getEvent(pushData.roomId, pushData.eventId) } ?: return
|
||||||
|
|
||||||
|
val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event, canBeReplaced = true)
|
||||||
|
|
||||||
|
if (resolvedEvent is NotifiableMessageEvent) {
|
||||||
|
// If the room is currently displayed, we will not show a notification, so no need to get the Event faster
|
||||||
|
if (notificationDrawerManager.shouldIgnoreMessageEventInRoom(resolvedEvent)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedEvent
|
||||||
|
?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") }
|
||||||
|
?.let {
|
||||||
|
notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(resolvedEvent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
// check if the event was not yet received
|
||||||
|
// a previous catchup might have already retrieved the notified event
|
||||||
|
private fun isEventAlreadyKnown(pushData: PushData): Boolean {
|
||||||
|
/* TODO EAx
|
||||||
|
if (pushData.eventId != null && pushData.roomId != null) {
|
||||||
|
try {
|
||||||
|
val session = activeSessionHolder.getSafeActiveSession() ?: return false
|
||||||
|
val room = session.getRoom(pushData.roomId) ?: return false
|
||||||
|
return room.getTimelineEvent(pushData.eventId) != null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(loggerTag.value).e(e, "## isEventAlreadyKnown() : failed to check if the event was already defined")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.widget.Toast
|
||||||
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
|
import io.element.android.libraries.push.api.model.BackgroundSyncMode
|
||||||
|
import io.element.android.libraries.push.api.store.PushDataStore
|
||||||
|
import io.element.android.libraries.push.impl.di.VectorUnifiedPushMessagingReceiverBindings
|
||||||
|
import io.element.android.libraries.push.impl.parser.PushParser
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.unifiedpush.android.connector.MessagingReceiver
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private val loggerTag = LoggerTag("Push", LoggerTag.SYNC)
|
||||||
|
|
||||||
|
class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
|
||||||
|
@Inject lateinit var pushersManager: PushersManager
|
||||||
|
@Inject lateinit var pushParser: PushParser
|
||||||
|
|
||||||
|
//@Inject lateinit var activeSessionHolder: ActiveSessionHolder
|
||||||
|
@Inject lateinit var pushDataStore: PushDataStore
|
||||||
|
@Inject lateinit var vectorPushHandler: VectorPushHandler
|
||||||
|
@Inject lateinit var guardServiceStarter: GuardServiceStarter
|
||||||
|
@Inject lateinit var unifiedPushStore: UnifiedPushStore
|
||||||
|
@Inject lateinit var unifiedPushHelper: UnifiedPushHelper
|
||||||
|
|
||||||
|
private val coroutineScope = CoroutineScope(SupervisorJob())
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
super.onReceive(context, intent)
|
||||||
|
// Inject
|
||||||
|
context.applicationContext.bindings<VectorUnifiedPushMessagingReceiverBindings>().inject(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when message is received.
|
||||||
|
*
|
||||||
|
* @param context the Android context
|
||||||
|
* @param message the message
|
||||||
|
* @param instance connection, for multi-account
|
||||||
|
*/
|
||||||
|
override fun onMessage(context: Context, message: ByteArray, instance: String) {
|
||||||
|
Timber.tag(loggerTag.value).d("New message")
|
||||||
|
pushParser.parsePushDataUnifiedPush(message)?.let {
|
||||||
|
vectorPushHandler.handle(it)
|
||||||
|
} ?: run {
|
||||||
|
Timber.tag(loggerTag.value).w("Invalid received data Json format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||||
|
Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint")
|
||||||
|
if (pushDataStore.areNotificationEnabledForDevice() /* TODO EAx && activeSessionHolder.hasActiveSession() */) {
|
||||||
|
// If the endpoint has changed
|
||||||
|
// or the gateway has changed
|
||||||
|
if (unifiedPushHelper.getEndpointOrToken() != endpoint) {
|
||||||
|
unifiedPushStore.storeUpEndpoint(endpoint)
|
||||||
|
coroutineScope.launch {
|
||||||
|
unifiedPushHelper.storeCustomOrDefaultGateway(endpoint) {
|
||||||
|
unifiedPushHelper.getPushGateway()?.let {
|
||||||
|
pushersManager.enqueueRegisterPusher(endpoint, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.tag(loggerTag.value).i("onNewEndpoint: skipped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED
|
||||||
|
pushDataStore.setFdroidSyncBackgroundMode(mode)
|
||||||
|
guardServiceStarter.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRegistrationFailed(context: Context, instance: String) {
|
||||||
|
Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show()
|
||||||
|
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
|
||||||
|
pushDataStore.setFdroidSyncBackgroundMode(mode)
|
||||||
|
guardServiceStarter.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnregistered(context: Context, instance: String) {
|
||||||
|
Timber.tag(loggerTag.value).d("Unifiedpush: Unregistered")
|
||||||
|
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
|
||||||
|
pushDataStore.setFdroidSyncBackgroundMode(mode)
|
||||||
|
guardServiceStarter.start()
|
||||||
|
runBlocking {
|
||||||
|
try {
|
||||||
|
pushersManager.unregisterPusher(unifiedPushHelper.getEndpointOrToken().orEmpty())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(loggerTag.value).d("Probably unregistering a non existing pusher")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.config
|
||||||
|
|
||||||
|
object PushConfig {
|
||||||
|
/**
|
||||||
|
* It is the push gateway for FCM embedded distributor.
|
||||||
|
* Note: pusher_http_url should have path '/_matrix/push/v1/notify' -->
|
||||||
|
*/
|
||||||
|
const val pusher_http_url: String = "https://matrix.org/_matrix/push/v1/notify"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It is the push gateway for UnifiedPush.
|
||||||
|
* Note: default_push_gateway_http_url should have path '/_matrix/push/v1/notify'
|
||||||
|
*/
|
||||||
|
const val default_push_gateway_http_url: String = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: pusher_app_id cannot exceed 64 chars.
|
||||||
|
*/
|
||||||
|
const val pusher_app_id: String = "im.vector.app.android"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set to true to allow external push distributor such as Ntfy.
|
||||||
|
*/
|
||||||
|
const val allowExternalUnifiedPushDistributors: Boolean = false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.di
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.push.impl.VectorFirebaseMessagingService
|
||||||
|
|
||||||
|
@ContributesTo(AppScope::class)
|
||||||
|
interface FirebaseMessagingServiceBindings {
|
||||||
|
fun inject(service: VectorFirebaseMessagingService)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.di
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.push.impl.VectorUnifiedPushMessagingReceiver
|
||||||
|
|
||||||
|
@ContributesTo(AppScope::class)
|
||||||
|
interface VectorUnifiedPushMessagingReceiverBindings {
|
||||||
|
fun inject(receiver: VectorUnifiedPushMessagingReceiver)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represent parsed data that the app has received from a Push content.
|
||||||
|
*
|
||||||
|
* @property eventId The Event ID. If not null, it will not be empty, and will have a valid format.
|
||||||
|
* @property roomId The Room ID. If not null, it will not be empty, and will have a valid format.
|
||||||
|
* @property unread Number of unread message.
|
||||||
|
*/
|
||||||
|
data class PushData(
|
||||||
|
val eventId: String?,
|
||||||
|
val roomId: String?,
|
||||||
|
val unread: Int?,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl.model
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In this case, the format is:
|
||||||
|
* <pre>
|
||||||
|
* {
|
||||||
|
* "event_id":"$anEventId",
|
||||||
|
* "room_id":"!aRoomId",
|
||||||
|
* "unread":"1",
|
||||||
|
* "prio":"high"
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
* .
|
||||||
|
*/
|
||||||
|
data class PushDataFcm(
|
||||||
|
val eventId: String?,
|
||||||
|
val roomId: String?,
|
||||||
|
var unread: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun PushDataFcm.toPushData() = PushData(
|
||||||
|
eventId = eventId?.takeIf { MatrixPatterns.isEventId(it) },
|
||||||
|
roomId = roomId?.takeIf { MatrixPatterns.isRoomId(it) },
|
||||||
|
unread = unread
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl.model
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In this case, the format is:
|
||||||
|
* <pre>
|
||||||
|
* {
|
||||||
|
* "notification":{
|
||||||
|
* "event_id":"$anEventId",
|
||||||
|
* "room_id":"!aRoomId",
|
||||||
|
* "counts":{
|
||||||
|
* "unread":1
|
||||||
|
* },
|
||||||
|
* "prio":"high"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
* .
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PushDataUnifiedPush(
|
||||||
|
val notification: PushDataUnifiedPushNotification?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PushDataUnifiedPushNotification(
|
||||||
|
@SerialName("event_id") val eventId: String?,
|
||||||
|
@SerialName("room_id") val roomId: String?,
|
||||||
|
@SerialName("counts") var counts: PushDataUnifiedPushCounts?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PushDataUnifiedPushCounts(
|
||||||
|
@SerialName("unread") val unread: Int?
|
||||||
|
)
|
||||||
|
|
||||||
|
fun PushDataUnifiedPush.toPushData() = PushData(
|
||||||
|
eventId = notification?.eventId?.takeIf { MatrixPatterns.isEventId(it) },
|
||||||
|
roomId = notification?.roomId?.takeIf { MatrixPatterns.isRoomId(it) },
|
||||||
|
unread = notification?.counts?.unread
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class FilteredEventDetector @Inject constructor(
|
||||||
|
//private val activeSessionDataSource: ActiveSessionDataSource
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given event should be ignored.
|
||||||
|
* Used to skip notifications if a non expected message is received.
|
||||||
|
*/
|
||||||
|
fun shouldBeIgnored(notifiableEvent: NotifiableEvent): Boolean {
|
||||||
|
/* TODO EAx
|
||||||
|
val session = activeSessionDataSource.currentValue?.orNull() ?: return false
|
||||||
|
|
||||||
|
if (notifiableEvent is NotifiableMessageEvent) {
|
||||||
|
val room = session.getRoom(notifiableEvent.roomId) ?: return false
|
||||||
|
val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false
|
||||||
|
return timelineEvent.shouldBeIgnored()
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the timeline event should be ignored.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
private fun TimelineEvent.shouldBeIgnored(): Boolean {
|
||||||
|
if (root.isVoiceMessage()) {
|
||||||
|
val audioEvent = root.asMessageAudioEvent()
|
||||||
|
// if the event is a voice message related to a voice broadcast, only show the event on the first chunk.
|
||||||
|
return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import io.element.android.libraries.push.impl.AutoAcceptInvites
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private typealias ProcessedEvents = List<ProcessedEvent<NotifiableEvent>>
|
||||||
|
|
||||||
|
class NotifiableEventProcessor @Inject constructor(
|
||||||
|
private val outdatedDetector: OutdatedEventDetector,
|
||||||
|
private val autoAcceptInvites: AutoAcceptInvites
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun process(queuedEvents: List<NotifiableEvent>, currentRoomId: String?, currentThreadId: String?, renderedEvents: ProcessedEvents): ProcessedEvents {
|
||||||
|
val processedEvents = queuedEvents.map {
|
||||||
|
val type = when (it) {
|
||||||
|
is InviteNotifiableEvent -> if (autoAcceptInvites.hideInvites) ProcessedEvent.Type.REMOVE else ProcessedEvent.Type.KEEP
|
||||||
|
is NotifiableMessageEvent -> when {
|
||||||
|
it.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) -> {
|
||||||
|
ProcessedEvent.Type.REMOVE
|
||||||
|
.also { Timber.d("notification message removed due to currently viewing the same room or thread") }
|
||||||
|
}
|
||||||
|
outdatedDetector.isMessageOutdated(it) -> ProcessedEvent.Type.REMOVE
|
||||||
|
.also { Timber.d("notification message removed due to being read") }
|
||||||
|
else -> ProcessedEvent.Type.KEEP
|
||||||
|
}
|
||||||
|
is SimpleNotifiableEvent -> when (it.type) {
|
||||||
|
/*EventType.REDACTION*/ "m.room.redaction" -> ProcessedEvent.Type.REMOVE
|
||||||
|
else -> ProcessedEvent.Type.KEEP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProcessedEvent(type, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
val removedEventsDiff = renderedEvents.filter { renderedEvent ->
|
||||||
|
queuedEvents.none { it.eventId == renderedEvent.event.eventId }
|
||||||
|
}.map { ProcessedEvent(ProcessedEvent.Type.REMOVE, it.event) }
|
||||||
|
|
||||||
|
return removedEventsDiff + processedEvents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
/*
|
||||||
|
* 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.toolbox.api.strings.StringProvider
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
import io.element.android.libraries.toolbox.api.systemclock.SystemClock
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 @Inject constructor(
|
||||||
|
private val stringProvider: StringProvider,
|
||||||
|
// private val noticeEventFormatter: NoticeEventFormatter,
|
||||||
|
// private val displayableEventFormatter: DisplayableEventFormatter,
|
||||||
|
private val clock: SystemClock,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun resolveEvent(/*event: Event, session: Session, isNoisy: Boolean*/): NotifiableEvent? {
|
||||||
|
return TODO()
|
||||||
|
/*
|
||||||
|
val roomID = event.roomId ?: return null
|
||||||
|
val eventId = event.eventId ?: return null
|
||||||
|
if (event.getClearType() == EventType.STATE_ROOM_MEMBER) {
|
||||||
|
return resolveStateRoomEvent(event, session, canBeReplaced = false, isNoisy = isNoisy)
|
||||||
|
}
|
||||||
|
val timelineEvent = session.getRoom(roomID)?.getTimelineEvent(eventId) ?: return null
|
||||||
|
return when {
|
||||||
|
event.supportsNotification() || event.type == EventType.ENCRYPTED -> {
|
||||||
|
resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// If the event can be displayed, display it as is
|
||||||
|
Timber.w("NotifiableEventResolver Received an unsupported event matching a bing rule")
|
||||||
|
// TODO Better event text display
|
||||||
|
val bodyPreview = event.type ?: EventType.MISSING_TYPE
|
||||||
|
|
||||||
|
SimpleNotifiableEvent(
|
||||||
|
session.myUserId,
|
||||||
|
eventId = event.eventId!!,
|
||||||
|
editedEventId = timelineEvent.getEditedEventId(),
|
||||||
|
noisy = false, // will be updated
|
||||||
|
timestamp = event.originServerTs ?: clock.epochMillis(),
|
||||||
|
description = bodyPreview,
|
||||||
|
title = stringProvider.getString(StringR.string.notification_unknown_new_event),
|
||||||
|
soundName = null,
|
||||||
|
type = event.type,
|
||||||
|
canBeReplaced = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resolveInMemoryEvent(/*session: Session, event: Event, canBeReplaced: Boolean*/): NotifiableEvent? {
|
||||||
|
TODO()
|
||||||
|
/*
|
||||||
|
if (!event.supportsNotification()) return null
|
||||||
|
|
||||||
|
// Ignore message edition
|
||||||
|
if (event.isEdition()) return null
|
||||||
|
|
||||||
|
val actions = session.pushRuleService().getActions(event)
|
||||||
|
val notificationAction = actions.toNotificationAction()
|
||||||
|
|
||||||
|
return if (notificationAction.shouldNotify) {
|
||||||
|
val user = session.getUserOrDefault(event.senderId!!)
|
||||||
|
|
||||||
|
val timelineEvent = TimelineEvent(
|
||||||
|
root = event,
|
||||||
|
localId = -1,
|
||||||
|
eventId = event.eventId!!,
|
||||||
|
displayIndex = 0,
|
||||||
|
senderInfo = SenderInfo(
|
||||||
|
userId = user.userId,
|
||||||
|
displayName = user.toMatrixItem().getBestName(),
|
||||||
|
isUniqueDisplayName = true,
|
||||||
|
avatarUrl = user.avatarUrl
|
||||||
|
)
|
||||||
|
)
|
||||||
|
resolveMessageEvent(timelineEvent, session, canBeReplaced = canBeReplaced, isNoisy = !notificationAction.soundName.isNullOrBlank())
|
||||||
|
} else {
|
||||||
|
Timber.d("Matched push rule is set to not notify")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveMessageEvent(/*event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean*/): NotifiableMessageEvent? {
|
||||||
|
TODO()
|
||||||
|
/*
|
||||||
|
// 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 = session.getRoom(event.root.roomId!! /*roomID cannot be null*/)
|
||||||
|
|
||||||
|
return if (room == null) {
|
||||||
|
Timber.e("## Unable to resolve room for eventId [$event]")
|
||||||
|
// Ok room is not known in store, but we can still display something
|
||||||
|
val body = displayableEventFormatter.format(event, isDm = false, appendAuthor = false)
|
||||||
|
val roomName = stringProvider.getString(StringR.string.notification_unknown_room_name)
|
||||||
|
val senderDisplayName = event.senderInfo.disambiguatedDisplayName
|
||||||
|
|
||||||
|
NotifiableMessageEvent(
|
||||||
|
eventId = event.root.eventId!!,
|
||||||
|
editedEventId = event.getEditedEventId(),
|
||||||
|
canBeReplaced = canBeReplaced,
|
||||||
|
timestamp = event.root.originServerTs ?: 0,
|
||||||
|
noisy = isNoisy,
|
||||||
|
senderName = senderDisplayName,
|
||||||
|
senderId = event.root.senderId,
|
||||||
|
body = body.toString(),
|
||||||
|
imageUriString = event.fetchImageIfPresent(session)?.toString(),
|
||||||
|
roomId = event.root.roomId!!,
|
||||||
|
threadId = event.root.getRootThreadEventId(),
|
||||||
|
roomName = roomName,
|
||||||
|
matrixID = session.myUserId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
event.attemptToDecryptIfNeeded(session)
|
||||||
|
// only convert encrypted messages to NotifiableMessageEvents
|
||||||
|
when {
|
||||||
|
event.root.supportsNotification() -> {
|
||||||
|
val body = displayableEventFormatter.format(event, isDm = room.roomSummary()?.isDirect.orFalse(), appendAuthor = false).toString()
|
||||||
|
val roomName = room.roomSummary()?.displayName ?: ""
|
||||||
|
val senderDisplayName = event.senderInfo.disambiguatedDisplayName
|
||||||
|
|
||||||
|
NotifiableMessageEvent(
|
||||||
|
eventId = event.root.eventId!!,
|
||||||
|
editedEventId = event.getEditedEventId(),
|
||||||
|
canBeReplaced = canBeReplaced,
|
||||||
|
timestamp = event.root.originServerTs ?: 0,
|
||||||
|
noisy = isNoisy,
|
||||||
|
senderName = senderDisplayName,
|
||||||
|
senderId = event.root.senderId,
|
||||||
|
body = body,
|
||||||
|
imageUriString = event.fetchImageIfPresent(session)?.toString(),
|
||||||
|
roomId = event.root.roomId!!,
|
||||||
|
threadId = event.root.getRootThreadEventId(),
|
||||||
|
roomName = roomName,
|
||||||
|
roomIsDirect = room.roomSummary()?.isDirect ?: false,
|
||||||
|
roomAvatarPath = session.contentUrlResolver()
|
||||||
|
.resolveThumbnail(
|
||||||
|
room.roomSummary()?.avatarUrl,
|
||||||
|
250,
|
||||||
|
250,
|
||||||
|
ContentUrlResolver.ThumbnailMethod.SCALE
|
||||||
|
),
|
||||||
|
senderAvatarPath = session.contentUrlResolver()
|
||||||
|
.resolveThumbnail(
|
||||||
|
event.senderInfo.avatarUrl,
|
||||||
|
250,
|
||||||
|
250,
|
||||||
|
ContentUrlResolver.ThumbnailMethod.SCALE
|
||||||
|
),
|
||||||
|
matrixID = session.myUserId,
|
||||||
|
soundName = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
private suspend fun TimelineEvent.attemptToDecryptIfNeeded(session: Session) {
|
||||||
|
if (root.isEncrypted() && root.mxDecryptionResult == null) {
|
||||||
|
// TODO use a global event decryptor? attache to session and that listen to new sessionId?
|
||||||
|
// for now decrypt sync
|
||||||
|
try {
|
||||||
|
val result = session.cryptoService().decryptEvent(root, root.roomId + UUID.randomUUID().toString())
|
||||||
|
root.mxDecryptionResult = OlmDecryptionResult(
|
||||||
|
payload = result.clearEvent,
|
||||||
|
senderKey = result.senderCurve25519Key,
|
||||||
|
keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) },
|
||||||
|
forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain,
|
||||||
|
isSafe = result.isSafe
|
||||||
|
)
|
||||||
|
} catch (ignore: MXCryptoError) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
private suspend fun TimelineEvent.fetchImageIfPresent(session: Session): Uri? {
|
||||||
|
return when {
|
||||||
|
root.isEncrypted() && root.mxDecryptionResult == null -> null
|
||||||
|
root.isImageMessage() -> downloadAndExportImage(session)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
private suspend fun TimelineEvent.downloadAndExportImage(session: Session): Uri? {
|
||||||
|
return kotlin.runCatching {
|
||||||
|
getVectorLastMessageContent()?.takeAs<MessageWithAttachmentContent>()?.let { imageMessage ->
|
||||||
|
val fileService = session.fileService()
|
||||||
|
fileService.downloadFile(imageMessage)
|
||||||
|
fileService.getTemporarySharableURI(imageMessage)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed to download and export image for notification")
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
private fun resolveStateRoomEvent(event: Event, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent? {
|
||||||
|
val content = event.content?.toModel<RoomMemberContent>() ?: return null
|
||||||
|
val roomId = event.roomId ?: return null
|
||||||
|
val dName = event.senderId?.let { session.roomService().getRoomMember(it, roomId)?.displayName }
|
||||||
|
if (Membership.INVITE == content.membership) {
|
||||||
|
val roomSummary = session.getRoomSummary(roomId)
|
||||||
|
val body = noticeEventFormatter.format(event, dName, isDm = roomSummary?.isDirect.orFalse())
|
||||||
|
?: stringProvider.getString(StringR.string.notification_new_invitation)
|
||||||
|
return InviteNotifiableEvent(
|
||||||
|
session.myUserId,
|
||||||
|
eventId = event.eventId!!,
|
||||||
|
editedEventId = null,
|
||||||
|
canBeReplaced = canBeReplaced,
|
||||||
|
roomId = roomId,
|
||||||
|
roomName = roomSummary?.displayName,
|
||||||
|
timestamp = event.originServerTs ?: 0,
|
||||||
|
noisy = isNoisy,
|
||||||
|
title = stringProvider.getString(StringR.string.notification_new_invitation),
|
||||||
|
description = body.toString(),
|
||||||
|
soundName = null, // will be set later
|
||||||
|
type = event.getClearType()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.e("## unsupported notifiable event for eventId [${event.eventId}]")
|
||||||
|
if (buildMeta.lowPrivacyLoggingEnabled) {
|
||||||
|
Timber.e("## unsupported notifiable event for event [$event]")
|
||||||
|
}
|
||||||
|
// TODO generic handling?
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
data class NotificationAction(
|
||||||
|
val shouldNotify: Boolean,
|
||||||
|
val highlight: Boolean,
|
||||||
|
val soundName: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
fun List<Action>.toNotificationAction(): NotificationAction {
|
||||||
|
var shouldNotify = false
|
||||||
|
var highlight = false
|
||||||
|
var sound: String? = null
|
||||||
|
forEach { action ->
|
||||||
|
when (action) {
|
||||||
|
is Action.Notify -> shouldNotify = true
|
||||||
|
is Action.DoNotNotify -> shouldNotify = false
|
||||||
|
is Action.Highlight -> highlight = action.highlight
|
||||||
|
is Action.Sound -> sound = action.sound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NotificationAction(shouldNotify, highlight, sound)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Util class for creating notifications.
|
||||||
|
* Note: Cannot inject ColorProvider in the constructor, because it requires an Activity
|
||||||
|
*/
|
||||||
|
|
||||||
|
data class NotificationActionIds @Inject constructor(
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION"
|
||||||
|
val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_ACTION"
|
||||||
|
val quickLaunch = "${buildMeta.applicationId}.NotificationActions.QUICK_LAUNCH_ACTION"
|
||||||
|
val markRoomRead = "${buildMeta.applicationId}.NotificationActions.MARK_ROOM_READ_ACTION"
|
||||||
|
val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION"
|
||||||
|
val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION"
|
||||||
|
val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION"
|
||||||
|
val tapToView = "${buildMeta.applicationId}.NotificationActions.TAP_TO_VIEW_ACTION"
|
||||||
|
val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
|
||||||
|
val push = "${buildMeta.applicationId}.PUSH"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
/*
|
||||||
|
* 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class NotificationBitmapLoader @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon of a room.
|
||||||
|
*/
|
||||||
|
@WorkerThread
|
||||||
|
fun getRoomBitmap(path: String?): Bitmap? {
|
||||||
|
if (path == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return loadRoomBitmap(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun loadRoomBitmap(path: String): Bitmap? {
|
||||||
|
return try {
|
||||||
|
null
|
||||||
|
/* TODO Notification
|
||||||
|
Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.load(path)
|
||||||
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
|
.signature(ObjectKey("room-icon-notification"))
|
||||||
|
.submit()
|
||||||
|
.get()
|
||||||
|
*/
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "decodeFile failed")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon of a user.
|
||||||
|
* Before Android P, this does nothing because the icon won't be used
|
||||||
|
*/
|
||||||
|
@WorkerThread
|
||||||
|
fun getUserIcon(path: String?): IconCompat? {
|
||||||
|
if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadUserIcon(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun loadUserIcon(path: String): IconCompat? {
|
||||||
|
return try {
|
||||||
|
null
|
||||||
|
/* TODO Notification
|
||||||
|
val bitmap = Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.load(path)
|
||||||
|
.transform(CircleCrop())
|
||||||
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
|
.signature(ObjectKey("user-icon-notification"))
|
||||||
|
.submit()
|
||||||
|
.get()
|
||||||
|
IconCompat.createWithBitmap(bitmap)
|
||||||
|
*/
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "decodeFile failed")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
/*
|
||||||
|
* 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
import io.element.android.libraries.analytics.api.AnalyticsTracker
|
||||||
|
import io.element.android.libraries.analytics.api.plan.JoinedRoom
|
||||||
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
import io.element.android.libraries.toolbox.api.systemclock.SystemClock
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.).
|
||||||
|
*/
|
||||||
|
class NotificationBroadcastReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var notificationDrawerManager: NotificationDrawerManager
|
||||||
|
//@Inject lateinit var activeSessionHolder: ActiveSessionHolder
|
||||||
|
@Inject lateinit var analyticsTracker: AnalyticsTracker
|
||||||
|
@Inject lateinit var clock: SystemClock
|
||||||
|
@Inject lateinit var actionIds: NotificationActionIds
|
||||||
|
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent == null || context == null) return
|
||||||
|
context.bindings<NotificationBroadcastReceiverBindings>().inject(this)
|
||||||
|
Timber.v("NotificationBroadcastReceiver received : $intent")
|
||||||
|
when (intent.action) {
|
||||||
|
actionIds.smartReply ->
|
||||||
|
handleSmartReply(intent, context)
|
||||||
|
actionIds.dismissRoom ->
|
||||||
|
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
|
||||||
|
notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) }
|
||||||
|
}
|
||||||
|
actionIds.dismissSummary ->
|
||||||
|
notificationDrawerManager.clearAllEvents()
|
||||||
|
actionIds.markRoomRead ->
|
||||||
|
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
|
||||||
|
notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) }
|
||||||
|
handleMarkAsRead(roomId)
|
||||||
|
}
|
||||||
|
actionIds.join -> {
|
||||||
|
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
|
||||||
|
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) }
|
||||||
|
handleJoinRoom(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actionIds.reject -> {
|
||||||
|
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
|
||||||
|
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) }
|
||||||
|
handleRejectRoom(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleJoinRoom(roomId: String) {
|
||||||
|
/*
|
||||||
|
activeSessionHolder.getSafeActiveSession()?.let { session ->
|
||||||
|
val room = session.getRoom(roomId)
|
||||||
|
if (room != null) {
|
||||||
|
session.coroutineScope.launch {
|
||||||
|
tryOrNull {
|
||||||
|
session.roomService().joinRoom(room.roomId)
|
||||||
|
analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom(JoinedRoom.Trigger.Notification))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRejectRoom(roomId: String) {
|
||||||
|
/*
|
||||||
|
activeSessionHolder.getSafeActiveSession()?.let { session ->
|
||||||
|
session.coroutineScope.launch {
|
||||||
|
tryOrNull { session.roomService().leaveRoom(roomId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMarkAsRead(roomId: String) {
|
||||||
|
/*
|
||||||
|
activeSessionHolder.getActiveSession().let { session ->
|
||||||
|
val room = session.getRoom(roomId)
|
||||||
|
if (room != null) {
|
||||||
|
session.coroutineScope.launch {
|
||||||
|
tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSmartReply(intent: Intent, context: Context) {
|
||||||
|
val message = getReplyMessage(intent)
|
||||||
|
val roomId = intent.getStringExtra(KEY_ROOM_ID)
|
||||||
|
val threadId = intent.getStringExtra(KEY_THREAD_ID)
|
||||||
|
|
||||||
|
if (message.isNullOrBlank() || roomId.isNullOrBlank()) {
|
||||||
|
// ignore this event
|
||||||
|
// Can this happen? should we update notification?
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
activeSessionHolder.getActiveSession().let { session ->
|
||||||
|
session.getRoom(roomId)?.let { room ->
|
||||||
|
sendMatrixEvent(message, threadId, session, room, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
private fun sendMatrixEvent(message: String, threadId: String?, session: Session, room: Room, context: Context?) {
|
||||||
|
if (threadId != null) {
|
||||||
|
room.relationService().replyInThread(
|
||||||
|
rootThreadEventId = threadId,
|
||||||
|
replyInThreadText = message,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
room.sendService().sendTextMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new event to be displayed in the notification drawer, right now
|
||||||
|
|
||||||
|
val notifiableMessageEvent = NotifiableMessageEvent(
|
||||||
|
// Generate a Fake event id
|
||||||
|
eventId = UUID.randomUUID().toString(),
|
||||||
|
editedEventId = null,
|
||||||
|
noisy = false,
|
||||||
|
timestamp = clock.epochMillis(),
|
||||||
|
senderName = session.roomService().getRoomMember(session.myUserId, room.roomId)?.displayName
|
||||||
|
?: context?.getString(StringR.string.notification_sender_me),
|
||||||
|
senderId = session.myUserId,
|
||||||
|
body = message,
|
||||||
|
imageUriString = null,
|
||||||
|
roomId = room.roomId,
|
||||||
|
threadId = threadId,
|
||||||
|
roomName = room.roomSummary()?.displayName ?: room.roomId,
|
||||||
|
roomIsDirect = room.roomSummary()?.isDirect == true,
|
||||||
|
outGoingMessage = true,
|
||||||
|
canBeReplaced = false
|
||||||
|
)
|
||||||
|
|
||||||
|
notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notifiableMessageEvent) }
|
||||||
|
|
||||||
|
/*
|
||||||
|
// TODO Error cannot be managed the same way than in Riot
|
||||||
|
|
||||||
|
val event = Event(mxMessage, session.credentials.userId, roomId)
|
||||||
|
room.storeOutgoingEvent(event)
|
||||||
|
room.sendEvent(event, object : MatrixCallback<Void?> {
|
||||||
|
override fun onSuccess(info: Void?) {
|
||||||
|
Timber.v("Send message : onSuccess ")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNetworkError(e: Exception) {
|
||||||
|
Timber.e(e, "Send message : onNetworkError")
|
||||||
|
onSmartReplyFailed(e.localizedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMatrixError(e: MatrixError) {
|
||||||
|
Timber.v("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,
|
||||||
|
clock.epochMillis(),
|
||||||
|
session.myUser?.displayname
|
||||||
|
?: context?.getString(StringR.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_THREAD_ID = "threadID"
|
||||||
|
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
||||||
|
@ContributesTo(AppScope::class)
|
||||||
|
interface NotificationBroadcastReceiverBindings {
|
||||||
|
fun inject(receiver: NotificationBroadcastReceiver)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class NotificationDisplayer @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val notificationManager = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
fun showNotificationMessage(tag: String?, id: Int, notification: Notification) {
|
||||||
|
notificationManager.notify(tag, id, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelNotificationMessage(tag: String?, id: Int) {
|
||||||
|
notificationManager.cancel(tag, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAllNotifications() {
|
||||||
|
// Keep this try catch (reported by GA)
|
||||||
|
try {
|
||||||
|
notificationManager.cancelAll()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "## cancelAllNotifications() failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
/*
|
||||||
|
* 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import io.element.android.libraries.androidutils.throttler.FirstThrottler
|
||||||
|
import io.element.android.libraries.core.cache.CircularCache
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.push.api.store.PushDataStore
|
||||||
|
import io.element.android.libraries.push.impl.R
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class NotificationDrawerManager @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
private val notificationDisplayer: NotificationDisplayer,
|
||||||
|
private val pushDataStore: PushDataStore,
|
||||||
|
// private val activeSessionDataSource: ActiveSessionDataSource,
|
||||||
|
private val notifiableEventProcessor: NotifiableEventProcessor,
|
||||||
|
private val notificationRenderer: NotificationRenderer,
|
||||||
|
private val notificationEventPersistence: NotificationEventPersistence,
|
||||||
|
private val filteredEventDetector: FilteredEventDetector,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
|
||||||
|
private var backgroundHandler: Handler
|
||||||
|
|
||||||
|
// TODO Multi-session: this will have to be improved
|
||||||
|
/*
|
||||||
|
private val currentSession: Session?
|
||||||
|
get() = activeSessionDataSource.currentValue?.orNull()
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
|
||||||
|
*/
|
||||||
|
private val notificationState by lazy { createInitialNotificationState() }
|
||||||
|
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
|
||||||
|
private var currentRoomId: String? = null
|
||||||
|
private var currentThreadId: String? = null
|
||||||
|
private val firstThrottler = FirstThrottler(200)
|
||||||
|
|
||||||
|
private var useCompleteNotificationFormat = pushDataStore.useCompleteNotificationFormat()
|
||||||
|
|
||||||
|
init {
|
||||||
|
handlerThread.start()
|
||||||
|
backgroundHandler = Handler(handlerThread.looper)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createInitialNotificationState(): NotificationState {
|
||||||
|
val queuedEvents = notificationEventPersistence.loadEvents(factory = { rawEvents ->
|
||||||
|
NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25))
|
||||||
|
})
|
||||||
|
val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList()
|
||||||
|
return NotificationState(queuedEvents, renderedEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
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 NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
|
||||||
|
if (!pushDataStore.areNotificationEnabledForDevice()) {
|
||||||
|
Timber.i("Notification are disabled for this device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If we support multi session, event list should be per userId
|
||||||
|
// Currently only manage single session
|
||||||
|
if (buildMeta.lowPrivacyLoggingEnabled) {
|
||||||
|
Timber.d("onNotifiableEventReceived(): $notifiableEvent")
|
||||||
|
} else {
|
||||||
|
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) {
|
||||||
|
Timber.d("onNotifiableEventReceived(): ignore the event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
add(notifiableEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all known events and refresh the notification drawer.
|
||||||
|
*/
|
||||||
|
fun clearAllEvents() {
|
||||||
|
updateEvents { it.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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?) {
|
||||||
|
updateEvents {
|
||||||
|
val hasChanged = roomId != currentRoomId
|
||||||
|
currentRoomId = roomId
|
||||||
|
if (hasChanged && roomId != null) {
|
||||||
|
it.clearMessagesForRoom(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should be called when the application is currently opened and showing timeline for the given threadId.
|
||||||
|
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
|
||||||
|
*/
|
||||||
|
fun setCurrentThread(threadId: String?) {
|
||||||
|
updateEvents {
|
||||||
|
val hasChanged = threadId != currentThreadId
|
||||||
|
currentThreadId = threadId
|
||||||
|
currentRoomId?.let { roomId ->
|
||||||
|
if (hasChanged && threadId != null) {
|
||||||
|
it.clearMessagesForThread(roomId, threadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notificationStyleChanged() {
|
||||||
|
updateEvents {
|
||||||
|
val newSettings = pushDataStore.useCompleteNotificationFormat()
|
||||||
|
if (newSettings != useCompleteNotificationFormat) {
|
||||||
|
// Settings has changed, remove all current notifications
|
||||||
|
notificationDisplayer.cancelAllNotifications()
|
||||||
|
useCompleteNotificationFormat = newSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) {
|
||||||
|
notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
|
||||||
|
action(queuedEvents)
|
||||||
|
}
|
||||||
|
refreshNotificationDrawer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshNotificationDrawer() {
|
||||||
|
// Implement last throttler
|
||||||
|
val canHandle = firstThrottler.canHandle()
|
||||||
|
Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms")
|
||||||
|
backgroundHandler.removeCallbacksAndMessages(null)
|
||||||
|
|
||||||
|
backgroundHandler.postDelayed(
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
refreshNotificationDrawerBg()
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
// It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer
|
||||||
|
Timber.w(throwable, "refreshNotificationDrawerBg failure")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
canHandle.waitMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun refreshNotificationDrawerBg() {
|
||||||
|
Timber.v("refreshNotificationDrawerBg()")
|
||||||
|
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
|
||||||
|
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, currentThreadId, renderedEvents).also {
|
||||||
|
queuedEvents.clearAndAdd(it.onlyKeptEvents())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationState.hasAlreadyRendered(eventsToRender)) {
|
||||||
|
Timber.d("Skipping notification update due to event list not changing")
|
||||||
|
} else {
|
||||||
|
notificationState.clearAndAddRenderedEvents(eventsToRender)
|
||||||
|
// TODO EAx
|
||||||
|
//val session = currentSession ?: return
|
||||||
|
//renderEvents(session, eventsToRender)
|
||||||
|
persistEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistEvents() {
|
||||||
|
notificationState.queuedEvents { queuedEvents ->
|
||||||
|
notificationEventPersistence.persistEvents(queuedEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderEvents(/*session: Session, eventsToRender: List<ProcessedEvent<NotifiableEvent>>*/) {
|
||||||
|
/* TODO EAx
|
||||||
|
val user = session.getUserOrDefault(session.myUserId)
|
||||||
|
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
|
||||||
|
val myUserDisplayName = user.toMatrixItem().getBestName()
|
||||||
|
val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail(
|
||||||
|
contentUrl = user.avatarUrl,
|
||||||
|
width = avatarSize,
|
||||||
|
height = avatarSize,
|
||||||
|
method = ContentUrlResolver.ThumbnailMethod.SCALE
|
||||||
|
)
|
||||||
|
notificationRenderer.render(session.myUserId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, eventsToRender)
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
|
||||||
|
return resolvedEvent.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SUMMARY_NOTIFICATION_ID = 0
|
||||||
|
const val ROOM_MESSAGES_NOTIFICATION_ID = 1
|
||||||
|
const val ROOM_EVENT_NOTIFICATION_ID = 2
|
||||||
|
const val ROOM_INVITATION_NOTIFICATION_ID = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
// TODO Multi-account
|
||||||
|
private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache"
|
||||||
|
private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr"
|
||||||
|
|
||||||
|
class NotificationEventPersistence @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
// private val matrix: Matrix,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun loadEvents(factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue {
|
||||||
|
try {
|
||||||
|
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
|
||||||
|
if (file.exists()) {
|
||||||
|
file.inputStream().use {
|
||||||
|
val events: ArrayList<NotifiableEvent>? = null // TODO EAx matrix.secureStorageService().loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
|
||||||
|
if (events != null) {
|
||||||
|
return factory(events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Timber.e(e, "## Failed to load cached notification info")
|
||||||
|
}
|
||||||
|
return factory(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun persistEvents(queuedEvents: NotificationEventQueue) {
|
||||||
|
if (queuedEvents.isEmpty()) {
|
||||||
|
deleteCachedRoomNotifications(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
|
||||||
|
if (!file.exists()) file.createNewFile()
|
||||||
|
FileOutputStream(file).use {
|
||||||
|
// TODO EAx
|
||||||
|
// matrix.secureStorageService().securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Timber.e(e, "## Failed to save cached notification info")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteCachedRoomNotifications(context: Context) {
|
||||||
|
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
|
||||||
|
if (file.exists()) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.cache.CircularCache
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
data class NotificationEventQueue(
|
||||||
|
private val queue: MutableList<NotifiableEvent>,
|
||||||
|
/**
|
||||||
|
* An in memory FIFO cache of the seen events.
|
||||||
|
* Acts as a notification debouncer to stop already dismissed push notifications from
|
||||||
|
* displaying again when the /sync response is delayed.
|
||||||
|
*/
|
||||||
|
private val seenEventIds: CircularCache<String>
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun markRedacted(eventIds: List<String>) {
|
||||||
|
eventIds.forEach { redactedId ->
|
||||||
|
queue.replace(redactedId) {
|
||||||
|
when (it) {
|
||||||
|
is InviteNotifiableEvent -> it.copy(isRedacted = true)
|
||||||
|
is NotifiableMessageEvent -> it.copy(isRedacted = true)
|
||||||
|
is SimpleNotifiableEvent -> it.copy(isRedacted = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncRoomEvents(roomsLeft: Collection<String>, roomsJoined: Collection<String>) {
|
||||||
|
if (roomsLeft.isNotEmpty() || roomsJoined.isNotEmpty()) {
|
||||||
|
queue.removeAll {
|
||||||
|
when (it) {
|
||||||
|
is NotifiableMessageEvent -> roomsLeft.contains(it.roomId)
|
||||||
|
is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isEmpty() = queue.isEmpty()
|
||||||
|
|
||||||
|
fun clearAndAdd(events: List<NotifiableEvent>) {
|
||||||
|
queue.clear()
|
||||||
|
queue.addAll(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
queue.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(notifiableEvent: NotifiableEvent) {
|
||||||
|
val existing = findExistingById(notifiableEvent)
|
||||||
|
val edited = findEdited(notifiableEvent)
|
||||||
|
when {
|
||||||
|
existing != null -> {
|
||||||
|
if (existing.canBeReplaced) {
|
||||||
|
// Use the event coming from the event stream as it may contains more info than
|
||||||
|
// the fcm one (like type/content/clear text) (e.g when an encrypted message from
|
||||||
|
// FCM should be update with clear text after a sync)
|
||||||
|
// 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
|
||||||
|
// Use setOnlyAlertOnce to ensure update notification does not interfere with sound
|
||||||
|
// from first notify invocation as outlined in:
|
||||||
|
// https://developer.android.com/training/notify-user/build-notification#Updating
|
||||||
|
replace(replace = existing, with = notifiableEvent)
|
||||||
|
} else {
|
||||||
|
// keep the existing one, do not replace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edited != null -> {
|
||||||
|
// Replace the existing notification with the new content
|
||||||
|
replace(replace = edited, with = notifiableEvent)
|
||||||
|
}
|
||||||
|
seenEventIds.contains(notifiableEvent.eventId) -> {
|
||||||
|
// we've already seen the event, lets skip
|
||||||
|
Timber.d("onNotifiableEventReceived(): skipping event, already seen")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
seenEventIds.put(notifiableEvent.eventId)
|
||||||
|
queue.add(notifiableEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? {
|
||||||
|
return queue.firstOrNull { it.eventId == notifiableEvent.eventId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? {
|
||||||
|
return notifiableEvent.editedEventId?.let { editedId ->
|
||||||
|
queue.firstOrNull {
|
||||||
|
it.eventId == editedId || it.editedEventId == editedId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replace(replace: NotifiableEvent, with: NotifiableEvent) {
|
||||||
|
queue.remove(replace)
|
||||||
|
queue.add(
|
||||||
|
when (with) {
|
||||||
|
is InviteNotifiableEvent -> with.copy(isUpdated = true)
|
||||||
|
is NotifiableMessageEvent -> with.copy(isUpdated = true)
|
||||||
|
is SimpleNotifiableEvent -> with.copy(isUpdated = true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMemberShipNotificationForRoom(roomId: String) {
|
||||||
|
Timber.d("clearMemberShipOfRoom $roomId")
|
||||||
|
queue.removeAll { it is InviteNotifiableEvent && it.roomId == roomId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMessagesForRoom(roomId: String) {
|
||||||
|
Timber.d("clearMessageEventOfRoom $roomId")
|
||||||
|
queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMessagesForThread(roomId: String, threadId: String) {
|
||||||
|
Timber.d("clearMessageEventOfThread $roomId, $threadId")
|
||||||
|
queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId && it.threadId == threadId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rawEvents(): List<NotifiableEvent> = queue
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<NotifiableEvent>.replace(eventId: String, block: (NotifiableEvent) -> NotifiableEvent) {
|
||||||
|
val indexToReplace = indexOfFirst { it.eventId == eventId }
|
||||||
|
if (indexToReplace == -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set(indexToReplace, block(get(indexToReplace)))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>>
|
||||||
|
|
||||||
|
class NotificationFactory @Inject constructor(
|
||||||
|
private val notificationUtils: NotificationUtils,
|
||||||
|
private val roomGroupMessageCreator: RoomGroupMessageCreator,
|
||||||
|
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun Map<String, ProcessedMessageEvents>.toNotifications(myUserDisplayName: String, myUserAvatarUrl: String?): List<RoomNotification> {
|
||||||
|
return map { (roomId, events) ->
|
||||||
|
when {
|
||||||
|
events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId)
|
||||||
|
else -> {
|
||||||
|
val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted }
|
||||||
|
roomGroupMessageCreator.createRoomMessage(messageEvents, roomId, myUserDisplayName, myUserAvatarUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all {
|
||||||
|
it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
|
||||||
|
|
||||||
|
@JvmName("toNotificationsInviteNotifiableEvent")
|
||||||
|
fun List<ProcessedEvent<InviteNotifiableEvent>>.toNotifications(myUserId: String): List<OneShotNotification> {
|
||||||
|
return map { (processed, event) ->
|
||||||
|
when (processed) {
|
||||||
|
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId)
|
||||||
|
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
|
||||||
|
notificationUtils.buildRoomInvitationNotification(event, myUserId),
|
||||||
|
OneShotNotification.Append.Meta(
|
||||||
|
key = event.roomId,
|
||||||
|
summaryLine = event.description,
|
||||||
|
isNoisy = event.noisy,
|
||||||
|
timestamp = event.timestamp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmName("toNotificationsSimpleNotifiableEvent")
|
||||||
|
fun List<ProcessedEvent<SimpleNotifiableEvent>>.toNotifications(myUserId: String): List<OneShotNotification> {
|
||||||
|
return map { (processed, event) ->
|
||||||
|
when (processed) {
|
||||||
|
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId)
|
||||||
|
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
|
||||||
|
notificationUtils.buildSimpleEventNotification(event, myUserId),
|
||||||
|
OneShotNotification.Append.Meta(
|
||||||
|
key = event.eventId,
|
||||||
|
summaryLine = event.description,
|
||||||
|
isNoisy = event.noisy,
|
||||||
|
timestamp = event.timestamp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSummaryNotification(
|
||||||
|
roomNotifications: List<RoomNotification>,
|
||||||
|
invitationNotifications: List<OneShotNotification>,
|
||||||
|
simpleNotifications: List<OneShotNotification>,
|
||||||
|
useCompleteNotificationFormat: Boolean
|
||||||
|
): SummaryNotification {
|
||||||
|
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
|
||||||
|
val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
|
||||||
|
val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
|
||||||
|
return when {
|
||||||
|
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
|
||||||
|
else -> SummaryNotification.Update(
|
||||||
|
summaryGroupMessageCreator.createSummaryNotification(
|
||||||
|
roomNotifications = roomMeta,
|
||||||
|
invitationNotifications = invitationMeta,
|
||||||
|
simpleNotifications = simpleMeta,
|
||||||
|
useCompleteNotificationFormat = useCompleteNotificationFormat
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface RoomNotification {
|
||||||
|
data class Removed(val roomId: String) : RoomNotification
|
||||||
|
data class Message(val notification: Notification, val meta: Meta) : RoomNotification {
|
||||||
|
data class Meta(
|
||||||
|
val summaryLine: CharSequence,
|
||||||
|
val messageCount: Int,
|
||||||
|
val latestTimestamp: Long,
|
||||||
|
val roomId: String,
|
||||||
|
val shouldBing: Boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface OneShotNotification {
|
||||||
|
data class Removed(val key: String) : OneShotNotification
|
||||||
|
data class Append(val notification: Notification, val meta: Meta) : OneShotNotification {
|
||||||
|
data class Meta(
|
||||||
|
val key: String,
|
||||||
|
val summaryLine: CharSequence,
|
||||||
|
val isNoisy: Boolean,
|
||||||
|
val timestamp: Long,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface SummaryNotification {
|
||||||
|
object Removed : SummaryNotification
|
||||||
|
data class Update(val notification: Notification) : SummaryNotification
|
||||||
|
}
|
||||||
|
|
@ -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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_EVENT_NOTIFICATION_ID
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_INVITATION_NOTIFICATION_ID
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_MESSAGES_NOTIFICATION_ID
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.SUMMARY_NOTIFICATION_ID
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class NotificationRenderer @Inject constructor(
|
||||||
|
private val notificationDisplayer: NotificationDisplayer,
|
||||||
|
private val notificationFactory: NotificationFactory,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun render(
|
||||||
|
myUserId: String,
|
||||||
|
myUserDisplayName: String,
|
||||||
|
myUserAvatarUrl: String?,
|
||||||
|
useCompleteNotificationFormat: Boolean,
|
||||||
|
eventsToProcess: List<ProcessedEvent<NotifiableEvent>>
|
||||||
|
) {
|
||||||
|
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType()
|
||||||
|
with(notificationFactory) {
|
||||||
|
val roomNotifications = roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl)
|
||||||
|
val invitationNotifications = invitationEvents.toNotifications(myUserId)
|
||||||
|
val simpleNotifications = simpleEvents.toNotifications(myUserId)
|
||||||
|
val summaryNotification = createSummaryNotification(
|
||||||
|
roomNotifications = roomNotifications,
|
||||||
|
invitationNotifications = invitationNotifications,
|
||||||
|
simpleNotifications = simpleNotifications,
|
||||||
|
useCompleteNotificationFormat = useCompleteNotificationFormat
|
||||||
|
)
|
||||||
|
|
||||||
|
// Remove summary first to avoid briefly displaying it after dismissing the last notification
|
||||||
|
if (summaryNotification == SummaryNotification.Removed) {
|
||||||
|
Timber.d("Removing summary notification")
|
||||||
|
notificationDisplayer.cancelNotificationMessage(null, SUMMARY_NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
roomNotifications.forEach { wrapper ->
|
||||||
|
when (wrapper) {
|
||||||
|
is RoomNotification.Removed -> {
|
||||||
|
Timber.d("Removing room messages notification ${wrapper.roomId}")
|
||||||
|
notificationDisplayer.cancelNotificationMessage(wrapper.roomId, ROOM_MESSAGES_NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
is RoomNotification.Message -> if (useCompleteNotificationFormat) {
|
||||||
|
Timber.d("Updating room messages notification ${wrapper.meta.roomId}")
|
||||||
|
notificationDisplayer.showNotificationMessage(wrapper.meta.roomId, ROOM_MESSAGES_NOTIFICATION_ID, wrapper.notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invitationNotifications.forEach { wrapper ->
|
||||||
|
when (wrapper) {
|
||||||
|
is OneShotNotification.Removed -> {
|
||||||
|
Timber.d("Removing invitation notification ${wrapper.key}")
|
||||||
|
notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_INVITATION_NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
|
||||||
|
Timber.d("Updating invitation notification ${wrapper.meta.key}")
|
||||||
|
notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_INVITATION_NOTIFICATION_ID, wrapper.notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
simpleNotifications.forEach { wrapper ->
|
||||||
|
when (wrapper) {
|
||||||
|
is OneShotNotification.Removed -> {
|
||||||
|
Timber.d("Removing simple notification ${wrapper.key}")
|
||||||
|
notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_EVENT_NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
|
||||||
|
Timber.d("Updating simple notification ${wrapper.meta.key}")
|
||||||
|
notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_EVENT_NOTIFICATION_ID, wrapper.notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update summary last to avoid briefly displaying it before other notifications
|
||||||
|
if (summaryNotification is SummaryNotification.Update) {
|
||||||
|
Timber.d("Updating summary notification")
|
||||||
|
notificationDisplayer.showNotificationMessage(null, SUMMARY_NOTIFICATION_ID, summaryNotification.notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotificationEvents {
|
||||||
|
val roomIdToEventMap: MutableMap<String, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap()
|
||||||
|
val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList()
|
||||||
|
val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList()
|
||||||
|
forEach {
|
||||||
|
when (val event = it.event) {
|
||||||
|
is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType())
|
||||||
|
is NotifiableMessageEvent -> {
|
||||||
|
val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() }
|
||||||
|
roomEvents.add(it.castedToEventType())
|
||||||
|
}
|
||||||
|
is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventType(): ProcessedEvent<T> = this as ProcessedEvent<T>
|
||||||
|
|
||||||
|
data class GroupedNotificationEvents(
|
||||||
|
val roomEvents: Map<String, List<ProcessedEvent<NotifiableMessageEvent>>>,
|
||||||
|
val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>,
|
||||||
|
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
|
|
||||||
|
class NotificationState(
|
||||||
|
/**
|
||||||
|
* The notifiable events queued for rendering or currently rendered.
|
||||||
|
*
|
||||||
|
* This is our source of truth for notifications, any changes to this list will be rendered as notifications.
|
||||||
|
* When events are removed the previously rendered notifications will be cancelled.
|
||||||
|
* When adding or updating, the notifications will be notified.
|
||||||
|
*
|
||||||
|
* Events are unique by their properties, we should be careful not to insert multiple events with the same event-id.
|
||||||
|
*/
|
||||||
|
private val queuedEvents: NotificationEventQueue,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last known rendered notifiable events.
|
||||||
|
* We keep track of them in order to know which events have been removed from the eventList
|
||||||
|
* allowing us to cancel any notifications previous displayed by now removed events
|
||||||
|
*/
|
||||||
|
private val renderedEvents: MutableList<ProcessedEvent<NotifiableEvent>>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun <T> updateQueuedEvents(
|
||||||
|
drawerManager: NotificationDrawerManager,
|
||||||
|
action: NotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T
|
||||||
|
): T {
|
||||||
|
return synchronized(queuedEvents) {
|
||||||
|
action(drawerManager, queuedEvents, renderedEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAndAddRenderedEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
|
||||||
|
renderedEvents.clear()
|
||||||
|
renderedEvents.addAll(eventsToRender)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasAlreadyRendered(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) = renderedEvents == eventsToRender
|
||||||
|
|
||||||
|
fun queuedEvents(block: (NotificationEventQueue) -> Unit) {
|
||||||
|
synchronized(queuedEvents) {
|
||||||
|
block(queuedEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,744 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:Suppress("UNUSED_PARAMETER")
|
||||||
|
|
||||||
|
package io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.ChecksSdkIntAtLeast
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import io.element.android.libraries.androidutils.intent.PendingIntentCompat
|
||||||
|
import io.element.android.libraries.androidutils.system.startNotificationChannelSettingsIntent
|
||||||
|
import io.element.android.libraries.androidutils.uri.createIgnoredUri
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.push.impl.R
|
||||||
|
import io.element.android.libraries.toolbox.api.strings.StringProvider
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||||
|
import io.element.android.libraries.toolbox.api.systemclock.SystemClock
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
// TODO EAx Split into factories
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class NotificationUtils @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
// private val vectorPreferences: VectorPreferences,
|
||||||
|
private val stringProvider: StringProvider,
|
||||||
|
private val clock: SystemClock,
|
||||||
|
private val actionIds: NotificationActionIds,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/* ==========================================================================================
|
||||||
|
* 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 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"
|
||||||
|
|
||||||
|
const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
|
||||||
|
private const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2"
|
||||||
|
|
||||||
|
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
|
||||||
|
fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
|
||||||
|
fun openSystemSettingsForSilentCategory(activity: Activity) {
|
||||||
|
startNotificationChannelSettingsIntent(activity, SILENT_NOTIFICATION_CHANNEL_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSystemSettingsForNoisyCategory(activity: Activity) {
|
||||||
|
startNotificationChannelSettingsIntent(activity, NOISY_NOTIFICATION_CHANNEL_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSystemSettingsForCallCategory(activity: Activity) {
|
||||||
|
startNotificationChannelSettingsIntent(activity, CALL_NOTIFICATION_CHANNEL_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val notificationManager = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
/* ==========================================================================================
|
||||||
|
* Channel names
|
||||||
|
* ========================================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create notification channels.
|
||||||
|
*/
|
||||||
|
fun createNotificationChannels() {
|
||||||
|
if (!supportNotificationChannels()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
stringProvider.getString(StringR.string.notification_noisy_notifications).ifEmpty { "Noisy notifications" },
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
)
|
||||||
|
.apply {
|
||||||
|
description = stringProvider.getString(StringR.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,
|
||||||
|
stringProvider.getString(StringR.string.notification_silent_notifications).ifEmpty { "Silent notifications" },
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
.apply {
|
||||||
|
description = stringProvider.getString(StringR.string.notification_silent_notifications)
|
||||||
|
setSound(null, null)
|
||||||
|
enableLights(true)
|
||||||
|
lightColor = accentColor
|
||||||
|
})
|
||||||
|
|
||||||
|
notificationManager.createNotificationChannel(NotificationChannel(
|
||||||
|
LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID,
|
||||||
|
stringProvider.getString(StringR.string.notification_listening_for_events).ifEmpty { "Listening for events" },
|
||||||
|
NotificationManager.IMPORTANCE_MIN
|
||||||
|
)
|
||||||
|
.apply {
|
||||||
|
description = stringProvider.getString(StringR.string.notification_listening_for_events)
|
||||||
|
setSound(null, null)
|
||||||
|
setShowBadge(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
notificationManager.createNotificationChannel(NotificationChannel(
|
||||||
|
CALL_NOTIFICATION_CHANNEL_ID,
|
||||||
|
stringProvider.getString(StringR.string.call).ifEmpty { "Call" },
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
)
|
||||||
|
.apply {
|
||||||
|
description = stringProvider.getString(StringR.string.call)
|
||||||
|
setSound(null, null)
|
||||||
|
enableLights(true)
|
||||||
|
lightColor = accentColor
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChannel(channelId: String): NotificationChannel? {
|
||||||
|
return notificationManager.getNotificationChannel(channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChannelForIncomingCall(fromBg: Boolean): NotificationChannel? {
|
||||||
|
val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
|
||||||
|
return getChannel(notificationChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a notification for a Room.
|
||||||
|
*/
|
||||||
|
fun buildMessagesListNotification(
|
||||||
|
messageStyle: NotificationCompat.MessagingStyle,
|
||||||
|
roomInfo: RoomEventGroupInfo,
|
||||||
|
threadId: String?,
|
||||||
|
largeIcon: Bitmap?,
|
||||||
|
lastMessageTimestamp: Long,
|
||||||
|
senderDisplayNameForReplyCompat: String?,
|
||||||
|
tickerText: String
|
||||||
|
): Notification {
|
||||||
|
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
|
||||||
|
// Build the pending intent for when the notification is clicked
|
||||||
|
val openIntent = when {
|
||||||
|
threadId != null &&
|
||||||
|
true /** TODO EAx vectorPreferences.areThreadMessagesEnabled() */
|
||||||
|
-> buildOpenThreadIntent(roomInfo, threadId)
|
||||||
|
else -> buildOpenRoomIntent(roomInfo.roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val smallIcon = R.drawable.ic_notification
|
||||||
|
|
||||||
|
val channelID = if (roomInfo.shouldBing) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
|
||||||
|
return NotificationCompat.Builder(context, channelID)
|
||||||
|
.setOnlyAlertOnce(roomInfo.isUpdated)
|
||||||
|
.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)
|
||||||
|
// ID of the corresponding shortcut, for conversation features under API 30+
|
||||||
|
.setShortcutId(roomInfo.roomId)
|
||||||
|
// Title for API < 16 devices.
|
||||||
|
.setContentTitle(roomInfo.roomDisplayName)
|
||||||
|
// Content for API < 16 devices.
|
||||||
|
.setContentText(stringProvider.getString(StringR.string.notification_new_messages))
|
||||||
|
// Number of new notifications for API <24 (M and below) devices.
|
||||||
|
.setSubText(
|
||||||
|
stringProvider.getQuantityString(
|
||||||
|
StringR.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(buildMeta.applicationName)
|
||||||
|
// In order to avoid notification making sound twice (due to the summary notification)
|
||||||
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||||
|
.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 {
|
||||||
|
if (roomInfo.shouldBing) {
|
||||||
|
// Compat
|
||||||
|
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||||
|
/*
|
||||||
|
vectorPreferences.getNotificationRingTone()?.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 = actionIds.markRoomRead
|
||||||
|
markRoomReadIntent.data = createIgnoredUri(roomInfo.roomId)
|
||||||
|
markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
|
||||||
|
val markRoomReadPendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
clock.epochMillis().toInt(),
|
||||||
|
markRoomReadIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCompat.Action.Builder(
|
||||||
|
R.drawable.ic_material_done_all_white,
|
||||||
|
stringProvider.getString(StringR.string.action_mark_room_read), markRoomReadPendingIntent
|
||||||
|
)
|
||||||
|
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||||
|
.setShowsUserInterface(false)
|
||||||
|
.build()
|
||||||
|
.let { addAction(it) }
|
||||||
|
|
||||||
|
// Quick reply
|
||||||
|
if (!roomInfo.hasSmartReplyError) {
|
||||||
|
buildQuickReplyIntent(roomInfo.roomId, threadId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent ->
|
||||||
|
val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
|
||||||
|
.setLabel(stringProvider.getString(StringR.string.action_quick_reply))
|
||||||
|
.build()
|
||||||
|
NotificationCompat.Action.Builder(
|
||||||
|
R.drawable.vector_notification_quick_reply,
|
||||||
|
stringProvider.getString(StringR.string.action_quick_reply), replyPendingIntent
|
||||||
|
)
|
||||||
|
.addRemoteInput(remoteInput)
|
||||||
|
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||||
|
.setShowsUserInterface(false)
|
||||||
|
.build()
|
||||||
|
.let { addAction(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openIntent != null) {
|
||||||
|
setContentIntent(openIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (largeIcon != null) {
|
||||||
|
setLargeIcon(largeIcon)
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||||
|
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
|
||||||
|
intent.action = actionIds.dismissRoom
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context.applicationContext,
|
||||||
|
clock.epochMillis().toInt(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
setDeleteIntent(pendingIntent)
|
||||||
|
}
|
||||||
|
.setTicker(tickerText)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildRoomInvitationNotification(
|
||||||
|
inviteNotifiableEvent: InviteNotifiableEvent,
|
||||||
|
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 = R.drawable.ic_notification
|
||||||
|
val channelID = if (inviteNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(context, channelID)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName)
|
||||||
|
.setContentText(inviteNotifiableEvent.description)
|
||||||
|
.setGroup(buildMeta.applicationName)
|
||||||
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||||
|
.setSmallIcon(smallIcon)
|
||||||
|
.setColor(accentColor)
|
||||||
|
.apply {
|
||||||
|
val roomId = inviteNotifiableEvent.roomId
|
||||||
|
// offer to type a quick reject button
|
||||||
|
val rejectIntent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||||
|
rejectIntent.action = actionIds.reject
|
||||||
|
rejectIntent.data = createIgnoredUri("$roomId&$matrixId")
|
||||||
|
rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
|
||||||
|
val rejectIntentPendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
clock.epochMillis().toInt(),
|
||||||
|
rejectIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
addAction(
|
||||||
|
R.drawable.vector_notification_reject_invitation,
|
||||||
|
stringProvider.getString(StringR.string.action_reject),
|
||||||
|
rejectIntentPendingIntent
|
||||||
|
)
|
||||||
|
|
||||||
|
// offer to type a quick accept button
|
||||||
|
val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||||
|
joinIntent.action = actionIds.join
|
||||||
|
joinIntent.data = createIgnoredUri("$roomId&$matrixId")
|
||||||
|
joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
|
||||||
|
val joinIntentPendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
clock.epochMillis().toInt(),
|
||||||
|
joinIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
addAction(
|
||||||
|
R.drawable.vector_notification_accept_invitation,
|
||||||
|
stringProvider.getString(StringR.string.action_join),
|
||||||
|
joinIntentPendingIntent
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
val contentIntent = HomeActivity.newIntent(
|
||||||
|
context,
|
||||||
|
firstStartMainActivity = true,
|
||||||
|
inviteNotificationRoomId = inviteNotifiableEvent.roomId
|
||||||
|
)
|
||||||
|
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 = createIgnoredUri(inviteNotifiableEvent.eventId)
|
||||||
|
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (inviteNotifiableEvent.noisy) {
|
||||||
|
// Compat
|
||||||
|
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||||
|
/*
|
||||||
|
vectorPreferences.getNotificationRingTone()?.let {
|
||||||
|
setSound(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
setLights(accentColor, 500, 500)
|
||||||
|
} else {
|
||||||
|
priority = NotificationCompat.PRIORITY_LOW
|
||||||
|
}
|
||||||
|
setAutoCancel(true)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSimpleEventNotification(
|
||||||
|
simpleNotifiableEvent: SimpleNotifiableEvent,
|
||||||
|
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 = R.drawable.ic_notification
|
||||||
|
|
||||||
|
val channelID = if (simpleNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(context, channelID)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentTitle(buildMeta.applicationName)
|
||||||
|
.setContentText(simpleNotifiableEvent.description)
|
||||||
|
.setGroup(buildMeta.applicationName)
|
||||||
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||||
|
.setSmallIcon(smallIcon)
|
||||||
|
.setColor(accentColor)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.apply {
|
||||||
|
/* TODO EAx
|
||||||
|
val contentIntent = HomeActivity.newIntent(context, firstStartMainActivity = true)
|
||||||
|
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 = createIgnoredUri(simpleNotifiableEvent.eventId)
|
||||||
|
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
|
||||||
|
*/
|
||||||
|
if (simpleNotifiableEvent.noisy) {
|
||||||
|
// Compat
|
||||||
|
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||||
|
/*
|
||||||
|
vectorPreferences.getNotificationRingTone()?.let {
|
||||||
|
setSound(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
setLights(accentColor, 500, 500)
|
||||||
|
} else {
|
||||||
|
priority = NotificationCompat.PRIORITY_LOW
|
||||||
|
}
|
||||||
|
setAutoCancel(true)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildOpenRoomIntent(roomId: String): PendingIntent? {
|
||||||
|
return null
|
||||||
|
/*
|
||||||
|
val roomIntentTap = RoomDetailActivity.newIntent(context, TimelineArgs(roomId = roomId, switchToParentSpace = true), true)
|
||||||
|
roomIntentTap.action = actionIds.tapToView
|
||||||
|
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
|
||||||
|
roomIntentTap.data = createIgnoredUri("openRoom?$roomId")
|
||||||
|
|
||||||
|
// Recreate the back stack
|
||||||
|
return TaskStackBuilder.create(context)
|
||||||
|
.addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false))
|
||||||
|
.addNextIntent(roomIntentTap)
|
||||||
|
.getPendingIntent(
|
||||||
|
clock.epochMillis().toInt(),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: String?): PendingIntent? {
|
||||||
|
return null
|
||||||
|
/*
|
||||||
|
val threadTimelineArgs = ThreadTimelineArgs(
|
||||||
|
startsThread = false,
|
||||||
|
roomId = roomInfo.roomId,
|
||||||
|
rootThreadEventId = threadId,
|
||||||
|
showKeyboard = false,
|
||||||
|
displayName = roomInfo.roomDisplayName,
|
||||||
|
avatarUrl = null,
|
||||||
|
roomEncryptionTrustLevel = null,
|
||||||
|
)
|
||||||
|
val threadIntentTap = ThreadsActivity.newIntent(
|
||||||
|
context = context,
|
||||||
|
threadTimelineArgs = threadTimelineArgs,
|
||||||
|
threadListArgs = null,
|
||||||
|
firstStartMainActivity = true,
|
||||||
|
)
|
||||||
|
threadIntentTap.action = actionIds.tapToView
|
||||||
|
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
|
||||||
|
threadIntentTap.data = createIgnoredUri("openThread?$threadId")
|
||||||
|
|
||||||
|
val roomIntent = RoomDetailActivity.newIntent(
|
||||||
|
context = context,
|
||||||
|
timelineArgs = TimelineArgs(
|
||||||
|
roomId = roomInfo.roomId,
|
||||||
|
switchToParentSpace = true
|
||||||
|
),
|
||||||
|
firstStartMainActivity = false
|
||||||
|
)
|
||||||
|
// Recreate the back stack
|
||||||
|
return TaskStackBuilder.create(context)
|
||||||
|
.addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false))
|
||||||
|
.addNextIntentWithParentStack(roomIntent)
|
||||||
|
.addNextIntent(threadIntentTap)
|
||||||
|
.getPendingIntent(
|
||||||
|
clock.epochMillis().toInt(),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildOpenHomePendingIntentForSummary(): PendingIntent {
|
||||||
|
TODO()
|
||||||
|
/*
|
||||||
|
val intent = HomeActivity.newIntent(context, firstStartMainActivity = false, clearNotification = true)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
intent.data = createIgnoredUri("tapSummary")
|
||||||
|
val mainIntent = MainActivity.getIntentWithNextIntent(context, intent)
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
Random.nextInt(1000),
|
||||||
|
mainIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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(roomId: String, threadId: String?, senderName: String?): PendingIntent? {
|
||||||
|
val intent: Intent
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||||
|
intent.action = actionIds.smartReply
|
||||||
|
intent.data = createIgnoredUri(roomId)
|
||||||
|
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
|
||||||
|
threadId?.let {
|
||||||
|
intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
clock.epochMillis().toInt(),
|
||||||
|
intent,
|
||||||
|
// PendingIntents attached to actions with remote inputs must be mutable
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
|
||||||
|
)
|
||||||
|
} 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 = createIgnoredUri($roomId")
|
||||||
|
return PendingIntent.getActivity(context, 0, quickReplyIntent, PendingIntentCompat.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Number of new notifications for API <24 (M and below) devices.
|
||||||
|
/**
|
||||||
|
* Build the summary notification.
|
||||||
|
*/
|
||||||
|
fun buildSummaryListNotification(
|
||||||
|
style: NotificationCompat.InboxStyle?,
|
||||||
|
compatSummary: String,
|
||||||
|
noisy: Boolean,
|
||||||
|
lastMessageTimestamp: Long
|
||||||
|
): Notification {
|
||||||
|
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
|
||||||
|
val smallIcon = R.drawable.ic_notification
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(context, if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
// used in compat < N, after summary is built based on child notifications
|
||||||
|
.setWhen(lastMessageTimestamp)
|
||||||
|
.setStyle(style)
|
||||||
|
.setContentTitle(buildMeta.applicationName)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||||
|
.setSmallIcon(smallIcon)
|
||||||
|
// set content text to support devices running API level < 24
|
||||||
|
.setContentText(compatSummary)
|
||||||
|
.setGroup(buildMeta.applicationName)
|
||||||
|
// set this notification as the summary for the group
|
||||||
|
.setGroupSummary(true)
|
||||||
|
.setColor(accentColor)
|
||||||
|
.apply {
|
||||||
|
if (noisy) {
|
||||||
|
// Compat
|
||||||
|
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||||
|
/*
|
||||||
|
vectorPreferences.getNotificationRingTone()?.let {
|
||||||
|
setSound(it)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
setLights(accentColor, 500, 500)
|
||||||
|
} else {
|
||||||
|
// compat
|
||||||
|
priority = NotificationCompat.PRIORITY_LOW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setContentIntent(buildOpenHomePendingIntentForSummary())
|
||||||
|
.setDeleteIntent(getDismissSummaryPendingIntent())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDismissSummaryPendingIntent(): PendingIntent {
|
||||||
|
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||||
|
intent.action = actionIds.dismissSummary
|
||||||
|
intent.data = createIgnoredUri("deleteSummary")
|
||||||
|
return PendingIntent.getBroadcast(
|
||||||
|
context.applicationContext,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showNotificationMessage(tag: String?, id: Int, notification: Notification) {
|
||||||
|
notificationManager.notify(tag, id, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelNotificationMessage(tag: String?, id: Int) {
|
||||||
|
notificationManager.cancel(tag, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the foreground notification service.
|
||||||
|
*/
|
||||||
|
fun cancelNotificationForegroundService() {
|
||||||
|
notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all the notification.
|
||||||
|
*/
|
||||||
|
fun cancelAllNotifications() {
|
||||||
|
// Keep this try catch (reported by GA)
|
||||||
|
try {
|
||||||
|
notificationManager.cancelAll()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "## cancelAllNotifications() failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("LaunchActivityFromNotification")
|
||||||
|
fun displayDiagnosticNotification() {
|
||||||
|
val testActionIntent = Intent(context, TestNotificationReceiver::class.java)
|
||||||
|
testActionIntent.action = actionIds.diagnostic
|
||||||
|
val testPendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
testActionIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
notificationManager.notify(
|
||||||
|
"DIAGNOSTIC",
|
||||||
|
888,
|
||||||
|
NotificationCompat.Builder(context, NOISY_NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setContentTitle(buildMeta.applicationName)
|
||||||
|
.setContentText(stringProvider.getString(StringR.string.settings_troubleshoot_test_push_notification_content))
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setLargeIcon(getBitmap(context, R.drawable.element_logo_green))
|
||||||
|
.setColor(ContextCompat.getColor(context, R.color.notification_accent_color))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(testPendingIntent)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBitmap(context: Context, @DrawableRes drawableRes: Int): Bitmap? {
|
||||||
|
val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null
|
||||||
|
val canvas = Canvas()
|
||||||
|
val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
|
||||||
|
canvas.setBitmap(bitmap)
|
||||||
|
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||||
|
drawable.draw(canvas)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true it the user has enabled the do not disturb mode.
|
||||||
|
*/
|
||||||
|
fun isDoNotDisturbModeOn(): Boolean {
|
||||||
|
// We cannot use NotificationManagerCompat here.
|
||||||
|
val setting = context.getSystemService<NotificationManager>()!!.currentInterruptionFilter
|
||||||
|
|
||||||
|
return setting == NotificationManager.INTERRUPTION_FILTER_NONE ||
|
||||||
|
setting == NotificationManager.INTERRUPTION_FILTER_ALARMS
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
private fun getActionText(@StringRes stringRes: Int, @AttrRes colorRes: Int): Spannable {
|
||||||
|
return SpannableString(context.getText(stringRes)).apply {
|
||||||
|
val foregroundColorSpan = ForegroundColorSpan(ThemeUtils.getColor(context, colorRes))
|
||||||
|
setSpan(foregroundColorSpan, 0, length, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
private fun ensureTitleNotEmpty(title: String?): CharSequence {
|
||||||
|
if (title.isNullOrBlank()) {
|
||||||
|
return buildMeta.applicationName
|
||||||
|
}
|
||||||
|
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class OutdatedEventDetector @Inject constructor(
|
||||||
|
/// private val activeSessionDataSource: ActiveSessionDataSource
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
/* TODO EAx
|
||||||
|
val session = activeSessionDataSource.currentValue?.orNull() ?: return false
|
||||||
|
|
||||||
|
if (notifiableEvent is NotifiableMessageEvent) {
|
||||||
|
val eventID = notifiableEvent.eventId
|
||||||
|
val roomID = notifiableEvent.roomId
|
||||||
|
val room = session.getRoom(roomID) ?: return false
|
||||||
|
return room.readService().isEventRead(eventID)
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
data class ProcessedEvent<T>(
|
||||||
|
val type: Type,
|
||||||
|
val event: T
|
||||||
|
) {
|
||||||
|
enum class Type {
|
||||||
|
KEEP,
|
||||||
|
REMOVE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> List<ProcessedEvent<T>>.onlyKeptEvents() = mapNotNull { processedEvent ->
|
||||||
|
processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP }
|
||||||
|
}
|
||||||
|
|
@ -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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class to hold information about a group of notifications for a room.
|
||||||
|
*/
|
||||||
|
data class RoomEventGroupInfo(
|
||||||
|
val roomId: String,
|
||||||
|
val roomDisplayName: String = "",
|
||||||
|
val isDirect: Boolean = false
|
||||||
|
) {
|
||||||
|
// 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: Boolean = false
|
||||||
|
var isUpdated: Boolean = false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.Person
|
||||||
|
import io.element.android.libraries.toolbox.api.strings.StringProvider
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
import me.gujun.android.span.Span
|
||||||
|
import me.gujun.android.span.span
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
class RoomGroupMessageCreator @Inject constructor(
|
||||||
|
private val bitmapLoader: NotificationBitmapLoader,
|
||||||
|
private val stringProvider: StringProvider,
|
||||||
|
private val notificationUtils: NotificationUtils
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun createRoomMessage(events: List<NotifiableMessageEvent>, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message {
|
||||||
|
val lastKnownRoomEvent = events.last()
|
||||||
|
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: ""
|
||||||
|
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
|
||||||
|
val style = NotificationCompat.MessagingStyle(
|
||||||
|
Person.Builder()
|
||||||
|
.setName(userDisplayName)
|
||||||
|
.setIcon(bitmapLoader.getUserIcon(userAvatarUrl))
|
||||||
|
.setKey(lastKnownRoomEvent.matrixID)
|
||||||
|
.build()
|
||||||
|
).also {
|
||||||
|
it.conversationTitle = roomName.takeIf { roomIsGroup }
|
||||||
|
it.isGroupConversation = roomIsGroup
|
||||||
|
it.addMessagesFromEvents(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tickerText = if (roomIsGroup) {
|
||||||
|
stringProvider.getString(StringR.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description)
|
||||||
|
} else {
|
||||||
|
stringProvider.getString(StringR.string.notification_ticker_text_dm, events.last().senderName, events.last().description)
|
||||||
|
}
|
||||||
|
|
||||||
|
val largeBitmap = getRoomBitmap(events)
|
||||||
|
|
||||||
|
val lastMessageTimestamp = events.last().timestamp
|
||||||
|
val smartReplyErrors = events.filter { it.isSmartReplyError() }
|
||||||
|
val messageCount = (events.size - smartReplyErrors.size)
|
||||||
|
val meta = RoomNotification.Message.Meta(
|
||||||
|
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup),
|
||||||
|
messageCount = messageCount,
|
||||||
|
latestTimestamp = lastMessageTimestamp,
|
||||||
|
roomId = roomId,
|
||||||
|
shouldBing = events.any { it.noisy }
|
||||||
|
)
|
||||||
|
return RoomNotification.Message(
|
||||||
|
notificationUtils.buildMessagesListNotification(
|
||||||
|
style,
|
||||||
|
RoomEventGroupInfo(roomId, roomName, isDirect = !roomIsGroup).also {
|
||||||
|
it.hasSmartReplyError = smartReplyErrors.isNotEmpty()
|
||||||
|
it.shouldBing = meta.shouldBing
|
||||||
|
it.customSound = events.last().soundName
|
||||||
|
it.isUpdated = events.last().isUpdated
|
||||||
|
},
|
||||||
|
threadId = lastKnownRoomEvent.threadId,
|
||||||
|
largeIcon = largeBitmap,
|
||||||
|
lastMessageTimestamp,
|
||||||
|
userDisplayName,
|
||||||
|
tickerText
|
||||||
|
),
|
||||||
|
meta
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List<NotifiableMessageEvent>) {
|
||||||
|
events.forEach { event ->
|
||||||
|
val senderPerson = if (event.outGoingMessage) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
Person.Builder()
|
||||||
|
.setName(event.senderName)
|
||||||
|
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath))
|
||||||
|
.setKey(event.senderId)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
event.isSmartReplyError() -> addMessage(
|
||||||
|
stringProvider.getString(StringR.string.notification_inline_reply_failed),
|
||||||
|
event.timestamp,
|
||||||
|
senderPerson
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
val message = NotificationCompat.MessagingStyle.Message(event.body, event.timestamp, senderPerson).also { message ->
|
||||||
|
event.imageUri?.let {
|
||||||
|
message.setData("image/", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDirect: Boolean): CharSequence {
|
||||||
|
return try {
|
||||||
|
when (events.size) {
|
||||||
|
1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect)
|
||||||
|
else -> {
|
||||||
|
stringProvider.getQuantityString(
|
||||||
|
StringR.plurals.notification_compat_summary_line_for_room,
|
||||||
|
events.size,
|
||||||
|
roomName,
|
||||||
|
events.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
// String not found or bad format
|
||||||
|
Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string")
|
||||||
|
roomName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span {
|
||||||
|
return if (roomIsDirect) {
|
||||||
|
span {
|
||||||
|
span {
|
||||||
|
textStyle = "bold"
|
||||||
|
+String.format("%s: ", event.senderName)
|
||||||
|
}
|
||||||
|
+(event.description)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span {
|
||||||
|
span {
|
||||||
|
textStyle = "bold"
|
||||||
|
+String.format("%s: %s ", roomName, event.senderName)
|
||||||
|
}
|
||||||
|
+(event.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
|
||||||
|
// Use the last event (most recent?)
|
||||||
|
return events.lastOrNull()
|
||||||
|
?.roomAvatarPath
|
||||||
|
?.let { bitmapLoader.getRoomBitmap(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import io.element.android.libraries.toolbox.api.strings.StringProvider
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ======== 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
|
||||||
|
*/
|
||||||
|
class SummaryGroupMessageCreator @Inject constructor(
|
||||||
|
private val stringProvider: StringProvider,
|
||||||
|
private val notificationUtils: NotificationUtils
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun createSummaryNotification(
|
||||||
|
roomNotifications: List<RoomNotification.Message.Meta>,
|
||||||
|
invitationNotifications: List<OneShotNotification.Append.Meta>,
|
||||||
|
simpleNotifications: List<OneShotNotification.Append.Meta>,
|
||||||
|
useCompleteNotificationFormat: Boolean
|
||||||
|
): Notification {
|
||||||
|
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
|
||||||
|
roomNotifications.forEach { style.addLine(it.summaryLine) }
|
||||||
|
invitationNotifications.forEach { style.addLine(it.summaryLine) }
|
||||||
|
simpleNotifications.forEach { style.addLine(it.summaryLine) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
|
||||||
|
invitationNotifications.any { it.isNoisy } ||
|
||||||
|
simpleNotifications.any { it.isNoisy }
|
||||||
|
|
||||||
|
val messageCount = roomNotifications.fold(initial = 0) { acc, current -> acc + current.messageCount }
|
||||||
|
|
||||||
|
val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp
|
||||||
|
?: invitationNotifications.lastOrNull()?.timestamp
|
||||||
|
?: simpleNotifications.last().timestamp
|
||||||
|
|
||||||
|
// FIXME roomIdToEventMap.size is not correct, this is the number of rooms
|
||||||
|
val nbEvents = roomNotifications.size + simpleNotifications.size
|
||||||
|
val sumTitle = stringProvider.getQuantityString(StringR.plurals.notification_compat_summary_title, nbEvents, nbEvents)
|
||||||
|
summaryInboxStyle.setBigContentTitle(sumTitle)
|
||||||
|
// TODO get latest event?
|
||||||
|
.setSummaryText(stringProvider.getQuantityString(StringR.plurals.notification_unread_notified_messages, nbEvents, nbEvents))
|
||||||
|
return if (useCompleteNotificationFormat) {
|
||||||
|
notificationUtils.buildSummaryListNotification(
|
||||||
|
summaryInboxStyle,
|
||||||
|
sumTitle,
|
||||||
|
noisy = summaryIsNoisy,
|
||||||
|
lastMessageTimestamp = lastMessageTimestamp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
processSimpleGroupSummary(
|
||||||
|
summaryIsNoisy,
|
||||||
|
messageCount,
|
||||||
|
simpleNotifications.size,
|
||||||
|
invitationNotifications.size,
|
||||||
|
roomNotifications.size,
|
||||||
|
lastMessageTimestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processSimpleGroupSummary(
|
||||||
|
summaryIsNoisy: Boolean,
|
||||||
|
messageEventsCount: Int,
|
||||||
|
simpleEventsCount: Int,
|
||||||
|
invitationEventsCount: Int,
|
||||||
|
roomCount: Int,
|
||||||
|
lastMessageTimestamp: Long
|
||||||
|
): Notification {
|
||||||
|
// Add the simple events as message (?)
|
||||||
|
val messageNotificationCount = messageEventsCount + simpleEventsCount
|
||||||
|
|
||||||
|
val privacyTitle = if (invitationEventsCount > 0) {
|
||||||
|
val invitationsStr = stringProvider.getQuantityString(StringR.plurals.notification_invitations, invitationEventsCount, invitationEventsCount)
|
||||||
|
if (messageNotificationCount > 0) {
|
||||||
|
// Invitation and message
|
||||||
|
val messageStr = stringProvider.getQuantityString(
|
||||||
|
StringR.plurals.room_new_messages_notification,
|
||||||
|
messageNotificationCount, messageNotificationCount
|
||||||
|
)
|
||||||
|
if (roomCount > 1) {
|
||||||
|
// In several rooms
|
||||||
|
val roomStr = stringProvider.getQuantityString(
|
||||||
|
StringR.plurals.notification_unread_notified_messages_in_room_rooms,
|
||||||
|
roomCount, roomCount
|
||||||
|
)
|
||||||
|
stringProvider.getString(
|
||||||
|
StringR.string.notification_unread_notified_messages_in_room_and_invitation,
|
||||||
|
messageStr,
|
||||||
|
roomStr,
|
||||||
|
invitationsStr
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// In one room
|
||||||
|
stringProvider.getString(
|
||||||
|
StringR.string.notification_unread_notified_messages_and_invitation,
|
||||||
|
messageStr,
|
||||||
|
invitationsStr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only invitation
|
||||||
|
invitationsStr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No invitation, only messages
|
||||||
|
val messageStr = stringProvider.getQuantityString(
|
||||||
|
StringR.plurals.room_new_messages_notification,
|
||||||
|
messageNotificationCount, messageNotificationCount
|
||||||
|
)
|
||||||
|
if (roomCount > 1) {
|
||||||
|
// In several rooms
|
||||||
|
val roomStr = stringProvider.getQuantityString(StringR.plurals.notification_unread_notified_messages_in_room_rooms, roomCount, roomCount)
|
||||||
|
stringProvider.getString(StringR.string.notification_unread_notified_messages_in_room, messageStr, roomStr)
|
||||||
|
} else {
|
||||||
|
// In one room
|
||||||
|
messageStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return notificationUtils.buildSummaryListNotification(
|
||||||
|
style = null,
|
||||||
|
compatSummary = privacyTitle,
|
||||||
|
noisy = summaryIsNoisy,
|
||||||
|
lastMessageTimestamp = lastMessageTimestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2020 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 io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
|
||||||
|
class TestNotificationReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
// Internal broadcast to any one interested
|
||||||
|
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.notifications.model
|
||||||
|
|
||||||
|
data class InviteNotifiableEvent(
|
||||||
|
val matrixID: String?,
|
||||||
|
override val eventId: String,
|
||||||
|
override val editedEventId: String?,
|
||||||
|
override val canBeReplaced: Boolean,
|
||||||
|
val roomId: String,
|
||||||
|
val roomName: String?,
|
||||||
|
val noisy: Boolean,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val type: String?,
|
||||||
|
val timestamp: Long,
|
||||||
|
val soundName: String?,
|
||||||
|
override val isRedacted: Boolean = false,
|
||||||
|
override val isUpdated: Boolean = false
|
||||||
|
) : NotifiableEvent
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* 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 io.element.android.libraries.push.impl.notifications.model
|
||||||
|
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent interface for all events which can be displayed as a Notification.
|
||||||
|
*/
|
||||||
|
sealed interface NotifiableEvent : Serializable {
|
||||||
|
val eventId: String
|
||||||
|
val editedEventId: String?
|
||||||
|
|
||||||
|
// Used to know if event should be replaced with the one coming from eventstream
|
||||||
|
val canBeReplaced: Boolean
|
||||||
|
val isRedacted: Boolean
|
||||||
|
val isUpdated: Boolean
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.notifications.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
data class NotifiableMessageEvent(
|
||||||
|
override val eventId: String,
|
||||||
|
override val editedEventId: String?,
|
||||||
|
override val canBeReplaced: Boolean,
|
||||||
|
val noisy: Boolean,
|
||||||
|
val timestamp: Long,
|
||||||
|
val senderName: String?,
|
||||||
|
val senderId: String?,
|
||||||
|
val body: String?,
|
||||||
|
// We cannot use Uri? type here, as that could trigger a
|
||||||
|
// NotSerializableException when persisting this to storage
|
||||||
|
val imageUriString: String?,
|
||||||
|
val roomId: String,
|
||||||
|
val threadId: String?,
|
||||||
|
val roomName: String?,
|
||||||
|
val roomIsDirect: Boolean = false,
|
||||||
|
val roomAvatarPath: String? = null,
|
||||||
|
val senderAvatarPath: String? = null,
|
||||||
|
val matrixID: String? = null,
|
||||||
|
val soundName: String? = null,
|
||||||
|
// This is used for >N notification, as the result of a smart reply
|
||||||
|
val outGoingMessage: Boolean = false,
|
||||||
|
val outGoingMessageFailed: Boolean = false,
|
||||||
|
override val isRedacted: Boolean = false,
|
||||||
|
override val isUpdated: Boolean = false
|
||||||
|
) : NotifiableEvent {
|
||||||
|
|
||||||
|
val type: String = /* EventType.MESSAGE */ "m.room.message"
|
||||||
|
val description: String = body ?: ""
|
||||||
|
val title: String = senderName ?: ""
|
||||||
|
|
||||||
|
val imageUri: Uri?
|
||||||
|
get() = imageUriString?.let { Uri.parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom(currentRoomId: String?, currentThreadId: String?): Boolean {
|
||||||
|
return when (currentRoomId) {
|
||||||
|
null -> false
|
||||||
|
else -> roomId == currentRoomId && threadId == currentThreadId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.notifications.model
|
||||||
|
|
||||||
|
data class SimpleNotifiableEvent(
|
||||||
|
val matrixID: String?,
|
||||||
|
override val eventId: String,
|
||||||
|
override val editedEventId: String?,
|
||||||
|
val noisy: Boolean,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val type: String?,
|
||||||
|
val timestamp: Long,
|
||||||
|
val soundName: String?,
|
||||||
|
override var canBeReplaced: Boolean,
|
||||||
|
override val isRedacted: Boolean = false,
|
||||||
|
override val isUpdated: Boolean = false
|
||||||
|
) : NotifiableEvent
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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 io.element.android.libraries.push.impl.parser
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
|
import io.element.android.libraries.push.impl.model.PushData
|
||||||
|
import io.element.android.libraries.push.impl.model.PushDataFcm
|
||||||
|
import io.element.android.libraries.push.impl.model.PushDataUnifiedPush
|
||||||
|
import io.element.android.libraries.push.impl.model.toPushData
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the received data from Push. Json format are different depending on the source.
|
||||||
|
*
|
||||||
|
* Notifications received by FCM are formatted by the matrix gateway [1]. The data send to FCM is the content
|
||||||
|
* of the "notification" attribute of the json sent to the gateway [2][3].
|
||||||
|
* On the other side, with UnifiedPush, the content of the message received is the content posted to the push
|
||||||
|
* gateway endpoint [3].
|
||||||
|
*
|
||||||
|
* *Note*: If we want to get the same content with FCM and unifiedpush, we can do a new sygnal pusher [4].
|
||||||
|
*
|
||||||
|
* [1] https://github.com/matrix-org/sygnal/blob/main/sygnal/gcmpushkin.py
|
||||||
|
* [2] https://github.com/matrix-org/sygnal/blob/main/sygnal/gcmpushkin.py#L366
|
||||||
|
* [3] https://spec.matrix.org/latest/push-gateway-api/
|
||||||
|
* [4] https://github.com/p1gp1g/sygnal/blob/unifiedpush/sygnal/upfcmpushkin.py (Not tested for a while)
|
||||||
|
*/
|
||||||
|
class PushParser @Inject constructor() {
|
||||||
|
fun parsePushDataUnifiedPush(message: ByteArray): PushData? {
|
||||||
|
return tryOrNull { Json.decodeFromString<PushDataUnifiedPush>(String(message)) }?.toPushData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parsePushDataFcm(message: Map<String, String?>): PushData {
|
||||||
|
val pushDataFcm = PushDataFcm(
|
||||||
|
eventId = message["event_id"],
|
||||||
|
roomId = message["room_id"],
|
||||||
|
unread = message["unread"]?.let { tryOrNull { Integer.parseInt(it) } },
|
||||||
|
)
|
||||||
|
return pushDataFcm.toPushData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
package io.element.android.libraries.push.impl.store
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.di.DefaultPreferences
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.push.api.model.BackgroundSyncMode
|
||||||
|
import io.element.android.libraries.push.api.store.PushDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "push_store")
|
||||||
|
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultPushDataStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
@DefaultPreferences private val defaultPrefs: SharedPreferences,
|
||||||
|
) : PushDataStore {
|
||||||
|
private val pushCounter = intPreferencesKey("push_counter")
|
||||||
|
|
||||||
|
override val pushCounterFlow: Flow<Int> = context.dataStore.data.map { preferences ->
|
||||||
|
preferences[pushCounter] ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun incrementPushCounter() {
|
||||||
|
context.dataStore.edit { settings ->
|
||||||
|
val currentCounterValue = settings[pushCounter] ?: 0
|
||||||
|
settings[pushCounter] = currentCounterValue + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areNotificationEnabledForDevice(): Boolean {
|
||||||
|
return defaultPrefs.getBoolean(SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setNotificationEnabledForDevice(enabled: Boolean) {
|
||||||
|
defaultPrefs.edit {
|
||||||
|
putBoolean(SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY, enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun backgroundSyncTimeOut(): Int {
|
||||||
|
return tryOrNull {
|
||||||
|
// The xml pref is saved as a string so use getString and parse
|
||||||
|
defaultPrefs.getString(SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, null)?.toInt()
|
||||||
|
} ?: BackgroundSyncMode.DEFAULT_SYNC_TIMEOUT_SECONDS
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setBackgroundSyncTimeout(timeInSecond: Int) {
|
||||||
|
defaultPrefs
|
||||||
|
.edit()
|
||||||
|
.putString(SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, timeInSecond.toString())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun backgroundSyncDelay(): Int {
|
||||||
|
return tryOrNull {
|
||||||
|
// The xml pref is saved as a string so use getString and parse
|
||||||
|
defaultPrefs.getString(SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, null)?.toInt()
|
||||||
|
} ?: BackgroundSyncMode.DEFAULT_SYNC_DELAY_SECONDS
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setBackgroundSyncDelay(timeInSecond: Int) {
|
||||||
|
defaultPrefs
|
||||||
|
.edit()
|
||||||
|
.putString(SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, timeInSecond.toString())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isBackgroundSyncEnabled(): Boolean {
|
||||||
|
return getFdroidSyncBackgroundMode() != BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setFdroidSyncBackgroundMode(mode: BackgroundSyncMode) {
|
||||||
|
defaultPrefs
|
||||||
|
.edit()
|
||||||
|
.putString(SETTINGS_FDROID_BACKGROUND_SYNC_MODE, mode.name)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFdroidSyncBackgroundMode(): BackgroundSyncMode {
|
||||||
|
return try {
|
||||||
|
val strPref = defaultPrefs
|
||||||
|
.getString(SETTINGS_FDROID_BACKGROUND_SYNC_MODE, BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY.name)
|
||||||
|
BackgroundSyncMode.values().firstOrNull { it.name == strPref } ?: BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true if Pin code is disabled, or if user set the settings to see full notification content.
|
||||||
|
*/
|
||||||
|
override fun useCompleteNotificationFormat(): Boolean {
|
||||||
|
return true
|
||||||
|
/*
|
||||||
|
return !useFlagPinCode() ||
|
||||||
|
defaultPrefs.getBoolean(SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG, true)
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// notifications
|
||||||
|
const val SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY = "SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY"
|
||||||
|
const val SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY = "SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY"
|
||||||
|
|
||||||
|
// background sync
|
||||||
|
const val SETTINGS_START_ON_BOOT_PREFERENCE_KEY = "SETTINGS_START_ON_BOOT_PREFERENCE_KEY"
|
||||||
|
const val SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY"
|
||||||
|
const val SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY = "SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY"
|
||||||
|
const val SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY = "SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY"
|
||||||
|
|
||||||
|
const val SETTINGS_FDROID_BACKGROUND_SYNC_MODE = "SETTINGS_FDROID_BACKGROUND_SYNC_MODE"
|
||||||
|
const val SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY"
|
||||||
|
|
||||||
|
const val SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG = "SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG"
|
||||||
|
|
||||||
|
// notification method
|
||||||
|
const val SETTINGS_NOTIFICATION_METHOD_KEY = "SETTINGS_NOTIFICATION_METHOD_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="64dp"
|
||||||
|
android:height="64dp"
|
||||||
|
android:viewportWidth="64"
|
||||||
|
android:viewportHeight="64">
|
||||||
|
<path
|
||||||
|
android:pathData="M23.04,3.84C23.04,1.7192 24.7593,0 26.88,0C41.0185,0 52.48,11.4615 52.48,25.6C52.48,27.7208 50.7608,29.44 48.64,29.44C46.5193,29.44 44.8,27.7208 44.8,25.6C44.8,15.7031 36.777,7.68 26.88,7.68C24.7593,7.68 23.04,5.9608 23.04,3.84Z"
|
||||||
|
android:fillColor="#0DBD8B"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M40.96,60.16C40.96,62.2808 39.2407,64 37.12,64C22.9815,64 11.52,52.5385 11.52,38.4C11.52,36.2792 13.2392,34.56 15.36,34.56C17.4807,34.56 19.2,36.2792 19.2,38.4C19.2,48.2969 27.223,56.32 37.12,56.32C39.2407,56.32 40.96,58.0392 40.96,60.16Z"
|
||||||
|
android:fillColor="#0DBD8B"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M3.84,40.96C1.7192,40.96 -0,39.2407 -0,37.12C-0,22.9815 11.4615,11.52 25.6,11.52C27.7208,11.52 29.44,13.2392 29.44,15.36C29.44,17.4807 27.7208,19.2 25.6,19.2C15.7031,19.2 7.68,27.223 7.68,37.12C7.68,39.2407 5.9608,40.96 3.84,40.96Z"
|
||||||
|
android:fillColor="#0DBD8B"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M60.16,23.04C62.2808,23.04 64,24.7593 64,26.88C64,41.0185 52.5385,52.48 38.4,52.48C36.2792,52.48 34.56,50.7608 34.56,48.64C34.56,46.5193 36.2792,44.8 38.4,44.8C48.2969,44.8 56.32,36.777 56.32,26.88C56.32,24.7593 58.0392,23.04 60.16,23.04Z"
|
||||||
|
android:fillColor="#0DBD8B"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</vector>
|
||||||
BIN
libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png
Executable file
BIN
libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 398 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 473 B |
Binary file not shown.
|
After Width: | Height: | Size: 269 B |
Binary file not shown.
|
After Width: | Height: | Size: 309 B |
22
libraries/push/impl/src/main/res/values/colors.xml
Normal file
22
libraries/push/impl/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright (c) 2023 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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- TODO EAx -->
|
||||||
|
<color name="notification_accent_color">#368BD6</color>
|
||||||
|
|
||||||
|
</resources>
|
||||||
21
libraries/push/impl/src/main/res/values/dimens.xml
Normal file
21
libraries/push/impl/src/main/res/values/dimens.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright (c) 2023 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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<dimen name="profile_avatar_size">50dp</dimen>
|
||||||
|
|
||||||
|
</resources>
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2022 New Vector Ltd
|
||||||
*
|
*
|
||||||
|
|
@ -27,6 +29,14 @@ dependencyResolutionManagement {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
url = URI("https://www.jitpack.io")
|
||||||
|
content {
|
||||||
|
includeModule("com.github.UnifiedPush", "android-connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//noinspection JcenterRepositoryObsolete
|
||||||
|
jcenter()
|
||||||
flatDir {
|
flatDir {
|
||||||
dirs("libraries/matrix/libs")
|
dirs("libraries/matrix/libs")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue