Merge pull request #315 from vector-im/feature/bma/push2

Push - cleanup 1
This commit is contained in:
Benoit Marty 2023-04-13 15:33:41 +02:00 committed by GitHub
commit d4fdc5b964
119 changed files with 1802 additions and 1037 deletions

View file

@ -33,7 +33,8 @@ plugins {
id("com.google.firebase.appdistribution") version "4.0.0" id("com.google.firebase.appdistribution") version "4.0.0"
id("org.jetbrains.kotlinx.knit") version "0.4.0" id("org.jetbrains.kotlinx.knit") version "0.4.0"
id("kotlin-parcelize") id("kotlin-parcelize")
id("com.google.gms.google-services") // To be able to update the firebase.xml files, uncomment and build the project
// id("com.google.gms.google-services")
} }
android { android {
@ -225,9 +226,6 @@ dependencies {
implementation(platform(libs.network.okhttp.bom)) implementation(platform(libs.network.okhttp.bom))
implementation("com.squareup.okhttp3:logging-interceptor") implementation("com.squareup.okhttp3:logging-interceptor")
implementation(platform(libs.google.firebase.bom))
implementation("com.google.firebase:firebase-messaging-ktx")
implementation(libs.dagger) implementation(libs.dagger)
kapt(libs.dagger.compiler) kapt(libs.dagger.compiler)

View file

@ -1,49 +0,0 @@
{
"project_info": {
"project_number": "912726360885",
"firebase_url": "https://vector-alpha.firebaseio.com",
"project_id": "vector-alpha",
"storage_bucket": "vector-alpha.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:912726360885:android:def0a4e454042e9b00427c",
"android_client_info": {
"package_name": "io.element.android.x.debug"
}
},
"oauth_client": [
{
"client_id": "912726360885-hvgoj23p6plt7hikhtdrakihojghaftv.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "io.element.android.x.debug",
"certificate_hash": "41bd63b3b612a15d9ba36a5245c393f2a9b992d1"
}
},
{
"client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View file

@ -1,40 +0,0 @@
{
"project_info": {
"project_number": "912726360885",
"firebase_url": "https://vector-alpha.firebaseio.com",
"project_id": "vector-alpha",
"storage_bucket": "vector-alpha.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:912726360885:android:e17435e0beb0303000427c",
"android_client_info": {
"package_name": "io.element.android.x.nightly"
}
},
"oauth_client": [
{
"client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View file

@ -1,40 +0,0 @@
{
"project_info": {
"project_number": "912726360885",
"firebase_url": "https://vector-alpha.firebaseio.com",
"project_id": "vector-alpha",
"storage_bucket": "vector-alpha.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:912726360885:android:d097de99a4c23d2700427c",
"android_client_info": {
"package_name": "io.element.android.x"
}
},
"oauth_client": [
{
"client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View file

@ -45,6 +45,7 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.push.api) implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)

View file

@ -46,7 +46,10 @@ class LoggedInPresenter @Inject constructor(
override fun present(): LoggedInState { override fun present(): LoggedInState {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Ensure pusher is registered // Ensure pusher is registered
pushService.registerFirebasePusher(matrixClient) // TODO Manually select push provider for now
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
pushService.registerWith(matrixClient, pushProvider, distributor)
} }
val permissionsState = postNotificationPermissionsPresenter.present() val permissionsState = postNotificationPermissionsPresenter.present()

View file

@ -38,7 +38,7 @@ fun LoggedInView(
state = state.permissionsState, state = state.permissionsState,
modifier = modifier, modifier = modifier,
openSystemSettings = { openSystemSettings = {
activity?.let { openAppSettingsPage(it, "") } activity?.let { openAppSettingsPage(it) }
} }
) )
} }

View file

@ -27,6 +27,8 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.providers.api.Distributor
import io.element.android.libraries.push.providers.api.PushProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -55,7 +57,11 @@ class LoggedInPresenterTest {
override fun notificationStyleChanged() { override fun notificationStyleChanged() {
} }
override suspend fun registerFirebasePusher(matrixClient: MatrixClient) { override fun getAvailablePushProviders(): List<PushProvider> {
return emptyList()
}
override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) {
} }
override suspend fun testPush() { override suspend fun testPush() {

View file

@ -230,7 +230,6 @@ koverMerged {
target = kotlinx.kover.api.VerificationTarget.CLASS target = kotlinx.kover.api.VerificationTarget.CLASS
overrideClassFilter { overrideClassFilter {
includes += "*Presenter" includes += "*Presenter"
excludes += "*TemplatePresenter"
excludes += "*Fake*Presenter" excludes += "*Fake*Presenter"
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*" excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
} }

View file

@ -31,7 +31,6 @@ import io.element.android.libraries.androidutils.system.startSharePlainTextInten
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.ui.strings.R as StringR
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class RoomDetailsNode @AssistedInject constructor( class RoomDetailsNode @AssistedInject constructor(
@ -57,7 +56,6 @@ class RoomDetailsNode @AssistedInject constructor(
activityResultLauncher = null, activityResultLauncher = null,
chooserTitle = context.getString(R.string.screen_room_details_share_room_title), chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
text = permalink, text = permalink,
noActivityFoundMessage = context.getString(StringR.string.error_no_compatible_app_found)
) )
} }
} }

View file

@ -133,6 +133,8 @@ sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", vers
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.3" sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.3"
sqlite = "androidx.sqlite:sqlite:2.3.1" sqlite = "androidx.sqlite:sqlite:2.3.1"
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
gujun_span = "me.gujun.android:span:1.7"
# Di # Di
inject = "javax.inject:javax.inject:1" inject = "javax.inject:javax.inject:1"

View file

@ -32,6 +32,7 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.ChecksSdkIntAtLeast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat
/** /**
@ -125,7 +126,7 @@ fun startNotificationSettingsIntent(context: Context, activityResultLauncher: Ac
fun openAppSettingsPage( fun openAppSettingsPage(
activity: Activity, activity: Activity,
noActivityFoundMessage: String, noActivityFoundMessage: String = activity.getString(R.string.error_no_compatible_app_found),
) { ) {
try { try {
activity.startActivity( activity.startActivity(
@ -156,7 +157,7 @@ fun startNotificationChannelSettingsIntent(activity: Activity, channelID: String
fun startAddGoogleAccountIntent( fun startAddGoogleAccountIntent(
context: Context, context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>, activityResultLauncher: ActivityResultLauncher<Intent>,
noActivityFoundMessage: String, noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found),
) { ) {
val intent = Intent(Settings.ACTION_ADD_ACCOUNT) val intent = Intent(Settings.ACTION_ADD_ACCOUNT)
intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google")) intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google"))
@ -171,7 +172,7 @@ fun startAddGoogleAccountIntent(
fun startInstallFromSourceIntent( fun startInstallFromSourceIntent(
context: Context, context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>, activityResultLauncher: ActivityResultLauncher<Intent>,
noActivityFoundMessage: String, noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found),
) { ) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
.setData(Uri.parse(String.format("package:%s", context.packageName))) .setData(Uri.parse(String.format("package:%s", context.packageName)))
@ -189,7 +190,7 @@ fun startSharePlainTextIntent(
text: String, text: String,
subject: String? = null, subject: String? = null,
extraTitle: String? = null, extraTitle: String? = null,
noActivityFoundMessage: String, noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found),
) { ) {
val share = Intent(Intent.ACTION_SEND) val share = Intent(Intent.ACTION_SEND)
share.type = "text/plain" share.type = "text/plain"
@ -217,7 +218,7 @@ fun startSharePlainTextIntent(
fun startImportTextFromFileIntent( fun startImportTextFromFileIntent(
context: Context, context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>, activityResultLauncher: ActivityResultLauncher<Intent>,
noActivityFoundMessage: String, noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found),
) { ) {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply { val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = "text/plain" type = "text/plain"

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"No compatible app was found to handle this action."</string>
</resources>

View file

@ -16,9 +16,14 @@
package io.element.android.libraries.matrix.api.core package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.matrix.api.BuildConfig
import java.io.Serializable import java.io.Serializable
@JvmInline @JvmInline
value class EventId(val value: String) : Serializable value class EventId(val value: String) : Serializable
fun String.asEventId() = EventId(this) fun String.asEventId() = if (BuildConfig.DEBUG && !MatrixPatterns.isEventId(this)) {
error("`$this` is not a valid event Id")
} else {
EventId(this)
}

View file

@ -91,6 +91,14 @@ object MatrixPatterns {
PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER
) )
/**
* Tells if a string is a valid session Id. This is an alias for [isUserId]
*
* @param str the string to test
* @return true if the string is a valid session id
*/
fun isSessionId(str: String?) = isUserId(str)
/** /**
* Tells if a string is a valid user Id. * Tells if a string is a valid user Id.
* *
@ -101,6 +109,14 @@ object MatrixPatterns {
return str != null && str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER return str != null && str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER
} }
/**
* Tells if a string is a valid space id. This is an alias for [isRoomId]
*
* @param str the string to test
* @return true if the string is a valid space Id
*/
fun isSpaceId(str: String?) = isRoomId(str)
/** /**
* Tells if a string is a valid room id. * Tells if a string is a valid room id.
* *
@ -134,6 +150,14 @@ object MatrixPatterns {
str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4) str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4)
} }
/**
* Tells if a string is a valid thread id. This is an alias for [isEventId].
*
* @param str the string to test
* @return true if the string is a valid thread id.
*/
fun isThreadId(str: String?) = isEventId(str)
/** /**
* Tells if a string is a valid group id. * Tells if a string is a valid group id.
* *

View file

@ -16,9 +16,14 @@
package io.element.android.libraries.matrix.api.core package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.matrix.api.BuildConfig
import java.io.Serializable import java.io.Serializable
@JvmInline @JvmInline
value class RoomId(val value: String) : Serializable value class RoomId(val value: String) : Serializable
fun String.asRoomId() = RoomId(this) fun String.asRoomId() = if (BuildConfig.DEBUG && !MatrixPatterns.isRoomId(this)) {
error("`$this` is not a valid room Id")
} else {
RoomId(this)
}

View file

@ -16,6 +16,12 @@
package io.element.android.libraries.matrix.api.core package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.matrix.api.BuildConfig
typealias SessionId = UserId typealias SessionId = UserId
fun String.asSessionId() = SessionId(this) fun String.asSessionId() = if (BuildConfig.DEBUG && !MatrixPatterns.isSessionId(this)) {
error("`$this` is not a valid session Id")
} else {
SessionId(this)
}

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.api.core package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.matrix.api.BuildConfig
import java.io.Serializable import java.io.Serializable
@JvmInline @JvmInline
@ -26,4 +27,8 @@ value class SpaceId(val value: String) : Serializable
*/ */
val MAIN_SPACE = SpaceId("!mainSpace") val MAIN_SPACE = SpaceId("!mainSpace")
fun String.asSpaceId() = SpaceId(this) fun String.asSpaceId() = if (BuildConfig.DEBUG && !MatrixPatterns.isSpaceId(this)) {
error("`$this` is not a valid space Id")
} else {
SpaceId(this)
}

View file

@ -16,9 +16,14 @@
package io.element.android.libraries.matrix.api.core package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.matrix.api.BuildConfig
import java.io.Serializable import java.io.Serializable
@JvmInline @JvmInline
value class ThreadId(val value: String) : Serializable value class ThreadId(val value: String) : Serializable
fun String.asThreadId() = ThreadId(this) fun String.asThreadId() = if (BuildConfig.DEBUG && !MatrixPatterns.isThreadId(this)) {
error("`$this` is not a valid thread Id")
} else {
ThreadId(this)
}

View file

@ -16,9 +16,14 @@
package io.element.android.libraries.matrix.api.core package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.matrix.api.BuildConfig
import java.io.Serializable import java.io.Serializable
@JvmInline @JvmInline
value class UserId(val value: String) : Serializable value class UserId(val value: String) : Serializable
fun String.asUserId() = UserId(this) fun String.asUserId() = if (BuildConfig.DEBUG && !MatrixPatterns.isUserId(this)) {
error("`$this` is not a valid user Id")
} else {
UserId(this)
}

View file

@ -18,4 +18,5 @@ package io.element.android.libraries.matrix.api.pusher
interface PushersService { interface PushersService {
suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result<Unit> suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result<Unit>
suspend fun unsetHttpPusher(): Result<Unit>
} }

View file

@ -53,4 +53,9 @@ class RustPushersService(
} }
} }
} }
override suspend fun unsetHttpPusher(): Result<Unit> {
// TODO Missing client API. We need to set the pusher with Kind == null, but we do not have access to this field from the SDK.
return Result.success(Unit)
}
} }

View file

@ -31,9 +31,9 @@ val A_USER_ID = UserId("@alice:server.org")
val A_USER_ID_2 = UserId("@bob:server.org") val A_USER_ID_2 = UserId("@bob:server.org")
val A_SESSION_ID = SessionId(A_USER_ID.value) val A_SESSION_ID = SessionId(A_USER_ID.value)
val A_SESSION_ID_2 = SessionId(A_USER_ID_2.value) val A_SESSION_ID_2 = SessionId(A_USER_ID_2.value)
val A_SPACE_ID = SpaceId("!aSpaceId") val A_SPACE_ID = SpaceId("!aSpaceId:domain")
val A_ROOM_ID = RoomId("!aRoomId") val A_ROOM_ID = RoomId("!aRoomId:domain")
val A_ROOM_ID_2 = RoomId("!aRoomId2") val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
val A_THREAD_ID = ThreadId("\$aThreadId") val A_THREAD_ID = ThreadId("\$aThreadId")
val AN_EVENT_ID = EventId("\$anEventId") val AN_EVENT_ID = EventId("\$anEventId")
val AN_EVENT_ID_2 = EventId("\$anEventId2") val AN_EVENT_ID_2 = EventId("\$anEventId2")

View file

@ -21,4 +21,5 @@ import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
class FakePushersService : PushersService { class FakePushersService : PushersService {
override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Result.success(Unit) override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Result.success(Unit)
override suspend fun unsetHttpPusher(): Result<Unit> = Result.success(Unit)
} }

View file

@ -16,6 +16,7 @@
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.ksp)
} }
android { android {
@ -27,4 +28,6 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
} }

View file

@ -32,48 +32,52 @@ fun PermissionsView(
) { ) {
if (state.showDialog.not()) return if (state.showDialog.not()) return
if (state.permissionGranted) { when {
// Notification Granted, nothing to do state.permissionGranted -> {
} else if (state.permissionAlreadyDenied) { // Notification Granted, nothing to do
// In this case, tell the user to go to the settings }
ConfirmationDialog( state.permissionAlreadyDenied -> {
modifier = modifier, // In this case, tell the user to go to the settings
title = "System", ConfirmationDialog(
content = "In order to let the application display notification, please grant the permission to the system settings", modifier = modifier,
submitText = "Open settings", title = "System",
onSubmitClicked = { content = "In order to let the application display notification, please grant the permission to the system settings",
state.eventSink.invoke(PermissionsEvents.CloseDialog) submitText = "Open settings",
openSystemSettings() onSubmitClicked = {
}, state.eventSink.invoke(PermissionsEvents.CloseDialog)
onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, openSystemSettings()
) },
} else { onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) },
val textToShow = if (state.shouldShowRationale) { )
// TODO Move to state }
// If the user has denied the permission but the rationale can be shown, else -> {
// then gently explain why the app requires this permission val textToShow = if (state.shouldShowRationale) {
// permissions_rationale_msg_notification // TODO Move to state
"To be able to receive notifications, please grant the permission. Else you will not be able to be alerted if you've got new messages." // If the user has denied the permission but the rationale can be shown,
} else { // then gently explain why the app requires this permission
// TODO Move to state // permissions_rationale_msg_notification
// If it's the first time the user lands on this feature, or the user "To be able to receive notifications, please grant the permission. Else you will not be able to be alerted if you've got new messages."
// doesn't want to be asked again for this permission, explain that the } else {
// permission is required // TODO Move to state
"To be able to receive notifications, please grant the permission." // If it's the first time the user lands on this feature, or the user
// doesn't want to be asked again for this permission, explain that the
// permission is required
"To be able to receive notifications, please grant the permission."
}
ConfirmationDialog(
modifier = modifier,
title = "Notifications",
content = textToShow,
submitText = "Request permission",
onSubmitClicked = {
state.eventSink.invoke(PermissionsEvents.OpenSystemDialog)
},
onCancelClicked = {
state.eventSink.invoke(PermissionsEvents.CloseDialog)
},
onDismiss = {}
)
} }
ConfirmationDialog(
modifier = modifier,
title = "Notifications",
content = textToShow,
submitText = "Request permission",
onSubmitClicked = {
state.eventSink.invoke(PermissionsEvents.OpenSystemDialog)
},
onCancelClicked = {
state.eventSink.invoke(PermissionsEvents.CloseDialog)
},
onDismiss = {}
)
} }
} }

View file

@ -23,7 +23,8 @@ open class PermissionsViewStateProvider : PreviewParameterProvider<PermissionsSt
override val values: Sequence<PermissionsState> override val values: Sequence<PermissionsState>
get() = sequenceOf( get() = sequenceOf(
aPermissionsState(), aPermissionsState(),
// Add other state here aPermissionsState().copy(shouldShowRationale = true),
aPermissionsState().copy(permissionAlreadyDenied = true),
) )
} }

View file

@ -33,6 +33,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
@ -40,6 +41,8 @@ import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
private val loggerTag = LoggerTag("DefaultPermissionsPresenter")
class DefaultPermissionsPresenter @AssistedInject constructor( class DefaultPermissionsPresenter @AssistedInject constructor(
@Assisted val permission: String, @Assisted val permission: String,
private val permissionsStore: PermissionsStore, private val permissionsStore: PermissionsStore,
@ -71,7 +74,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
var permissionState: PermissionState? = null var permissionState: PermissionState? = null
fun onPermissionResult(result: Boolean) { fun onPermissionResult(result: Boolean) {
Timber.tag("PERMISSION").w("onPermissionResult: $result") Timber.tag(loggerTag.value).d("onPermissionResult: $result")
localCoroutineScope.launch { localCoroutineScope.launch {
permissionsStore.setPermissionAsked(permission, true) permissionsStore.setPermissionAsked(permission, true)
} }
@ -79,7 +82,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
if (!result) { if (!result) {
// Should show rational true -> denied. // Should show rational true -> denied.
if (permissionState?.status?.shouldShowRationale == true) { if (permissionState?.status?.shouldShowRationale == true) {
Timber.tag("PERMISSION").w("onPermissionResult: reset the store") Timber.tag(loggerTag.value).d("onPermissionResult: setPermissionDenied to true")
localCoroutineScope.launch { localCoroutineScope.launch {
permissionsStore.setPermissionDenied(permission, true) permissionsStore.setPermissionDenied(permission, true)
} }
@ -102,7 +105,6 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
val showDialog = rememberSaveable { mutableStateOf(permissionState.status !is PermissionStatus.Granted) } val showDialog = rememberSaveable { mutableStateOf(permissionState.status !is PermissionStatus.Granted) }
fun handleEvents(event: PermissionsEvents) { fun handleEvents(event: PermissionsEvents) {
Timber.tag("PERMISSION").w("New event: $event")
when (event) { when (event) {
PermissionsEvents.CloseDialog -> { PermissionsEvents.CloseDialog -> {
showDialog.value = false showDialog.value = false
@ -123,7 +125,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
permissionAlreadyDenied = isAlreadyDenied, permissionAlreadyDenied = isAlreadyDenied,
eventSink = ::handleEvents eventSink = ::handleEvents
).also { ).also {
Timber.tag("PERMISSION").w("New state: $it") Timber.tag(loggerTag.value).d("New state: $it")
} }
} }

View file

@ -26,4 +26,5 @@ dependencies {
implementation(libs.androidx.corektx) implementation(libs.androidx.corektx)
implementation(libs.coroutines.core) implementation(libs.coroutines.core)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.pushproviders.api)
} }

View file

@ -17,12 +17,22 @@
package io.element.android.libraries.push.api package io.element.android.libraries.push.api
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.providers.api.Distributor
import io.element.android.libraries.push.providers.api.PushProvider
interface PushService { interface PushService {
// TODO Move away
fun notificationStyleChanged() fun notificationStyleChanged()
// Ensure pusher is registered fun getAvailablePushProviders(): List<PushProvider>
suspend fun registerFirebasePusher(matrixClient: MatrixClient)
/**
* Will unregister any previous pusher and register a new one with the provided [PushProvider].
*
* The method has effect only if the [PushProvider] is different than the current one.
*/
suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor)
// TODO Move away
suspend fun testPush() suspend fun testPush()
} }

View file

@ -1,48 +0,0 @@
/*
* 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
}
}

View file

@ -16,26 +16,8 @@
package io.element.android.libraries.push.api.store package io.element.android.libraries.push.api.store
import io.element.android.libraries.push.api.model.BackgroundSyncMode
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface PushDataStore { interface PushDataStore {
val pushCounterFlow: Flow<Int> val pushCounterFlow: Flow<Int>
// TODO Move all those settings to the per user store...
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
} }

View file

@ -43,21 +43,20 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.network) implementation(projects.libraries.network)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
api(projects.libraries.pushproviders.api)
api(projects.libraries.pushstore.api)
api(projects.libraries.push.api) api(projects.libraries.push.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
api("me.gujun.android:span:1.7") { api(libs.gujun.span) {
exclude(group = "com.android.support", module = "support-annotations") exclude(group = "com.android.support", module = "support-annotations")
} }
implementation(platform(libs.google.firebase.bom)) // TODO Temporary use the deprecated LocalBroadcastManager, to be changed later.
implementation("com.google.firebase:firebase-messaging-ktx") implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
// UnifiedPush
api("com.github.UnifiedPush:android-connector:2.1.1")
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)

View file

@ -14,63 +14,15 @@
~ limitations under the License. ~ limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application> <application>
<!-- Firebase components -->
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="true" />
<service
android:name=".firebase.VectorFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- UnifiedPush -->
<receiver
android:name=".unifiedpush.VectorUnifiedPushMessagingReceiver"
android:enabled="true"
android:exported="true"
tools:ignore="ExportedReceiver">
<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=".unifiedpush.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>
<receiver <receiver
android:name=".notifications.TestNotificationReceiver" android:name=".notifications.TestNotificationReceiver"
android:exported="false" /> android:exported="false" />
<receiver <receiver
android:name=".notifications.NotificationBroadcastReceiver" android:name=".notifications.NotificationBroadcastReceiver"
android:enabled="true" android:enabled="true"
android:exported="false" /> android:exported="false" />
</application> </application>
</manifest> </manifest>

View file

@ -20,27 +20,41 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.impl.config.PushConfig import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
import timber.log.Timber import io.element.android.libraries.push.providers.api.Distributor
import io.element.android.libraries.push.providers.api.PushProvider
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPushService @Inject constructor( class DefaultPushService @Inject constructor(
private val notificationDrawerManager: NotificationDrawerManager, private val notificationDrawerManager: NotificationDrawerManager,
private val pushersManager: PushersManager, private val pushersManager: PushersManager,
private val fcmHelper: FcmHelper, private val userPushStoreFactory: UserPushStoreFactory,
private val pushProviders: Set<@JvmSuppressWildcards PushProvider>,
) : PushService { ) : PushService {
override fun notificationStyleChanged() { override fun notificationStyleChanged() {
notificationDrawerManager.notificationStyleChanged() notificationDrawerManager.notificationStyleChanged()
} }
override suspend fun registerFirebasePusher(matrixClient: MatrixClient) { override fun getAvailablePushProviders(): List<PushProvider> {
val pushKey = fcmHelper.getFcmToken() ?: return Unit.also { return pushProviders.sortedBy { it.index }
Timber.tag(pushLoggerTag.value).w("Unable to register pusher, Firebase token is not known.") }
/**
* Get current push provider, compare with provided one, then unregister and register if different, and store change.
*/
override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) {
val userPushStore = userPushStoreFactory.create(matrixClient.sessionId)
val currentPushProviderName = userPushStore.getPushProviderName()
if (currentPushProviderName != pushProvider.name) {
// Unregister previous one if any
pushProviders.find { it.name == currentPushProviderName }?.unregister(matrixClient)
} }
pushersManager.registerPusher(matrixClient, pushKey, PushConfig.pusher_http_url) pushProvider.registerWith(matrixClient, distributor)
// Store new value
userPushStore.setPushProviderName(pushProvider.name)
} }
override suspend fun testPush() { override suspend fun testPush() {

View file

@ -1,49 +0,0 @@
/*
* 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)
*/
}

View file

@ -1,104 +0,0 @@
/*
* 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 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 kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject
/**
* 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) {
runBlocking {// TODO
pushersManager.enqueueRegisterPusherWithFcmKey(token)
}
}
}
.addOnFailureListener { e ->
Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed")
}
} catch (e: Throwable) {
Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed")
}
} else {
Toast.makeText(context, R.string.push_no_valid_google_play_services_apk_android, 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"
}
}

View file

@ -16,20 +16,19 @@
package io.element.android.libraries.push.impl package io.element.android.libraries.push.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
import io.element.android.libraries.push.impl.clientsecret.PushClientSecret
import io.element.android.libraries.push.impl.config.PushConfig import io.element.android.libraries.push.impl.config.PushConfig
import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest
import io.element.android.libraries.push.impl.userpushstore.UserPushStoreFactory import io.element.android.libraries.push.providers.api.PusherSubscriber
import io.element.android.libraries.push.impl.userpushstore.isFirebase import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.libraries.sessionstorage.api.toUserList
import io.element.android.services.toolbox.api.appname.AppNameProvider import io.element.android.services.toolbox.api.appname.AppNameProvider
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -38,62 +37,32 @@ internal const val DEFAULT_PUSHER_FILE_TAG = "mobile"
private val loggerTag = LoggerTag("PushersManager", pushLoggerTag) private val loggerTag = LoggerTag("PushersManager", pushLoggerTag)
@ContributesBinding(AppScope::class)
class PushersManager @Inject constructor( class PushersManager @Inject constructor(
private val unifiedPushHelper: UnifiedPushHelper,
// private val localeProvider: LocaleProvider, // private val localeProvider: LocaleProvider,
private val appNameProvider: AppNameProvider, private val appNameProvider: AppNameProvider,
// private val getDeviceInfoUseCase: GetDeviceInfoUseCase, // private val getDeviceInfoUseCase: GetDeviceInfoUseCase,
private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, private val pushGatewayNotifyRequest: PushGatewayNotifyRequest,
private val pushClientSecret: PushClientSecret, private val pushClientSecret: PushClientSecret,
private val sessionStore: SessionStore,
private val matrixAuthenticationService: MatrixAuthenticationService,
private val userPushStoreFactory: UserPushStoreFactory, private val userPushStoreFactory: UserPushStoreFactory,
private val fcmHelper: FcmHelper, ) : PusherSubscriber {
) { // TODO Move this to the PushProvider API
suspend fun testPush() { suspend fun testPush() {
pushGatewayNotifyRequest.execute( pushGatewayNotifyRequest.execute(
PushGatewayNotifyRequest.Params( PushGatewayNotifyRequest.Params(
url = unifiedPushHelper.getPushGateway() ?: return, url = "TODO", // unifiedPushHelper.getPushGateway() ?: return,
appId = PushConfig.pusher_app_id, appId = PushConfig.pusher_app_id,
pushKey = unifiedPushHelper.getEndpointOrToken().orEmpty(), pushKey = "TODO", // unifiedPushHelper.getEndpointOrToken().orEmpty(),
eventId = TEST_EVENT_ID eventId = TEST_EVENT_ID
) )
) )
} }
suspend fun enqueueRegisterPusherWithFcmKey(pushKey: String) {
// return onNewFirebaseToken(pushKey, PushConfig.pusher_http_url)
TODO()
}
suspend fun onNewUnifiedPushEndpoint(
pushKey: String,
gateway: String
) {
TODO()
}
suspend fun onNewFirebaseToken(firebaseToken: String) {
fcmHelper.storeFcmToken(firebaseToken)
// Register the pusher for all the sessions
sessionStore.getAllSessions().toUserList().forEach { userId ->
val userDataStore = userPushStoreFactory.create(userId)
if (userDataStore.isFirebase()) {
matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull()?.use { client ->
registerPusher(client, firebaseToken, PushConfig.pusher_http_url)
}
} else {
Timber.tag(loggerTag.value).d("This session is not using Firebase pusher")
}
}
}
/** /**
* Register a pusher to the server if not done yet. * Register a pusher to the server if not done yet.
*/ */
suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) {
val userDataStore = userPushStoreFactory.create(matrixClient.sessionId.value) val userDataStore = userPushStoreFactory.create(matrixClient.sessionId)
if (userDataStore.getCurrentRegisteredPushKey() == pushKey) { if (userDataStore.getCurrentRegisteredPushKey() == pushKey) {
Timber.tag(loggerTag.value).d("Unnecessary to register again the same pusher") Timber.tag(loggerTag.value).d("Unnecessary to register again the same pusher")
} else { } else {
@ -162,9 +131,8 @@ class PushersManager @Inject constructor(
// currentSession.pushersService().removeEmailPusher(email) // currentSession.pushersService().removeEmailPusher(email)
} }
suspend fun unregisterPusher(pushKey: String) { override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) {
// val currentSession = activeSessionHolder.getSafeActiveSession() ?: return matrixClient.pushersService().unsetHttpPusher()
// currentSession.pushersService().removeHttpPusher(pushKey, PushConfig.pusher_app_id)
} }
companion object { companion object {

View file

@ -1,179 +0,0 @@
/*
* 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.services.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
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()) {
R.string.push_distributor_firebase_android
} else {
R.string.push_distributor_background_sync_android
}
)
val distributors = UnifiedPush.getDistributors(context)
val distributorsName = distributors.map {
if (it == context.packageName) {
internalDistributorName
} else {
context.getApplicationLabel(it)
}
}
MaterialAlertDialogBuilder(context)
.setTitle(stringProvider.getString(R.string.push_choose_distributor_dialog_title_android))
.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(R.string.push_distributor_firebase_android)
isBackgroundSync() -> stringProvider.getString(R.string.push_distributor_background_sync_android)
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()
}
}

View file

@ -17,25 +17,8 @@
package io.element.android.libraries.push.impl.config package io.element.android.libraries.push.impl.config
object PushConfig { 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. * Note: pusher_app_id cannot exceed 64 chars.
*/ */
const val pusher_app_id: String = "im.vector.app.android" 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
} }

View file

@ -70,7 +70,8 @@ class NotificationDrawerManager @Inject constructor(
private var currentAppNavigationState: AppNavigationState? = null private var currentAppNavigationState: AppNavigationState? = null
private val firstThrottler = FirstThrottler(200) private val firstThrottler = FirstThrottler(200)
private var useCompleteNotificationFormat = pushDataStore.useCompleteNotificationFormat() // TODO EAx add a setting per user for this
private var useCompleteNotificationFormat = true
init { init {
handlerThread.start() handlerThread.start()
@ -111,12 +112,6 @@ class NotificationDrawerManager @Inject constructor(
} }
private fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { private 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) { if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.d("onNotifiableEventReceived(): $notifiableEvent") Timber.d("onNotifiableEventReceived(): $notifiableEvent")
} else { } else {
@ -185,7 +180,7 @@ class NotificationDrawerManager @Inject constructor(
// TODO EAx Must be per account // TODO EAx Must be per account
fun notificationStyleChanged() { fun notificationStyleChanged() {
updateEvents { updateEvents {
val newSettings = pushDataStore.useCompleteNotificationFormat() val newSettings = true // pushDataStore.useCompleteNotificationFormat()
if (newSettings != useCompleteNotificationFormat) { if (newSettings != useCompleteNotificationFormat) {
// Settings has changed, remove all current notifications // Settings has changed, remove all current notifications
notificationRenderer.cancelAllNotifications() notificationRenderer.cancelAllNotifications()

View file

@ -34,6 +34,7 @@ data class NotificationEventQueue constructor(
* Acts as a notification debouncer to stop already dismissed push notifications from * Acts as a notification debouncer to stop already dismissed push notifications from
* displaying again when the /sync response is delayed. * displaying again when the /sync response is delayed.
*/ */
// TODO Should be per session, so the key must be Pair<SessionId, EventId>.
private val seenEventIds: CircularCache<EventId> private val seenEventIds: CircularCache<EventId>
) { ) {

View file

@ -21,19 +21,22 @@ import android.content.Intent
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import io.element.android.libraries.androidutils.network.WifiDetector import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta 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.ApplicationContext
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.push.api.store.PushDataStore
import io.element.android.libraries.push.impl.PushersManager import io.element.android.libraries.push.impl.PushersManager
import io.element.android.libraries.push.impl.clientsecret.PushClientSecret
import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver 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.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
import io.element.android.libraries.push.impl.store.DefaultPushDataStore import io.element.android.libraries.push.impl.store.DefaultPushDataStore
import io.element.android.libraries.push.providers.api.PushData
import io.element.android.libraries.push.providers.api.PushHandler
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -43,20 +46,20 @@ import javax.inject.Inject
private val loggerTag = LoggerTag("PushHandler", pushLoggerTag) private val loggerTag = LoggerTag("PushHandler", pushLoggerTag)
class PushHandler @Inject constructor( @ContributesBinding(AppScope::class)
class DefaultPushHandler @Inject constructor(
private val notificationDrawerManager: NotificationDrawerManager, private val notificationDrawerManager: NotificationDrawerManager,
private val notifiableEventResolver: NotifiableEventResolver, private val notifiableEventResolver: NotifiableEventResolver,
private val pushDataStore: PushDataStore,
private val defaultPushDataStore: DefaultPushDataStore, private val defaultPushDataStore: DefaultPushDataStore,
private val userPushStoreFactory: UserPushStoreFactory,
private val pushClientSecret: PushClientSecret, private val pushClientSecret: PushClientSecret,
private val actionIds: NotificationActionIds, private val actionIds: NotificationActionIds,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val matrixAuthenticationService: MatrixAuthenticationService, private val matrixAuthenticationService: MatrixAuthenticationService,
) { ) : PushHandler {
private val coroutineScope = CoroutineScope(SupervisorJob()) private val coroutineScope = CoroutineScope(SupervisorJob())
private val wifiDetector: WifiDetector = WifiDetector(context)
// UI handler // UI handler
private val mUIHandler by lazy { private val mUIHandler by lazy {
@ -68,7 +71,7 @@ class PushHandler @Inject constructor(
* *
* @param pushData the data received in the push. * @param pushData the data received in the push.
*/ */
suspend fun handle(pushData: PushData) { override suspend fun handle(pushData: PushData) {
Timber.tag(loggerTag.value).d("## handling pushData") Timber.tag(loggerTag.value).d("## handling pushData")
if (buildMeta.lowPrivacyLoggingEnabled) { if (buildMeta.lowPrivacyLoggingEnabled) {
@ -84,12 +87,6 @@ class PushHandler @Inject constructor(
return return
} }
// TODO EAx Should be per user
if (!pushDataStore.areNotificationEnabledForDevice()) {
Timber.tag(loggerTag.value).i("Notification are disabled for this device")
return
}
mUIHandler.post { mUIHandler.post {
coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) } coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) }
} }
@ -134,6 +131,13 @@ class PushHandler @Inject constructor(
return return
} }
val userPushStore = userPushStoreFactory.create(userId)
if (!userPushStore.areNotificationEnabledForDevice()) {
// TODO We need to check if this is an incoming call
Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.")
return
}
notificationDrawerManager.onNotifiableEventReceived(notificationData) notificationDrawerManager.onNotifiableEventReceived(notificationData)
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")

View file

@ -17,20 +17,15 @@
package io.element.android.libraries.push.impl.store package io.element.android.libraries.push.impl.store
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding 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.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.libraries.push.api.model.BackgroundSyncMode
import io.element.android.libraries.push.api.store.PushDataStore import io.element.android.libraries.push.api.store.PushDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -42,7 +37,6 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPushDataStore @Inject constructor( class DefaultPushDataStore @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@DefaultPreferences private val defaultPrefs: SharedPreferences,
) : PushDataStore { ) : PushDataStore {
private val pushCounter = intPreferencesKey("push_counter") private val pushCounter = intPreferencesKey("push_counter")
@ -56,94 +50,4 @@ class DefaultPushDataStore @Inject constructor(
settings[pushCounter] = currentCounterValue + 1 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"
}
} }

View 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.providers.api"
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
}

View file

@ -0,0 +1,22 @@
/*
* 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.providers.api
data class Distributor(
val value: String,
val name: String,
)

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.push package io.element.android.libraries.push.providers.api
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -22,14 +22,14 @@ import io.element.android.libraries.matrix.api.core.RoomId
/** /**
* Represent parsed data that the app has received from a Push content. * 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 eventId The Event Id.
* @property roomId The Room ID. If not null, it will not be empty, and will have a valid format. * @property roomId The Room Id.
* @property unread Number of unread message. * @property unread Number of unread message.
* @property clientSecret A client secret, used to determine which user should receive the notification. * @property clientSecret data used when the pusher was configured, to be able to determine the session.
*/ */
data class PushData( data class PushData(
val eventId: EventId?, val eventId: EventId,
val roomId: RoomId?, val roomId: RoomId,
val unread: Int?, val unread: Int?,
val clientSecret: String?, val clientSecret: String?,
) )

View file

@ -0,0 +1,21 @@
/*
* 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.providers.api
interface PushHandler {
suspend fun handle(pushData: PushData)
}

View file

@ -0,0 +1,51 @@
/*
* 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.providers.api
import io.element.android.libraries.matrix.api.MatrixClient
/**
* This is the main API for this module.
*/
interface PushProvider {
/**
* Allow to sort providers, from lower index to higher index.
*/
val index: Int
/**
* User friendly name.
*/
val name: String
fun getDistributors(): List<Distributor>
/**
* Register the pusher to the homeserver.
*/
suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor)
/**
* Unregister the pusher.
*/
suspend fun unregister(matrixClient: MatrixClient)
/**
* Attempt to troubleshoot the push provider.
*/
suspend fun troubleshoot(): Result<Unit>
}

View file

@ -0,0 +1,24 @@
/*
* 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.providers.api
import io.element.android.libraries.matrix.api.MatrixClient
interface PusherSubscriber {
suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String)
suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String)
}

View file

@ -0,0 +1,7 @@
# Firebase
## Configuration
In order to make this module only know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module.
To be able to change the values in the file `firebase.xml` from this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app/src/main`, build the project, and check the generated file `app/build/generated/res/google-services/<buildtype>/values/values.xml` to import the generated values into the `firebase.xml` files.

View file

@ -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.
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.libraries.push.providers.firebase"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
implementation(libs.androidx.corektx)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.pushstore.api)
implementation(projects.libraries.pushproviders.api)
api(platform(libs.google.firebase.bom))
api("com.google.firebase:firebase-messaging-ktx")
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="google_app_id" translatable="false">1:912726360885:android:def0a4e454042e9b00427c</string>
</resources>

View file

@ -0,0 +1,31 @@
<?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">
<application>
<!-- Firebase components -->
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="true" />
<service
android:name="io.element.android.libraries.push.providers.firebase.VectorFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -14,24 +14,22 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.firebase package io.element.android.libraries.push.providers.firebase
import io.element.android.libraries.push.impl.FcmHelper
import io.element.android.libraries.push.impl.PushersManager
import io.element.android.libraries.push.impl.UnifiedPushHelper
import javax.inject.Inject import javax.inject.Inject
// TODO
class EnsureFcmTokenIsRetrievedUseCase @Inject constructor( class EnsureFcmTokenIsRetrievedUseCase @Inject constructor(
private val unifiedPushHelper: UnifiedPushHelper, // private val unifiedPushHelper: UnifiedPushHelper,
private val fcmHelper: FcmHelper, // private val fcmHelper: FcmHelper,
// private val activeSessionHolder: ActiveSessionHolder, // private val activeSessionHolder: ActiveSessionHolder,
) { ) {
fun execute(pushersManager: PushersManager, registerPusher: Boolean) { // fun execute(pushersManager: PushersManager, registerPusher: Boolean) {
if (unifiedPushHelper.isEmbeddedDistributor()) { // if (unifiedPushHelper.isEmbeddedDistributor()) {
fcmHelper.ensureFcmTokenIsRetrieved(pushersManager, shouldAddHttpPusher(registerPusher)) // fcmHelper.ensureFcmTokenIsRetrieved(pushersManager, shouldAddHttpPusher(registerPusher))
} // }
} // }
private fun shouldAddHttpPusher(registerPusher: Boolean) = if (registerPusher) { private fun shouldAddHttpPusher(registerPusher: Boolean) = if (registerPusher) {
/* /*

View 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.
*/
package io.element.android.libraries.push.providers.firebase
object FirebaseConfig {
/**
* It is the push gateway for firebase.
* Note: pusher_http_url should have path '/_matrix/push/v1/notify' -->
*/
const val pusher_http_url: String = "https://matrix.org/_matrix/push/v1/notify"
const val index = 0
const val name = "Firebase"
}

View file

@ -0,0 +1,57 @@
/*
* 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.providers.firebase
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.asSessionId
import io.element.android.libraries.push.providers.api.PusherSubscriber
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.api.toUserList
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("FirebaseNewTokenHandler")
/**
* Handle new token receive from Firebase. Will update all the sessions which are using Firebase as a push provider.
*/
class FirebaseNewTokenHandler @Inject constructor(
private val pusherSubscriber: PusherSubscriber,
private val sessionStore: SessionStore,
private val userPushStoreFactory: UserPushStoreFactory,
private val matrixAuthenticationService: MatrixAuthenticationService,
private val firebaseStore: FirebaseStore,
) {
suspend fun handle(firebaseToken: String) {
firebaseStore.storeFcmToken(firebaseToken)
// Register the pusher for all the sessions
sessionStore.getAllSessions().toUserList()
.mapNotNull { it.asSessionId() }
.forEach { userId ->
val userDataStore = userPushStoreFactory.create(userId)
if (userDataStore.getPushProviderName() == FirebaseConfig.name) {
matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client ->
pusherSubscriber.registerPusher(client, firebaseToken, FirebaseConfig.pusher_http_url)
}
} else {
Timber.tag(loggerTag.value).d("This session is not using Firebase pusher")
}
}
}
}

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,18 +14,17 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.firebase package io.element.android.libraries.push.providers.firebase
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.push.providers.api.PushData
import io.element.android.libraries.push.impl.push.PushData
import javax.inject.Inject import javax.inject.Inject
class FirebasePushParser @Inject constructor() { class FirebasePushParser @Inject constructor() {
fun parse(message: Map<String, String?>): PushData { fun parse(message: Map<String, String?>): PushData? {
val pushDataFirebase = PushDataFirebase( val pushDataFirebase = PushDataFirebase(
eventId = message["event_id"], eventId = message["event_id"],
roomId = message["room_id"], roomId = message["room_id"],
unread = message["unread"]?.let { tryOrNull { Integer.parseInt(it) } }, unread = message["unread"]?.toIntOrNull(),
clientSecret = message["cs"], clientSecret = message["cs"],
) )
return pushDataFirebase.toPushData() return pushDataFirebase.toPushData()

View file

@ -0,0 +1,58 @@
/*
* 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.providers.firebase
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.providers.api.Distributor
import io.element.android.libraries.push.providers.api.PushProvider
import io.element.android.libraries.push.providers.api.PusherSubscriber
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("FirebasePushProvider")
class FirebasePushProvider @Inject constructor(
private val firebaseStore: FirebaseStore,
private val firebaseTroubleshooter: FirebaseTroubleshooter,
private val pusherSubscriber: PusherSubscriber,
) : PushProvider {
override val index = FirebaseConfig.index
override val name = FirebaseConfig.name
override fun getDistributors(): List<Distributor> {
return listOf(Distributor("Firebase", "Firebase"))
}
override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) {
val pushKey = firebaseStore.getFcmToken() ?: return Unit.also {
Timber.tag(loggerTag.value).w("Unable to register pusher, Firebase token is not known.")
}
pusherSubscriber.registerPusher(matrixClient, pushKey, FirebaseConfig.pusher_http_url)
}
override suspend fun unregister(matrixClient: MatrixClient) {
val pushKey = firebaseStore.getFcmToken() ?: return Unit.also {
Timber.tag(loggerTag.value).w("Unable to unregister pusher, Firebase token is not known.")
}
pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.pusher_http_url)
}
override suspend fun troubleshoot(): Result<Unit> {
return firebaseTroubleshooter.troubleshoot()
}
}

View file

@ -0,0 +1,43 @@
/*
* 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.providers.firebase
import android.content.SharedPreferences
import androidx.core.content.edit
import io.element.android.libraries.di.DefaultPreferences
import javax.inject.Inject
/**
* This class store the Firebase token in SharedPrefs.
*/
class FirebaseStore @Inject constructor(
@DefaultPreferences private val sharedPrefs: SharedPreferences,
) {
fun getFcmToken(): String? {
return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null)
}
fun storeFcmToken(token: String?) {
sharedPrefs.edit {
putString(PREFS_KEY_FCM_TOKEN, token)
}
}
companion object {
private const val PREFS_KEY_FCM_TOKEN = "FCM_TOKEN"
}
}

View file

@ -0,0 +1,79 @@
/*
* 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.providers.firebase
import android.content.Context
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.firebase.messaging.FirebaseMessaging
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
/**
* This class force retrieving and storage of the Firebase token.
*/
class FirebaseTroubleshooter @Inject constructor(
@ApplicationContext private val context: Context,
private val newTokenHandler: FirebaseNewTokenHandler,
) {
suspend fun troubleshoot(): Result<Unit> {
return runCatching {
val token = retrievedFirebaseToken()
newTokenHandler.handle(token)
}
}
private suspend fun retrievedFirebaseToken(): String {
return suspendCoroutine { continuation ->
// '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 ->
continuation.resume(token)
}
.addOnFailureListener { e ->
Timber.e(e, "## retrievedFirebaseToken() : failed")
continuation.resumeWithException(e)
}
} catch (e: Throwable) {
Timber.e(e, "## retrievedFirebaseToken() : failed")
continuation.resumeWithException(e)
}
} else {
val e = Exception("No valid Google Play Services found. Cannot use FCM.")
Timber.e(e)
continuation.resumeWithException(e)
}
}
}
/**
* 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
}
}

View file

@ -14,12 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.firebase package io.element.android.libraries.push.providers.firebase
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.core.asRoomId import io.element.android.libraries.matrix.api.core.asRoomId
import io.element.android.libraries.push.impl.push.PushData import io.element.android.libraries.push.providers.api.PushData
/** /**
* In this case, the format is: * In this case, the format is:
@ -41,9 +40,13 @@ data class PushDataFirebase(
val clientSecret: String? val clientSecret: String?
) )
fun PushDataFirebase.toPushData() = PushData( fun PushDataFirebase.toPushData(): PushData? {
eventId = eventId?.takeIf { MatrixPatterns.isEventId(it) }?.asEventId(), val safeEventId = eventId?.asEventId() ?: return null
roomId = roomId?.takeIf { MatrixPatterns.isRoomId(it) }?.asRoomId(), val safeRoomId = roomId?.asRoomId() ?: return null
unread = unread, return PushData(
clientSecret = clientSecret, eventId = safeEventId,
) roomId = safeRoomId,
unread = unread,
clientSecret = clientSecret,
)
}

View file

@ -14,25 +14,23 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.firebase package io.element.android.libraries.push.providers.firebase
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.push.impl.PushersManager import io.element.android.libraries.push.providers.api.PushHandler
import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.push.PushHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("Firebase", pushLoggerTag) private val loggerTag = LoggerTag("Firebase")
class VectorFirebaseMessagingService : FirebaseMessagingService() { class VectorFirebaseMessagingService : FirebaseMessagingService() {
@Inject lateinit var pushersManager: PushersManager @Inject lateinit var firebaseNewTokenHandler: FirebaseNewTokenHandler
@Inject lateinit var pushParser: FirebasePushParser @Inject lateinit var pushParser: FirebasePushParser
@Inject lateinit var pushHandler: PushHandler @Inject lateinit var pushHandler: PushHandler
@ -46,15 +44,18 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
Timber.tag(loggerTag.value).d("New Firebase token") Timber.tag(loggerTag.value).d("New Firebase token")
coroutineScope.launch { coroutineScope.launch {
pushersManager.onNewFirebaseToken(token) firebaseNewTokenHandler.handle(token)
} }
} }
override fun onMessageReceived(message: RemoteMessage) { override fun onMessageReceived(message: RemoteMessage) {
Timber.tag(loggerTag.value).d("New Firebase message") Timber.tag(loggerTag.value).d("New Firebase message")
coroutineScope.launch { coroutineScope.launch {
pushParser.parse(message.data).let { val pushData = pushParser.parse(message.data)
pushHandler.handle(it) if (pushData == null) {
Timber.tag(loggerTag.value).w("Invalid data received from Firebase")
} else {
pushHandler.handle(pushData)
} }
} }
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.firebase package io.element.android.libraries.push.providers.firebase
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope

View file

@ -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.providers.firebase.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoSet
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.providers.api.PushProvider
import io.element.android.libraries.push.providers.firebase.FirebasePushProvider
@Module
@ContributesTo(AppScope::class)
interface FirebaseModule {
@Binds
@IntoSet
fun bind(pushProvider: FirebasePushProvider): PushProvider
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="default_web_client_id" translatable="false">912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com</string>
<string name="firebase_database_url" translatable="false">https://vector-alpha.firebaseio.com</string>
<string name="gcm_defaultSenderId" translatable="false">912726360885</string>
<string name="google_api_key" translatable="false">AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c</string>
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c</string>
<string name="google_storage_bucket" translatable="false">vector-alpha.appspot.com</string>
<string name="project_id" translatable="false">vector-alpha</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="google_app_id" translatable="false">1:912726360885:android:e17435e0beb0303000427c</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="google_app_id" translatable="false">1:912726360885:android:d097de99a4c23d2700427c</string>
</resources>

View file

@ -0,0 +1,90 @@
/*
* 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.providers.firebase
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.push.providers.api.PushData
import io.element.android.tests.testutils.assertNullOrThrow
import org.junit.Test
class FirebasePushParserTest {
private val validData = PushData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
unread = 1,
clientSecret = "a-secret"
)
@Test
fun `test edge cases Firebase`() {
val pushParser = FirebasePushParser()
// Empty Json
assertThat(pushParser.parse(emptyMap())).isNull()
// Bad Json
assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("unread", "str"))).isEqualTo(validData.copy(unread = null))
// Extra data
assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("extra", "5"))).isEqualTo(validData)
}
@Test
fun `test Firebase format`() {
val pushParser = FirebasePushParser()
assertThat(pushParser.parse(FIREBASE_PUSH_DATA)).isEqualTo(validData)
}
@Test
fun `test empty roomId`() {
val pushParser = FirebasePushParser()
assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("room_id", null))).isNull()
assertNullOrThrow { pushParser.parse(FIREBASE_PUSH_DATA.mutate("room_id", "")) }
}
@Test
fun `test invalid roomId`() {
val pushParser = FirebasePushParser()
assertNullOrThrow { pushParser.parse(FIREBASE_PUSH_DATA.mutate("room_id", "aRoomId:domain")) }
}
@Test
fun `test empty eventId`() {
val pushParser = FirebasePushParser()
assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", null))).isNull()
assertNullOrThrow { pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", "")) }
}
@Test
fun `test invalid eventId`() {
val pushParser = FirebasePushParser()
assertNullOrThrow { pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", "anEventId")) }
}
companion object {
private val FIREBASE_PUSH_DATA = mapOf(
"event_id" to AN_EVENT_ID.value,
"room_id" to A_ROOM_ID.value,
"unread" to "1",
"prio" to "high",
"cs" to "a-secret",
)
}
}
private fun Map<String, String?>.mutate(key: String, value: String?): Map<String, String?> {
return toMutableMap().apply { put(key, value) }
}

View file

@ -0,0 +1,57 @@
/*
* 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.providers.unifiedpush"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.pushstore.api)
implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.services.toolbox.api)
implementation(projects.libraries.network)
implementation(platform(libs.network.okhttp.bom))
implementation("com.squareup.okhttp3:okhttp")
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
// UnifiedPush library
api(libs.unifiedpush)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View file

@ -0,0 +1,47 @@
<?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"
xmlns:tools="http://schemas.android.com/tools">
<application>
<receiver
android:name=".VectorUnifiedPushMessagingReceiver"
android:enabled="true"
android:exported="true"
tools:ignore="ExportedReceiver">
<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>

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.unifiedpush package io.element.android.libraries.push.providers.unifiedpush
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.unifiedpush package io.element.android.libraries.push.providers.unifiedpush
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context

View file

@ -14,12 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.unifiedpush package io.element.android.libraries.push.providers.unifiedpush
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.core.asRoomId import io.element.android.libraries.matrix.api.core.asRoomId
import io.element.android.libraries.push.impl.push.PushData import io.element.android.libraries.push.providers.api.PushData
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -41,24 +40,28 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
data class PushDataUnifiedPush( data class PushDataUnifiedPush(
val notification: PushDataUnifiedPushNotification? val notification: PushDataUnifiedPushNotification? = null
) )
@Serializable @Serializable
data class PushDataUnifiedPushNotification( data class PushDataUnifiedPushNotification(
@SerialName("event_id") val eventId: String?, @SerialName("event_id") val eventId: String? = null,
@SerialName("room_id") val roomId: String?, @SerialName("room_id") val roomId: String? = null,
@SerialName("counts") var counts: PushDataUnifiedPushCounts?, @SerialName("counts") var counts: PushDataUnifiedPushCounts? = null,
) )
@Serializable @Serializable
data class PushDataUnifiedPushCounts( data class PushDataUnifiedPushCounts(
@SerialName("unread") val unread: Int? @SerialName("unread") val unread: Int? = null
) )
fun PushDataUnifiedPush.toPushData() = PushData( fun PushDataUnifiedPush.toPushData(clientSecret: String): PushData? {
eventId = notification?.eventId?.takeIf { MatrixPatterns.isEventId(it) }?.asEventId(), val safeEventId = notification?.eventId?.asEventId() ?: return null
roomId = notification?.roomId?.takeIf { MatrixPatterns.isRoomId(it) }?.asRoomId(), val safeRoomId = notification.roomId?.asRoomId() ?: return null
unread = notification?.counts?.unread, return PushData(
clientSecret = null // TODO EAx check how client secret will be sent through UnifiedPush eventId = safeEventId,
) roomId = safeRoomId,
unread = notification.counts?.unread,
clientSecret = clientSecret
)
}

View file

@ -14,55 +14,60 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.unifiedpush package io.element.android.libraries.push.providers.unifiedpush
import android.content.Context import android.content.Context
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.impl.config.PushConfig import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.providers.api.Distributor
import io.element.android.libraries.push.providers.api.PusherSubscriber
import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.UnifiedPush
import javax.inject.Inject import javax.inject.Inject
class RegisterUnifiedPushUseCase @Inject constructor( class RegisterUnifiedPushUseCase @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val pusherSubscriber: PusherSubscriber,
private val unifiedPushStore: UnifiedPushStore,
) { ) {
sealed interface RegisterUnifiedPushResult { sealed interface RegisterUnifiedPushResult {
object Success : RegisterUnifiedPushResult object Success : RegisterUnifiedPushResult
object NeedToAskUserForDistributor : RegisterUnifiedPushResult object NeedToAskUserForDistributor : RegisterUnifiedPushResult
object Error : RegisterUnifiedPushResult
} }
fun execute(distributor: String = ""): RegisterUnifiedPushResult { suspend fun execute(matrixClient: MatrixClient, distributor: Distributor, clientSecret: String): RegisterUnifiedPushResult {
if (distributor.isNotEmpty()) { val distributorValue = distributor.value
saveAndRegisterApp(distributor) if (distributorValue.isNotEmpty()) {
return RegisterUnifiedPushResult.Success saveAndRegisterApp(distributorValue, clientSecret)
} val endpoint = unifiedPushStore.getEndpoint(clientSecret) ?: return RegisterUnifiedPushResult.Error
val gateway = unifiedPushStore.getPushGateway(clientSecret) ?: return RegisterUnifiedPushResult.Error
if (!PushConfig.allowExternalUnifiedPushDistributors) { pusherSubscriber.registerPusher(matrixClient, endpoint, gateway)
saveAndRegisterApp(context.packageName)
return RegisterUnifiedPushResult.Success return RegisterUnifiedPushResult.Success
} }
// TODO Below should never happen?
if (UnifiedPush.getDistributor(context).isNotEmpty()) { if (UnifiedPush.getDistributor(context).isNotEmpty()) {
registerApp() registerApp(clientSecret)
return RegisterUnifiedPushResult.Success return RegisterUnifiedPushResult.Success
} }
val distributors = UnifiedPush.getDistributors(context) val distributors = UnifiedPush.getDistributors(context)
return if (distributors.size == 1) { return if (distributors.size == 1) {
saveAndRegisterApp(distributors.first()) saveAndRegisterApp(distributors.first(), clientSecret)
RegisterUnifiedPushResult.Success RegisterUnifiedPushResult.Success
} else { } else {
RegisterUnifiedPushResult.NeedToAskUserForDistributor RegisterUnifiedPushResult.NeedToAskUserForDistributor
} }
} }
private fun saveAndRegisterApp(distributor: String) { private fun saveAndRegisterApp(distributor: String, clientSecret: String) {
UnifiedPush.saveDistributor(context, distributor) UnifiedPush.saveDistributor(context, distributor)
registerApp() registerApp(clientSecret)
} }
private fun registerApp() { private fun registerApp(clientSecret: String) {
UnifiedPush.registerApp(context) UnifiedPush.registerApp(context = context, instance = clientSecret)
} }
} }

View 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.
*/
package io.element.android.libraries.push.providers.unifiedpush
object UnifiedPushConfig {
/**
* 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"
const val index = 1
const val name = "UnifiedPush"
}

View file

@ -0,0 +1,56 @@
/*
* 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.providers.unifiedpush
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.network.RetrofitFactory
import io.element.android.libraries.push.providers.unifiedpush.network.UnifiedPushApi
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.net.URL
import javax.inject.Inject
class UnifiedPushGatewayResolver @Inject constructor(
private val retrofitFactory: RetrofitFactory,
private val coroutineDispatchers: CoroutineDispatchers,
) {
suspend fun getGateway(endpoint: String): String? {
val gateway = UnifiedPushConfig.default_push_gateway_http_url
val url = URL(endpoint)
val custom = "${url.protocol}://${url.host}/_matrix/push/v1/notify"
Timber.i("Testing $custom")
try {
return withContext(coroutineDispatchers.io) {
val api = retrofitFactory.create("${url.protocol}://${url.host}")
.create(UnifiedPushApi::class.java)
try {
val discoveryResponse = api.discover()
if (discoveryResponse.unifiedpush.gateway == "matrix") {
Timber.d("Using custom gateway")
return@withContext custom
}
} catch (throwable: Throwable) {
Timber.tag("UnifiedPushHelper").e(throwable)
}
return@withContext gateway
}
} catch (e: Throwable) {
Timber.d(e, "Cannot try custom gateway")
}
return gateway
}
}

View file

@ -0,0 +1,52 @@
/*
* 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.providers.unifiedpush
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.push.providers.api.PusherSubscriber
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("UnifiedPushNewGatewayHandler")
/**
* Handle new endpoint received from UnifiedPush. Will update all the sessions which are using UnifiedPush as a push provider.
*/
class UnifiedPushNewGatewayHandler @Inject constructor(
private val pusherSubscriber: PusherSubscriber,
private val userPushStoreFactory: UserPushStoreFactory,
private val pushClientSecret: PushClientSecret,
private val matrixAuthenticationService: MatrixAuthenticationService,
) {
suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String) {
// Register the pusher for the session with this client secret, if is it using UnifiedPush.
val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Unit.also {
Timber.w("Unable to retrieve session")
}
val userDataStore = userPushStoreFactory.create(userId)
if (userDataStore.getPushProviderName() == UnifiedPushConfig.name) {
matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client ->
pusherSubscriber.registerPusher(client, endpoint, pushGateway)
}
} else {
Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher")
}
}
}

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,16 +14,18 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.unifiedpush package io.element.android.libraries.push.providers.unifiedpush
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.push.impl.push.PushData import io.element.android.libraries.push.providers.api.PushData
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import javax.inject.Inject import javax.inject.Inject
class UnifiedPushParser @Inject constructor() { class UnifiedPushParser @Inject constructor() {
fun parse(message: ByteArray): PushData? { private val json by lazy { Json { ignoreUnknownKeys = true } }
return tryOrNull { Json.decodeFromString<PushDataUnifiedPush>(String(message)) }?.toPushData()
fun parse(message: ByteArray, clientSecret: String): PushData? {
return tryOrNull { json.decodeFromString<PushDataUnifiedPush>(String(message)) }?.toPushData(clientSecret)
} }
} }

View file

@ -0,0 +1,63 @@
/*
* 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.providers.unifiedpush
import android.content.Context
import io.element.android.libraries.androidutils.system.getApplicationLabel
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.providers.api.Distributor
import io.element.android.libraries.push.providers.api.PushProvider
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import org.unifiedpush.android.connector.UnifiedPush
import javax.inject.Inject
class UnifiedPushProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase,
private val unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase,
private val pushClientSecret: PushClientSecret,
) : PushProvider {
override val index = UnifiedPushConfig.index
override val name = UnifiedPushConfig.name
override fun getDistributors(): List<Distributor> {
val distributors = UnifiedPush.getDistributors(context)
return distributors.mapNotNull {
if (it == context.packageName) {
// Exclude self
null
} else {
Distributor(it, context.getApplicationLabel(it))
}
}
}
override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) {
val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId)
registerUnifiedPushUseCase.execute(matrixClient, distributor, clientSecret)
}
override suspend fun unregister(matrixClient: MatrixClient) {
val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId)
unRegisterUnifiedPushUseCase.execute(clientSecret)
}
override suspend fun troubleshoot(): Result<Unit> {
TODO("Not yet implemented")
}
}

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl package io.element.android.libraries.push.providers.unifiedpush
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
@ -23,9 +23,6 @@ import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.DefaultPreferences import io.element.android.libraries.di.DefaultPreferences
import javax.inject.Inject import javax.inject.Inject
/**
* TODO EAx Store in BDD (for multisession).
*/
class UnifiedPushStore @Inject constructor( class UnifiedPushStore @Inject constructor(
@ApplicationContext val context: Context, @ApplicationContext val context: Context,
@DefaultPreferences private val defaultPrefs: SharedPreferences, @DefaultPreferences private val defaultPrefs: SharedPreferences,
@ -33,40 +30,44 @@ class UnifiedPushStore @Inject constructor(
/** /**
* Retrieves the UnifiedPush Endpoint. * Retrieves the UnifiedPush Endpoint.
* *
* @param clientSecret the client secret, to identify the session
* @return the UnifiedPush Endpoint or null if not received * @return the UnifiedPush Endpoint or null if not received
*/ */
fun getEndpoint(): String? { fun getEndpoint(clientSecret: String): String? {
return defaultPrefs.getString(PREFS_ENDPOINT_OR_TOKEN, null) return defaultPrefs.getString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, null)
} }
/** /**
* Store UnifiedPush Endpoint to the SharedPrefs. * Store UnifiedPush Endpoint to the SharedPrefs.
* *
* @param endpoint the endpoint to store * @param endpoint the endpoint to store
* @param clientSecret the client secret, to identify the session
*/ */
fun storeUpEndpoint(endpoint: String?) { fun storeUpEndpoint(endpoint: String?, clientSecret: String) {
defaultPrefs.edit { defaultPrefs.edit {
putString(PREFS_ENDPOINT_OR_TOKEN, endpoint) putString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, endpoint)
} }
} }
/** /**
* Retrieves the Push Gateway. * Retrieves the Push Gateway.
* *
* @param clientSecret the client secret, to identify the session
* @return the Push Gateway or null if not defined * @return the Push Gateway or null if not defined
*/ */
fun getPushGateway(): String? { fun getPushGateway(clientSecret: String): String? {
return defaultPrefs.getString(PREFS_PUSH_GATEWAY, null) return defaultPrefs.getString(PREFS_PUSH_GATEWAY + clientSecret, null)
} }
/** /**
* Store Push Gateway to the SharedPrefs. * Store Push Gateway to the SharedPrefs.
* *
* @param gateway the push gateway to store * @param gateway the push gateway to store
* @param clientSecret the client secret, to identify the session
*/ */
fun storePushGateway(gateway: String?) { fun storePushGateway(gateway: String?, clientSecret: String) {
defaultPrefs.edit { defaultPrefs.edit {
putString(PREFS_PUSH_GATEWAY, gateway) putString(PREFS_PUSH_GATEWAY + clientSecret, gateway)
} }
} }

View file

@ -14,39 +14,34 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.unifiedpush package io.element.android.libraries.push.providers.unifiedpush
import android.content.Context import android.content.Context
import io.element.android.libraries.di.ApplicationContext 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 io.element.android.libraries.push.impl.PushersManager
import io.element.android.libraries.push.impl.UnifiedPushHelper
import io.element.android.libraries.push.impl.UnifiedPushStore
import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.UnifiedPush
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class UnregisterUnifiedPushUseCase @Inject constructor( class UnregisterUnifiedPushUseCase @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val pushDataStore: PushDataStore, //private val pushDataStore: PushDataStore,
private val unifiedPushStore: UnifiedPushStore, private val unifiedPushStore: UnifiedPushStore,
private val unifiedPushHelper: UnifiedPushHelper, private val unifiedPushGatewayResolver: UnifiedPushGatewayResolver,
) { ) {
suspend fun execute(pushersManager: PushersManager?) { suspend fun execute(clientSecret: String /*pushersManager: PushersManager?*/) {
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME //val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
pushDataStore.setFdroidSyncBackgroundMode(mode) //pushDataStore.setFdroidSyncBackgroundMode(mode)
try { try {
unifiedPushHelper.getEndpointOrToken()?.let { unifiedPushStore.getEndpoint(clientSecret)?.let {
Timber.d("Removing $it") Timber.d("Removing $it")
pushersManager?.unregisterPusher(it) // TODO pushersManager?.unregisterPusher(it)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.d(e, "Probably unregistering a non existing pusher") Timber.d(e, "Probably unregistering a non existing pusher")
} }
unifiedPushStore.storeUpEndpoint(null) unifiedPushStore.storeUpEndpoint(null, clientSecret)
unifiedPushStore.storePushGateway(null) unifiedPushStore.storePushGateway(null, clientSecret)
UnifiedPush.unregisterApp(context) UnifiedPush.unregisterApp(context)
} }
} }

View file

@ -14,51 +14,39 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.unifiedpush package io.element.android.libraries.push.providers.unifiedpush
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.widget.Toast
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.push.api.model.BackgroundSyncMode import io.element.android.libraries.push.providers.api.PushHandler
import io.element.android.libraries.push.api.store.PushDataStore
import io.element.android.libraries.push.impl.PushersManager
import io.element.android.libraries.push.impl.UnifiedPushHelper
import io.element.android.libraries.push.impl.UnifiedPushStore
import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.push.PushHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.unifiedpush.android.connector.MessagingReceiver import org.unifiedpush.android.connector.MessagingReceiver
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("Unified", pushLoggerTag) private val loggerTag = LoggerTag("VectorUnifiedPushMessagingReceiver")
class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
@Inject lateinit var pushersManager: PushersManager
@Inject lateinit var pushParser: UnifiedPushParser @Inject lateinit var pushParser: UnifiedPushParser
//@Inject lateinit var activeSessionHolder: ActiveSessionHolder
@Inject lateinit var pushDataStore: PushDataStore
@Inject lateinit var pushHandler: PushHandler @Inject lateinit var pushHandler: PushHandler
@Inject lateinit var guardServiceStarter: GuardServiceStarter @Inject lateinit var guardServiceStarter: GuardServiceStarter
@Inject lateinit var unifiedPushStore: UnifiedPushStore @Inject lateinit var unifiedPushStore: UnifiedPushStore
@Inject lateinit var unifiedPushHelper: UnifiedPushHelper @Inject lateinit var unifiedPushGatewayResolver: UnifiedPushGatewayResolver
@Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler
private val coroutineScope = CoroutineScope(SupervisorJob()) private val coroutineScope = CoroutineScope(SupervisorJob())
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
// Inject
context.applicationContext.bindings<VectorUnifiedPushMessagingReceiverBindings>().inject(this) context.applicationContext.bindings<VectorUnifiedPushMessagingReceiverBindings>().inject(this)
super.onReceive(context, intent)
} }
/** /**
* Called when message is received. * Called when message is received. The message contains the full POST body of the push message.
* *
* @param context the Android context * @param context the Android context
* @param message the message * @param message the message
@ -67,48 +55,58 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
override fun onMessage(context: Context, message: ByteArray, instance: String) { override fun onMessage(context: Context, message: ByteArray, instance: String) {
Timber.tag(loggerTag.value).d("New message") Timber.tag(loggerTag.value).d("New message")
coroutineScope.launch { coroutineScope.launch {
pushParser.parse(message)?.let { val pushData = pushParser.parse(message, instance)
pushHandler.handle(it) if (pushData == null) {
} ?: run { Timber.tag(loggerTag.value).w("Invalid data received from UnifiedPush")
Timber.tag(loggerTag.value).w("Invalid received data Json format") } else {
pushHandler.handle(pushData)
} }
} }
} }
/**
* Called when a new endpoint is to be used for sending push messages.
* You should send the endpoint to your application server and sync for missing notifications.
*/
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint") Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint")
if (pushDataStore.areNotificationEnabledForDevice() /* TODO EAx && activeSessionHolder.hasActiveSession() */) { // If the endpoint has changed
// If the endpoint has changed // or the gateway has changed
// or the gateway has changed if (unifiedPushStore.getEndpoint(instance) != endpoint) {
if (unifiedPushHelper.getEndpointOrToken() != endpoint) { unifiedPushStore.storeUpEndpoint(endpoint, instance)
unifiedPushStore.storeUpEndpoint(endpoint) coroutineScope.launch {
coroutineScope.launch { val gateway = unifiedPushGatewayResolver.getGateway(endpoint)
unifiedPushHelper.storeCustomOrDefaultGateway(endpoint) { unifiedPushStore.storePushGateway(gateway, instance)
unifiedPushHelper.getPushGateway()?.let { gateway?.let { pushGateway ->
coroutineScope.launch { newGatewayHandler.handle(endpoint, pushGateway, instance)
pushersManager.onNewUnifiedPushEndpoint(endpoint, it)
}
}
}
} }
} else {
Timber.tag(loggerTag.value).i("onNewEndpoint: skipped")
} }
} else {
Timber.tag(loggerTag.value).i("onNewEndpoint: skipped")
} }
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED
pushDataStore.setFdroidSyncBackgroundMode(mode)
guardServiceStarter.stop() guardServiceStarter.stop()
} }
/**
* Called when the registration is not possible, eg. no network.
*/
override fun onRegistrationFailed(context: Context, instance: String) { override fun onRegistrationFailed(context: Context, instance: String) {
Timber.tag(loggerTag.value).e("onRegistrationFailed for $instance")
/*
Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show()
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
pushDataStore.setFdroidSyncBackgroundMode(mode) pushDataStore.setFdroidSyncBackgroundMode(mode)
guardServiceStarter.start() guardServiceStarter.start()
*/
} }
/**
* Called when this application is unregistered from receiving push messages.
*/
override fun onUnregistered(context: Context, instance: String) { override fun onUnregistered(context: Context, instance: String) {
Timber.tag(loggerTag.value).d("Unifiedpush: Unregistered") Timber.tag(loggerTag.value).d("Unifiedpush: Unregistered")
TODO()
/*
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
pushDataStore.setFdroidSyncBackgroundMode(mode) pushDataStore.setFdroidSyncBackgroundMode(mode)
guardServiceStarter.start() guardServiceStarter.start()
@ -119,5 +117,6 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
Timber.tag(loggerTag.value).d("Probably unregistering a non existing pusher") Timber.tag(loggerTag.value).d("Probably unregistering a non existing pusher")
} }
} }
*/
} }
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.unifiedpush package io.element.android.libraries.push.providers.unifiedpush
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope

View file

@ -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.providers.unifiedpush.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoSet
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.providers.api.PushProvider
import io.element.android.libraries.push.providers.unifiedpush.UnifiedPushProvider
@Module
@ContributesTo(AppScope::class)
interface UnifiedPushModule {
@Binds
@IntoSet
fun bind(pushProvider: UnifiedPushProvider): PushProvider
}

View file

@ -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.providers.unifiedpush.network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class DiscoveryResponse(
@SerialName("unifiedpush") val unifiedpush: DiscoveryUnifiedPush = DiscoveryUnifiedPush()
)

View file

@ -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.providers.unifiedpush.network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class DiscoveryUnifiedPush(
@SerialName("gateway") val gateway: String = ""
)

View file

@ -0,0 +1,24 @@
/*
* 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.providers.unifiedpush.network
import retrofit2.http.GET
interface UnifiedPushApi {
@GET("_matrix/push/v1/notify")
suspend fun discover(): DiscoveryResponse
}

View file

@ -0,0 +1,93 @@
/*
* 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.providers.unifiedpush
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.push.providers.api.PushData
import io.element.android.tests.testutils.assertNullOrThrow
import org.junit.Test
class UnifiedPushParserTest {
private val aClientSecret = "a-client-secret"
private val validData = PushData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
unread = 1,
clientSecret = aClientSecret
)
@Test
fun `test edge cases UnifiedPush`() {
val pushParser = UnifiedPushParser()
// Empty string
assertThat(pushParser.parse("".toByteArray(), aClientSecret)).isNull()
// Empty Json
assertThat(pushParser.parse("{}".toByteArray(), aClientSecret)).isNull()
// Bad Json
assertThat(pushParser.parse("ABC".toByteArray(), aClientSecret)).isNull()
}
@Test
fun `test UnifiedPush format`() {
val pushParser = UnifiedPushParser()
assertThat(pushParser.parse(UNIFIED_PUSH_DATA.toByteArray(), aClientSecret)).isEqualTo(validData)
}
@Test
fun `test empty roomId`() {
val pushParser = UnifiedPushParser()
assertNullOrThrow {
pushParser.parse(UNIFIED_PUSH_DATA.replace(A_ROOM_ID.value, "").toByteArray(), aClientSecret)
}
}
@Test
fun `test invalid roomId`() {
val pushParser = UnifiedPushParser()
assertNullOrThrow {
pushParser.parse(UNIFIED_PUSH_DATA.mutate(A_ROOM_ID.value, "aRoomId:domain"), aClientSecret)
}
}
@Test
fun `test empty eventId`() {
val pushParser = UnifiedPushParser()
assertNullOrThrow {
pushParser.parse(UNIFIED_PUSH_DATA.mutate(AN_EVENT_ID.value, ""), aClientSecret)
}
}
@Test
fun `test invalid eventId`() {
val pushParser = UnifiedPushParser()
assertNullOrThrow {
pushParser.parse(UNIFIED_PUSH_DATA.mutate(AN_EVENT_ID.value, "anEventId"), aClientSecret)
}
}
companion object {
private val UNIFIED_PUSH_DATA =
"{\"notification\":{\"event_id\":\"${AN_EVENT_ID.value}\",\"room_id\":\"${A_ROOM_ID.value}\",\"counts\":{\"unread\":1},\"prio\":\"high\"}}"
// TODO Check client secret format?
}
}
private fun String.mutate(oldValue: String, newValue: String): ByteArray {
return replace(oldValue, newValue).toByteArray()
}

View file

@ -0,0 +1,27 @@
/*
* 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.pushstore.api"
}
dependencies {
implementation(projects.libraries.matrix.api)
}

View file

@ -14,27 +14,24 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.userpushstore package io.element.android.libraries.pushstore.api
const val NOTIFICATION_METHOD_FIREBASE = "NOTIFICATION_METHOD_FIREBASE"
const val NOTIFICATION_METHOD_UNIFIEDPUSH = "NOTIFICATION_METHOD_UNIFIEDPUSH"
/** /**
* Store data related to push about a user. * Store data related to push about a user.
*/ */
interface UserPushStore { interface UserPushStore {
/** suspend fun getPushProviderName(): String?
* [NOTIFICATION_METHOD_FIREBASE] or [NOTIFICATION_METHOD_UNIFIEDPUSH]. suspend fun setPushProviderName(value: String)
*/
suspend fun getNotificationMethod(): String
suspend fun setNotificationMethod(value: String)
suspend fun getCurrentRegisteredPushKey(): String? suspend fun getCurrentRegisteredPushKey(): String?
suspend fun setCurrentRegisteredPushKey(value: String) suspend fun setCurrentRegisteredPushKey(value: String)
suspend fun areNotificationEnabledForDevice(): Boolean
suspend fun setNotificationEnabledForDevice(enabled: Boolean)
/**
* Return true if Pin code is disabled, or if user set the settings to see full notification content.
*/
fun useCompleteNotificationFormat(): Boolean
suspend fun reset() suspend fun reset()
} }
suspend fun UserPushStore.isFirebase(): Boolean = getNotificationMethod() == NOTIFICATION_METHOD_FIREBASE

View file

@ -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.pushstore.api
import io.element.android.libraries.matrix.api.core.SessionId
/**
* Store data related to push about a user.
*/
interface UserPushStoreFactory {
fun create(userId: SessionId): UserPushStore
}

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.clientsecret package io.element.android.libraries.pushstore.api.clientsecret
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.clientsecret package io.element.android.libraries.pushstore.api.clientsecret
interface PushClientSecretFactory { interface PushClientSecretFactory {
fun create(): String fun create(): String

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.clientsecret package io.element.android.libraries.pushstore.api.clientsecret
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId

View file

@ -0,0 +1,47 @@
/*
* 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)
}
android {
namespace = "io.element.android.libraries.push.pushstore.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.pushstore.api)
implementation(projects.libraries.sessionStorage.api)
implementation(libs.androidx.corektx)
implementation(libs.androidx.datastore.preferences)
testImplementation(libs.test.junit)
testImplementation(libs.test.mockk)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.appnavstate.test)
}

View file

@ -14,28 +14,34 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.userpushstore package io.element.android.libraries.pushstore.impl
import android.content.Context import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
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.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.asSessionId
import io.element.android.libraries.pushstore.api.UserPushStore
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import javax.inject.Inject import javax.inject.Inject
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class UserPushStoreFactory @Inject constructor( @ContributesBinding(AppScope::class, boundType = UserPushStoreFactory::class)
class DefaultUserPushStoreFactory @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val sessionObserver: SessionObserver, private val sessionObserver: SessionObserver,
) : SessionListener { ) : UserPushStoreFactory, SessionListener {
init { init {
observeSessions() observeSessions()
} }
// We can have only one class accessing a single data store, so keep a cache of them. // We can have only one class accessing a single data store, so keep a cache of them.
private val cache = mutableMapOf<String, UserPushStore>() private val cache = mutableMapOf<SessionId, UserPushStore>()
fun create(userId: String): UserPushStore { override fun create(userId: SessionId): UserPushStore {
return cache.getOrPut(userId) { return cache.getOrPut(userId) {
UserPushStoreDataStore( UserPushStoreDataStore(
context = context, context = context,
@ -54,6 +60,6 @@ class UserPushStoreFactory @Inject constructor(
override suspend fun onSessionDeleted(userId: String) { override suspend fun onSessionDeleted(userId: String) {
// Delete the store // Delete the store
create(userId).reset() userId.asSessionId()?.let { create(it).reset() }
} }
} }

View file

@ -14,14 +14,18 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.userpushstore package io.element.android.libraries.pushstore.impl
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.UserPushStore
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
/** /**
@ -29,19 +33,20 @@ import kotlinx.coroutines.flow.first
*/ */
class UserPushStoreDataStore( class UserPushStoreDataStore(
private val context: Context, private val context: Context,
userId: String, userId: SessionId,
) : UserPushStore { ) : UserPushStore {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "push_store_$userId") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "push_store_$userId")
private val notificationMethod = stringPreferencesKey("notificationMethod") private val pushProviderName = stringPreferencesKey("pushProviderName")
private val currentPushKey = stringPreferencesKey("currentPushKey") private val currentPushKey = stringPreferencesKey("currentPushKey")
private val notificationEnabled = booleanPreferencesKey("notificationEnabled")
override suspend fun getNotificationMethod(): String { override suspend fun getPushProviderName(): String? {
return context.dataStore.data.first()[notificationMethod] ?: NOTIFICATION_METHOD_FIREBASE return context.dataStore.data.first()[pushProviderName]
} }
override suspend fun setNotificationMethod(value: String) { override suspend fun setPushProviderName(value: String) {
context.dataStore.edit { context.dataStore.edit {
it[notificationMethod] = value it[pushProviderName] = value
} }
} }
@ -55,6 +60,20 @@ class UserPushStoreDataStore(
} }
} }
override suspend fun areNotificationEnabledForDevice(): Boolean {
return context.dataStore.data.first()[notificationEnabled].orTrue()
}
override suspend fun setNotificationEnabledForDevice(enabled: Boolean) {
context.dataStore.edit {
it[notificationEnabled] = enabled
}
}
override fun useCompleteNotificationFormat(): Boolean {
return true
}
override suspend fun reset() { override suspend fun reset() {
context.dataStore.edit { context.dataStore.edit {
it.clear() it.clear()

View file

@ -14,10 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.clientsecret package io.element.android.libraries.pushstore.impl.clientsecret
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretFactory
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject

View file

@ -14,11 +14,14 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.push.impl.clientsecret package io.element.android.libraries.pushstore.impl.clientsecret
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)

Some files were not shown because too many files have changed in this diff Show more