From fa8786190f846fcad2601e0580f54c73170f949d Mon Sep 17 00:00:00 2001
From: Artur Skubei <41114720+gdlbo@users.noreply.github.com>
Date: Mon, 13 Apr 2026 00:08:11 +0300
Subject: [PATCH 1/3] implement UnifiedPush support and enhanced push
diagnostics
- add `UnifiedPushManager` and `UnifiedPushService` to support decentralized push notifications via the UnifiedPush connector
- introduce `PushProvider.UNIFIED_PUSH` and update `TdNotificationManager` to handle `DeviceTokenSimplePush` registration
- implement `PushDebugRepository` and `PushSyncTrigger` to facilitate push diagnostic monitoring and manual sync testing
- update Debug settings UI with a comprehensive "Push Diagnostics" section covering runtime flags, environment status, and UnifiedPush details
- enhance `TdNotificationService` logic to correctly manage foreground service lifecycle based on the active push provider
- update `App` startup logic and `DistrManager` to automatically detect available push distributors and handle provider fallbacks
- add necessary Android manifest queries and service declarations for UnifiedPush integration
- refine notification settings UI to allow selection between FCM, UnifiedPush, and GMS-less providers based on availability
---
app/build.gradle.kts | 11 +
app/src/main/AndroidManifest.xml | 6 +
app/src/main/java/org/monogram/app/App.kt | 13 +-
.../java/org/monogram/app/MainActivity.kt | 2 +-
.../org/monogram/app/di/DistrManagerImpl.kt | 5 +
data/build.gradle.kts | 1 +
data/src/main/AndroidManifest.xml | 8 +
.../monogram/data/di/TdNotificationManager.kt | 32 ++-
.../java/org/monogram/data/di/dataModule.kt | 21 +-
.../org/monogram/data/push/PushSyncTrigger.kt | 33 +++
.../monogram/data/push/UnifiedPushManager.kt | 156 +++++++++++
.../repository/PushDebugRepositoryImpl.kt | 139 ++++++++++
.../data/service/TdNotificationService.kt | 19 +-
.../data/service/UnifiedPushService.kt | 51 ++++
.../monogram/domain/managers/DistrManager.kt | 1 +
.../repository/AppPreferencesProvider.kt | 2 +-
.../domain/repository/PushDebugRepository.kt | 31 +++
gradle/libs.versions.toml | 2 +
presentation/build.gradle.kts | 1 +
.../presentation/core/util/AppPreferences.kt | 27 +-
.../core/util/ContextExtensions.kt | 11 +
.../monogram/presentation/di/AppContainer.kt | 53 +++-
.../presentation/di/KoinAppContainer.kt | 53 +++-
.../settings/debug/DebugComponent.kt | 27 +-
.../settings/debug/DebugContent.kt | 249 +++++++++++++++++-
.../settings/debug/DefaultDebugComponent.kt | 44 ++++
.../notifications/NotificationsComponent.kt | 21 +-
.../notifications/NotificationsContent.kt | 88 ++++++-
presentation/src/main/res/values/string.xml | 7 +
29 files changed, 1074 insertions(+), 40 deletions(-)
create mode 100644 data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt
create mode 100644 data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt
create mode 100644 data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt
create mode 100644 data/src/main/java/org/monogram/data/service/UnifiedPushService.kt
create mode 100644 domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt
create mode 100644 presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ff16f497..b319a197 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -110,6 +110,16 @@ androidComponents {
}
}
+configurations.configureEach {
+ val tink = "com.google.crypto.tink:tink-android:1.21.0"
+ resolutionStrategy {
+ force(tink)
+ dependencySubstitution {
+ substitute(module("com.google.crypto.tink:tink")).using(module(tink))
+ }
+ }
+}
+
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.bundles.androidx.compose)
@@ -126,6 +136,7 @@ dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
+ implementation(libs.unifiedpush.connector)
implementation(libs.maplibre.compose)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d799dff8..f7cf541c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -23,6 +23,12 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
+
+
+
+
+
+
()
val isGmsAvailable = distrManager.isGmsAvailable()
val isFcmAvailable = distrManager.isFcmAvailable()
+ val isUnifiedPushAvailable = distrManager.isUnifiedPushDistributorAvailable()
val prefs = get()
- if (!(isGmsAvailable && isFcmAvailable) && prefs.pushProvider.value == PushProvider.FCM) {
- prefs.setPushProvider(PushProvider.GMS_LESS)
+ val currentProvider = prefs.pushProvider.value
+ if (currentProvider == PushProvider.FCM && !(isGmsAvailable && isFcmAvailable)) {
+ val fallback =
+ if (isUnifiedPushAvailable) PushProvider.UNIFIED_PUSH else PushProvider.GMS_LESS
+ prefs.setPushProvider(fallback)
+ } else if (currentProvider == PushProvider.UNIFIED_PUSH && !isUnifiedPushAvailable) {
+ val fallback =
+ if (isGmsAvailable && isFcmAvailable) PushProvider.FCM else PushProvider.GMS_LESS
+ prefs.setPushProvider(fallback)
}
}
diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt
index c051f5d3..163f28ef 100644
--- a/app/src/main/java/org/monogram/app/MainActivity.kt
+++ b/app/src/main/java/org/monogram/app/MainActivity.kt
@@ -107,7 +107,7 @@ class MainActivity : FragmentActivity() {
}
private fun startNotificationService() {
- if (appPreferences.pushProvider.value == PushProvider.FCM) return
+ if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) return
val intent = Intent(this, TdNotificationService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
diff --git a/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt b/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt
index 3006cb30..0ea81ceb 100644
--- a/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt
+++ b/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt
@@ -6,6 +6,7 @@ import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.firebase.FirebaseApp
import org.monogram.domain.managers.DistrManager
+import org.unifiedpush.android.connector.UnifiedPush
class DistrManagerImpl(private val context: Context) : DistrManager {
override fun isGmsAvailable(): Boolean {
@@ -16,6 +17,10 @@ class DistrManagerImpl(private val context: Context) : DistrManager {
return FirebaseApp.getApps(context).isNotEmpty()
}
+ override fun isUnifiedPushDistributorAvailable(): Boolean {
+ return UnifiedPush.getDistributors(context).isNotEmpty()
+ }
+
override fun isInstalledFromGooglePlay(): Boolean {
val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index 96b96568..c11bab91 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -70,6 +70,7 @@ dependencies {
implementation(libs.androidx.media3.datasource)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
+ implementation(libs.unifiedpush.connector)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml
index 0be57ce1..8bf7ac1e 100644
--- a/data/src/main/AndroidManifest.xml
+++ b/data/src/main/AndroidManifest.xml
@@ -20,6 +20,14 @@
android:exported="false" />
+
+
+
+
+
+
diff --git a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt
index dad7bc6a..a887605f 100644
--- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt
+++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt
@@ -44,6 +44,7 @@ import org.monogram.data.db.dao.NotificationSettingDao
import org.monogram.data.db.model.NotificationSettingEntity
import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.infra.FileDownloadQueue
+import org.monogram.data.push.UnifiedPushManager
import org.monogram.data.service.NotificationDismissReceiver
import org.monogram.data.service.NotificationReadReceiver
import org.monogram.data.service.NotificationReplyReceiver
@@ -62,7 +63,8 @@ class TdNotificationManager(
private val notificationSettingsRepository: NotificationSettingsRepository,
private val notificationSettingDao: NotificationSettingDao,
private val fileQueue: FileDownloadQueue,
- private val stringProvider: StringProvider
+ private val stringProvider: StringProvider,
+ private val unifiedPushManager: UnifiedPushManager
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val notificationManager = NotificationManagerCompat.from(context)
@@ -194,6 +196,14 @@ class TdNotificationManager(
updatePushRegistration()
}
}
+
+ scope.launch {
+ unifiedPushManager.endpoint.collect {
+ if (appPreferences.pushProvider.value == PushProvider.UNIFIED_PUSH && !it.isNullOrBlank()) {
+ updatePushRegistration()
+ }
+ }
+ }
}
private fun updateChatNotificationSettings(chatId: Long, settings: TdApi.ChatNotificationSettings) {
@@ -214,6 +224,7 @@ class TdNotificationManager(
when (appPreferences.pushProvider.value) {
PushProvider.FCM -> {
coRunCatching {
+ unifiedPushManager.unregister()
val token = FirebaseMessaging.getInstance().token.await()
gateway.execute(
TdApi.RegisterDevice(
@@ -224,8 +235,27 @@ class TdNotificationManager(
}.onFailure { Log.e(TAG, "FCM token registration failed", it) }
}
+ PushProvider.UNIFIED_PUSH -> {
+ coRunCatching {
+ unifiedPushManager.ensureRegistered()
+ val endpoint = unifiedPushManager.endpoint.value
+ if (endpoint.isNullOrBlank()) {
+ Log.w(TAG, "UnifiedPush endpoint is not available yet")
+ return@coRunCatching
+ }
+
+ gateway.execute(
+ TdApi.RegisterDevice(
+ TdApi.DeviceTokenSimplePush(endpoint),
+ longArrayOf()
+ )
+ )
+ }.onFailure { Log.e(TAG, "UnifiedPush registration failed", it) }
+ }
+
PushProvider.GMS_LESS -> {
coRunCatching {
+ unifiedPushManager.unregister()
gateway.execute(
TdApi.RegisterDevice(
TdApi.DeviceTokenFirebaseCloudMessaging("", false),
diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt
index d1c19d29..15f057d9 100644
--- a/data/src/main/java/org/monogram/data/di/dataModule.kt
+++ b/data/src/main/java/org/monogram/data/di/dataModule.kt
@@ -81,6 +81,8 @@ import org.monogram.data.mapper.WebPageMapper
import org.monogram.data.mapper.message.MessageContentMapper
import org.monogram.data.mapper.message.MessagePersistenceMapper
import org.monogram.data.mapper.message.MessageSenderResolver
+import org.monogram.data.push.PushSyncTrigger
+import org.monogram.data.push.UnifiedPushManager
import org.monogram.data.repository.AttachMenuBotRepositoryImpl
import org.monogram.data.repository.AuthRepositoryImpl
import org.monogram.data.repository.BotRepositoryImpl
@@ -100,6 +102,7 @@ import org.monogram.data.repository.PollRepositoryImpl
import org.monogram.data.repository.PremiumRepositoryImpl
import org.monogram.data.repository.PrivacyRepositoryImpl
import org.monogram.data.repository.ProfilePhotoRepositoryImpl
+import org.monogram.data.repository.PushDebugRepositoryImpl
import org.monogram.data.repository.SessionRepositoryImpl
import org.monogram.data.repository.SponsorRepositoryImpl
import org.monogram.data.repository.StickerRepositoryImpl
@@ -140,6 +143,7 @@ import org.monogram.domain.repository.PollRepository
import org.monogram.domain.repository.PremiumRepository
import org.monogram.domain.repository.PrivacyRepository
import org.monogram.domain.repository.ProfilePhotoRepository
+import org.monogram.domain.repository.PushDebugRepository
import org.monogram.domain.repository.SessionRepository
import org.monogram.domain.repository.SponsorRepository
import org.monogram.domain.repository.StickerRepository
@@ -485,6 +489,9 @@ val dataModule = module {
)
}
+ single { PushSyncTrigger(connectionManager = get()) }
+ single { UnifiedPushManager(androidContext()) }
+
single {
ChatsListRepositoryImpl(
remoteDataSource = get(),
@@ -796,5 +803,17 @@ val dataModule = module {
)
}
- single(createdAtStart = true) { TdNotificationManager(androidContext(), get(), get(), get(), get(), get(), get()) }
+ single {
+ PushDebugRepositoryImpl(
+ context = androidContext(),
+ appPreferences = get(),
+ unifiedPushManager = get(),
+ pushSyncTrigger = get(),
+ scope = get()
+ )
+ }
+
+ single(createdAtStart = true) {
+ TdNotificationManager(androidContext(), get(), get(), get(), get(), get(), get(), get())
+ }
}
diff --git a/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt
new file mode 100644
index 00000000..8d091d97
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt
@@ -0,0 +1,33 @@
+package org.monogram.data.push
+
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import org.monogram.data.infra.ConnectionManager
+import java.util.concurrent.atomic.AtomicLong
+
+class PushSyncTrigger(
+ private val connectionManager: ConnectionManager
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val lastSyncAt = AtomicLong(0L)
+
+ fun requestSync(reason: String) {
+ val now = System.currentTimeMillis()
+ val previous = lastSyncAt.get()
+ if (now - previous < MIN_SYNC_GAP_MS) return
+ if (!lastSyncAt.compareAndSet(previous, now)) return
+
+ scope.launch {
+ Log.d(TAG, "Triggering TDLib sync from push: $reason")
+ connectionManager.retryConnection()
+ }
+ }
+
+ private companion object {
+ const val TAG = "PushSyncTrigger"
+ const val MIN_SYNC_GAP_MS = 1500L
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt
new file mode 100644
index 00000000..d1143ce5
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt
@@ -0,0 +1,156 @@
+package org.monogram.data.push
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.unifiedpush.android.connector.FailedReason
+import org.unifiedpush.android.connector.UnifiedPush
+import org.unifiedpush.android.connector.data.PushEndpoint
+import org.unifiedpush.android.connector.data.ResolvedDistributor
+
+class UnifiedPushManager(
+ private val context: Context
+) {
+ enum class Status {
+ IDLE,
+ REGISTERING,
+ REGISTERED,
+ FAILED,
+ UNREGISTERED
+ }
+
+ private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ private val _endpoint = MutableStateFlow(loadEndpoint())
+ val endpoint: StateFlow = _endpoint.asStateFlow()
+
+ private val _status = MutableStateFlow(loadStatus())
+ val status: StateFlow = _status.asStateFlow()
+
+ fun isDistributorAvailable(): Boolean = UnifiedPush.getDistributors(context).isNotEmpty()
+
+ fun getDistributors(): List = UnifiedPush.getDistributors(context)
+
+ fun getSavedDistributor(): String? = UnifiedPush.getSavedDistributor(context)
+
+ fun getAckDistributor(): String? = UnifiedPush.getAckDistributor(context)
+
+ fun currentDistributor(): String? {
+ val distributors = getDistributors()
+ val ack = getAckDistributor()
+ if (!ack.isNullOrBlank() && distributors.contains(ack)) {
+ return ack
+ }
+
+ val saved = getSavedDistributor()
+ if (!saved.isNullOrBlank() && distributors.contains(saved)) {
+ return saved
+ }
+
+ val resolved = UnifiedPush.resolveDefaultDistributor(context)
+ if (resolved is ResolvedDistributor.Found && distributors.contains(resolved.packageName)) {
+ return resolved.packageName
+ }
+
+ return distributors.firstOrNull()
+ }
+
+ fun ensureRegistered(force: Boolean = false): Boolean {
+ val distributor = currentDistributor() ?: return false
+ val endpointKnown = !_endpoint.value.isNullOrBlank()
+ if (!force && endpointKnown && _status.value == Status.REGISTERED && !shouldRefreshRegistration()) {
+ return true
+ }
+
+ _status.value = Status.REGISTERING
+ markRegistrationAttempt()
+
+ return runCatching {
+ UnifiedPush.saveDistributor(context, distributor)
+ UnifiedPush.register(context, INSTANCE_ID)
+ }.onFailure {
+ _status.value = Status.FAILED
+ Log.e(TAG, "Failed to request UnifiedPush registration", it)
+ }.isSuccess
+ }
+
+ fun unregister() {
+ runCatching {
+ UnifiedPush.unregister(context, INSTANCE_ID)
+ }.onFailure {
+ Log.e(TAG, "Failed to request UnifiedPush unregister", it)
+ }
+ clearEndpoint()
+ _status.value = Status.UNREGISTERED
+ }
+
+ fun onNewEndpoint(endpoint: PushEndpoint) {
+ onNewEndpoint(endpoint.url)
+ }
+
+ fun onNewEndpoint(endpoint: String?) {
+ val value = endpoint?.trim().orEmpty()
+ if (value.isEmpty()) {
+ _status.value = Status.FAILED
+ return
+ }
+
+ prefs.edit()
+ .putString(KEY_ENDPOINT, value)
+ .putLong(KEY_LAST_REGISTERED_AT, System.currentTimeMillis())
+ .apply()
+ _endpoint.value = value
+ _status.value = Status.REGISTERED
+ }
+
+ fun onRegistrationFailed(reason: FailedReason?) {
+ _status.value = Status.FAILED
+ if (reason != null) {
+ Log.w(TAG, "UnifiedPush registration failed: $reason")
+ }
+ }
+
+ fun onTempUnavailable() {
+ _status.value = Status.FAILED
+ Log.w(TAG, "UnifiedPush distributor temporarily unavailable")
+ }
+
+ fun onUnregistered() {
+ clearEndpoint()
+ _status.value = Status.UNREGISTERED
+ }
+
+ fun shouldRefreshRegistration(): Boolean {
+ val last = prefs.getLong(KEY_LAST_REGISTERED_AT, 0L)
+ if (last <= 0L) return true
+ return System.currentTimeMillis() - last >= REFRESH_INTERVAL_MS
+ }
+
+ private fun clearEndpoint() {
+ prefs.edit().remove(KEY_ENDPOINT).apply()
+ _endpoint.value = null
+ }
+
+ private fun markRegistrationAttempt() {
+ prefs.edit().putLong(KEY_LAST_REGISTER_ATTEMPT_AT, System.currentTimeMillis()).apply()
+ }
+
+ private fun loadEndpoint(): String? =
+ prefs.getString(KEY_ENDPOINT, null)?.takeIf { it.isNotBlank() }
+
+ private fun loadStatus(): Status {
+ return if (_endpoint.value.isNullOrBlank()) Status.IDLE else Status.REGISTERED
+ }
+
+ private companion object {
+ const val TAG = "UnifiedPushManager"
+ const val PREFS_NAME = "unified_push_state"
+ const val KEY_ENDPOINT = "endpoint"
+ const val KEY_LAST_REGISTERED_AT = "last_registered_at"
+ const val KEY_LAST_REGISTER_ATTEMPT_AT = "last_register_attempt_at"
+ const val REFRESH_INTERVAL_MS = 24L * 60L * 60L * 1000L
+ const val INSTANCE_ID = "monogram_default"
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt
new file mode 100644
index 00000000..fc7b06ec
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt
@@ -0,0 +1,139 @@
+package org.monogram.data.repository
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+import org.monogram.data.push.PushSyncTrigger
+import org.monogram.data.push.UnifiedPushManager
+import org.monogram.data.service.TdNotificationService
+import org.monogram.domain.repository.AppPreferencesProvider
+import org.monogram.domain.repository.PushDebugRepository
+import org.monogram.domain.repository.PushDiagnostics
+import org.monogram.domain.repository.UnifiedPushDebugStatus
+
+class PushDebugRepositoryImpl(
+ private val context: Context,
+ private val appPreferences: AppPreferencesProvider,
+ private val unifiedPushManager: UnifiedPushManager,
+ private val pushSyncTrigger: PushSyncTrigger,
+ private val scope: CoroutineScope
+) : PushDebugRepository {
+
+ private val _diagnostics = MutableStateFlow(PushDiagnostics())
+ override val diagnostics: StateFlow = _diagnostics
+
+ init {
+ val prefsFlow = combine(
+ appPreferences.pushProvider,
+ appPreferences.backgroundServiceEnabled,
+ appPreferences.hideForegroundNotification,
+ appPreferences.isPowerSavingMode,
+ appPreferences.isWakeLockEnabled
+ ) { provider, bgEnabled, hideForeground, powerSaving, wakeLock ->
+ PrefSnapshot(provider, bgEnabled, hideForeground, powerSaving, wakeLock)
+ }
+
+ scope.launch {
+ combine(
+ prefsFlow,
+ appPreferences.batteryOptimizationEnabled,
+ TdNotificationService.isRunningFlow,
+ unifiedPushManager.status,
+ unifiedPushManager.endpoint
+ ) { prefs, batteryOpt, serviceRunning, unifiedStatus, endpoint ->
+ PushDiagnostics(
+ pushProvider = prefs.pushProvider,
+ backgroundServiceEnabled = prefs.backgroundServiceEnabled,
+ hideForegroundNotification = prefs.hideForegroundNotification,
+ isPowerSavingMode = prefs.isPowerSavingMode,
+ isWakeLockEnabled = prefs.isWakeLockEnabled,
+ batteryOptimizationEnabled = batteryOpt,
+ isTdNotificationServiceRunning = serviceRunning,
+ unifiedPushStatus = when (unifiedStatus) {
+ UnifiedPushManager.Status.IDLE -> UnifiedPushDebugStatus.IDLE
+ UnifiedPushManager.Status.REGISTERING -> UnifiedPushDebugStatus.REGISTERING
+ UnifiedPushManager.Status.REGISTERED -> UnifiedPushDebugStatus.REGISTERED
+ UnifiedPushManager.Status.FAILED -> UnifiedPushDebugStatus.FAILED
+ UnifiedPushManager.Status.UNREGISTERED -> UnifiedPushDebugStatus.UNREGISTERED
+ },
+ unifiedPushEndpoint = endpoint,
+ unifiedPushSavedDistributor = unifiedPushManager.getSavedDistributor(),
+ unifiedPushAckDistributor = unifiedPushManager.getAckDistributor(),
+ unifiedPushDistributorsCount = unifiedPushManager.getDistributors().size
+ )
+ }.collect {
+ _diagnostics.value = it
+ }
+ }
+ }
+
+ override fun triggerTestPush() {
+ ensureDebugChannel()
+
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ 9110,
+ launchIntent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ val builder = NotificationCompat.Builder(context, DEBUG_CHANNEL_ID)
+ .setSmallIcon(org.monogram.data.R.drawable.message_outline)
+ .setContentTitle("MonoGram Debug Push")
+ .setContentText("Synthetic push signal delivered")
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+
+ if (ActivityCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
+ ) {
+ NotificationManagerCompat.from(context).notify(DEBUG_NOTIFICATION_ID, builder.build())
+ }
+
+ pushSyncTrigger.requestSync("debug_test_push")
+ }
+
+ private fun ensureDebugChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+
+ val manager = context.getSystemService(NotificationManager::class.java) ?: return
+ val channel = NotificationChannel(
+ DEBUG_CHANNEL_ID,
+ "Debug Push",
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ description = "Debug notifications for push diagnostics"
+ setShowBadge(false)
+ }
+ manager.createNotificationChannel(channel)
+ }
+
+ private data class PrefSnapshot(
+ val pushProvider: org.monogram.domain.repository.PushProvider,
+ val backgroundServiceEnabled: Boolean,
+ val hideForegroundNotification: Boolean,
+ val isPowerSavingMode: Boolean,
+ val isWakeLockEnabled: Boolean
+ )
+
+ private companion object {
+ const val DEBUG_CHANNEL_ID = "debug_push_channel"
+ const val DEBUG_NOTIFICATION_ID = 9110
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt
index 40ba3df1..cd02c8a2 100644
--- a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt
+++ b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt
@@ -17,6 +17,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -39,6 +42,9 @@ class TdNotificationService : Service() {
const val FOREGROUND_ID = 999
const val ACTION_STOP = "org.monogram.data.service.ACTION_STOP"
private const val CHECK_INTERVAL = 60_000L // 1 minute
+
+ private val _isRunningFlow = MutableStateFlow(false)
+ val isRunningFlow: StateFlow = _isRunningFlow.asStateFlow()
}
override fun onBind(intent: Intent?): IBinder? = null
@@ -51,11 +57,12 @@ class TdNotificationService : Service() {
if (!isServiceRunning) {
isServiceRunning = true
+ _isRunningFlow.value = true
// Call startForeground as soon as possible to satisfy
// startForegroundService() timing requirements on Android 8+.
startForegroundNotification()
- if (appPreferences.pushProvider.value == PushProvider.FCM) {
+ if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) {
stopForegroundService()
return START_NOT_STICKY
}
@@ -63,7 +70,7 @@ class TdNotificationService : Service() {
acquireWakeLock()
startListeningUpdates()
startPeriodicCheck()
- } else if (appPreferences.pushProvider.value == PushProvider.FCM) {
+ } else if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) {
stopForegroundService()
return START_NOT_STICKY
}
@@ -71,7 +78,7 @@ class TdNotificationService : Service() {
}
private fun acquireWakeLock() {
- if (appPreferences.pushProvider.value == PushProvider.FCM) return
+ if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) return
if (appPreferences.isPowerSavingMode.value) return
if (!appPreferences.isWakeLockEnabled.value) return
@@ -168,6 +175,7 @@ class TdNotificationService : Service() {
private fun stopForegroundService(userInitiated: Boolean = false) {
isServiceRunning = false
+ _isRunningFlow.value = false
checkJob?.cancel()
checkJob = null
releaseWakeLock()
@@ -197,7 +205,7 @@ class TdNotificationService : Service() {
) { powerSaving, wakeLockEnabled, batteryOptimization, pushProvider ->
Quadruple(powerSaving, wakeLockEnabled, batteryOptimization, pushProvider)
}.collect { (isPowerSaving, isWakeLockEnabled, isBatteryOptimization, pushProvider) ->
- if (pushProvider == PushProvider.FCM) {
+ if (pushProvider != PushProvider.GMS_LESS) {
stopForegroundService()
return@collect
}
@@ -217,7 +225,7 @@ class TdNotificationService : Service() {
checkJob?.cancel()
checkJob = serviceScope.launch {
while (isActive) {
- if (appPreferences.pushProvider.value == PushProvider.FCM || (!appPreferences.backgroundServiceEnabled.value && appPreferences.pushProvider.value == PushProvider.GMS_LESS)) {
+ if (appPreferences.pushProvider.value != PushProvider.GMS_LESS || !appPreferences.backgroundServiceEnabled.value) {
stopForegroundService()
break
}
@@ -257,6 +265,7 @@ class TdNotificationService : Service() {
override fun onDestroy() {
super.onDestroy()
isServiceRunning = false
+ _isRunningFlow.value = false
serviceScope.cancel()
releaseWakeLock()
}
diff --git a/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt
new file mode 100644
index 00000000..db2e0f00
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt
@@ -0,0 +1,51 @@
+package org.monogram.data.service
+
+import android.util.Log
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import org.monogram.data.push.PushSyncTrigger
+import org.monogram.data.push.UnifiedPushManager
+import org.monogram.domain.repository.AppPreferencesProvider
+import org.monogram.domain.repository.PushProvider
+import org.unifiedpush.android.connector.FailedReason
+import org.unifiedpush.android.connector.PushService
+import org.unifiedpush.android.connector.data.PushEndpoint
+import org.unifiedpush.android.connector.data.PushMessage
+
+class UnifiedPushService : PushService(), KoinComponent {
+ private val unifiedPushManager: UnifiedPushManager by inject()
+ private val pushSyncTrigger: PushSyncTrigger by inject()
+ private val appPreferences: AppPreferencesProvider by inject()
+
+ override fun onMessage(message: PushMessage, instance: String) {
+ if (!isUnifiedPushSelected()) return
+ pushSyncTrigger.requestSync("unified_push_message")
+ }
+
+ override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
+ if (!isUnifiedPushSelected()) return
+ unifiedPushManager.onNewEndpoint(endpoint)
+ pushSyncTrigger.requestSync("unified_push_new_endpoint")
+ }
+
+ override fun onRegistrationFailed(reason: FailedReason, instance: String) {
+ unifiedPushManager.onRegistrationFailed(reason)
+ }
+
+ override fun onUnregistered(instance: String) {
+ unifiedPushManager.onUnregistered()
+ }
+
+ override fun onTempUnavailable(instance: String) {
+ unifiedPushManager.onTempUnavailable()
+ Log.w(TAG, "UnifiedPush temp unavailable for instance=$instance")
+ }
+
+ private fun isUnifiedPushSelected(): Boolean {
+ return appPreferences.pushProvider.value == PushProvider.UNIFIED_PUSH
+ }
+
+ private companion object {
+ const val TAG = "UnifiedPushService"
+ }
+}
diff --git a/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt b/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt
index b40ef808..a0a0842a 100644
--- a/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt
+++ b/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt
@@ -3,5 +3,6 @@ package org.monogram.domain.managers
interface DistrManager {
fun isGmsAvailable(): Boolean
fun isFcmAvailable(): Boolean
+ fun isUnifiedPushDistributorAvailable(): Boolean
fun isInstalledFromGooglePlay(): Boolean
}
\ No newline at end of file
diff --git a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
index e45ab9c9..d42dce35 100644
--- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
@@ -3,7 +3,7 @@ package org.monogram.domain.repository
import kotlinx.coroutines.flow.StateFlow
enum class PushProvider {
- FCM, GMS_LESS
+ FCM, UNIFIED_PUSH, GMS_LESS
}
enum class ProxyNetworkType {
diff --git a/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt
new file mode 100644
index 00000000..856d2f55
--- /dev/null
+++ b/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt
@@ -0,0 +1,31 @@
+package org.monogram.domain.repository
+
+import kotlinx.coroutines.flow.StateFlow
+
+enum class UnifiedPushDebugStatus {
+ IDLE,
+ REGISTERING,
+ REGISTERED,
+ FAILED,
+ UNREGISTERED
+}
+
+data class PushDiagnostics(
+ val pushProvider: PushProvider = PushProvider.FCM,
+ val backgroundServiceEnabled: Boolean = true,
+ val hideForegroundNotification: Boolean = false,
+ val isPowerSavingMode: Boolean = false,
+ val isWakeLockEnabled: Boolean = true,
+ val batteryOptimizationEnabled: Boolean = false,
+ val isTdNotificationServiceRunning: Boolean = false,
+ val unifiedPushStatus: UnifiedPushDebugStatus = UnifiedPushDebugStatus.IDLE,
+ val unifiedPushEndpoint: String? = null,
+ val unifiedPushSavedDistributor: String? = null,
+ val unifiedPushAckDistributor: String? = null,
+ val unifiedPushDistributorsCount: Int = 0
+)
+
+interface PushDebugRepository {
+ val diagnostics: StateFlow
+ fun triggerTestPush()
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 76cc6af3..c7997585 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -48,6 +48,7 @@ playServices-ossLicenses = "17.5.0"
zxing = "3.5.4"
junit = "4.13.2"
libphonenumber = "9.0.27"
+unifiedpush-connector = "3.3.2"
[libraries]
# AndroidX Activity
@@ -140,6 +141,7 @@ maplibre-compose = { module = "io.github.rallista:maplibre-compose", version.ref
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
libphonenumber = { module = "com.googlecode.libphonenumber:libphonenumber", version.ref = "libphonenumber" }
+unifiedpush-connector = { group = "org.unifiedpush.android", name = "connector", version.ref = "unifiedpush-connector" }
[bundles]
androidx-camera = [
diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts
index af285278..9a485ecd 100644
--- a/presentation/build.gradle.kts
+++ b/presentation/build.gradle.kts
@@ -75,6 +75,7 @@ dependencies {
implementation(libs.maplibre.compose)
implementation(libs.play.services.oss.licenses)
implementation(libs.play.services.location)
+ implementation(libs.unifiedpush.connector)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt
index 5b666ca0..6af73078 100644
--- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt
+++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt
@@ -296,8 +296,7 @@ class AppPreferences(
private val _showSenderOnly = MutableStateFlow(prefs.getBoolean(KEY_SHOW_SENDER_ONLY, false))
override val showSenderOnly: StateFlow = _showSenderOnly
- private val _pushProvider =
- MutableStateFlow(PushProvider.entries[prefs.getInt(KEY_PUSH_PROVIDER, PushProvider.FCM.ordinal)])
+ private val _pushProvider = MutableStateFlow(loadPushProvider())
override val pushProvider: StateFlow = _pushProvider
private val _isArchivePinned = MutableStateFlow(prefs.getBoolean(KEY_IS_ARCHIVE_PINNED, true))
@@ -893,7 +892,10 @@ class AppPreferences(
}
override fun setPushProvider(provider: PushProvider) {
- prefs.edit().putInt(KEY_PUSH_PROVIDER, provider.ordinal).apply()
+ prefs.edit()
+ .putString(KEY_PUSH_PROVIDER_NAME, provider.name)
+ .putInt(KEY_PUSH_PROVIDER, provider.ordinal)
+ .apply()
_pushProvider.value = provider
}
@@ -1144,6 +1146,24 @@ class AppPreferences(
_isSupportViewed.value = viewed
}
+ private fun loadPushProvider(): PushProvider {
+ val byName = prefs.getString(KEY_PUSH_PROVIDER_NAME, null)
+ ?.let { stored -> PushProvider.entries.firstOrNull { it.name == stored } }
+ if (byName != null) {
+ return byName
+ }
+
+ val legacyOrdinal = prefs.getInt(KEY_PUSH_PROVIDER, PushProvider.FCM.ordinal)
+ val migrated = when (legacyOrdinal) {
+ 0 -> PushProvider.FCM
+ 1 -> PushProvider.GMS_LESS
+ else -> PushProvider.entries.getOrNull(legacyOrdinal) ?: PushProvider.FCM
+ }
+
+ prefs.edit().putString(KEY_PUSH_PROVIDER_NAME, migrated.name).apply()
+ return migrated
+ }
+
companion object {
private const val KEY_FONT_SIZE = "font_size"
private const val KEY_LETTER_SPACING = "letter_spacing"
@@ -1226,6 +1246,7 @@ class AppPreferences(
private const val KEY_REPEAT_NOTIFICATIONS = "repeat_notifications"
private const val KEY_SHOW_SENDER_ONLY = "show_sender_only"
private const val KEY_PUSH_PROVIDER = "push_provider"
+ private const val KEY_PUSH_PROVIDER_NAME = "push_provider_name"
private const val KEY_IS_ARCHIVE_PINNED = "is_archive_pinned"
private const val KEY_IS_ARCHIVE_ALWAYS_VISIBLE = "is_archive_always_visible"
diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt b/presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt
new file mode 100644
index 00000000..5b6309ec
--- /dev/null
+++ b/presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt
@@ -0,0 +1,11 @@
+package org.monogram.presentation.core.util
+
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.activity.ComponentActivity
+
+fun Context.findActivity(): ComponentActivity? = when (this) {
+ is ComponentActivity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
diff --git a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt
index dc356e6f..f5187e46 100644
--- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt
+++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt
@@ -4,8 +4,56 @@ import coil3.ImageLoader
import kotlinx.coroutines.CoroutineScope
import org.monogram.core.DispatcherProvider
import org.monogram.core.Logger
-import org.monogram.domain.managers.*
-import org.monogram.domain.repository.*
+import org.monogram.domain.managers.AssetsManager
+import org.monogram.domain.managers.ClipManager
+import org.monogram.domain.managers.DistrManager
+import org.monogram.domain.managers.DomainManager
+import org.monogram.domain.managers.PhoneManager
+import org.monogram.domain.repository.AppPreferencesProvider
+import org.monogram.domain.repository.AttachMenuBotRepository
+import org.monogram.domain.repository.AuthRepository
+import org.monogram.domain.repository.BotPreferencesProvider
+import org.monogram.domain.repository.BotRepository
+import org.monogram.domain.repository.CacheProvider
+import org.monogram.domain.repository.ChatCreationRepository
+import org.monogram.domain.repository.ChatEventLogRepository
+import org.monogram.domain.repository.ChatFolderRepository
+import org.monogram.domain.repository.ChatInfoRepository
+import org.monogram.domain.repository.ChatListRepository
+import org.monogram.domain.repository.ChatOperationsRepository
+import org.monogram.domain.repository.ChatSearchRepository
+import org.monogram.domain.repository.ChatSettingsRepository
+import org.monogram.domain.repository.ChatStatisticsRepository
+import org.monogram.domain.repository.EditorSnippetProvider
+import org.monogram.domain.repository.EmojiRepository
+import org.monogram.domain.repository.ExternalNavigator
+import org.monogram.domain.repository.ExternalProxyRepository
+import org.monogram.domain.repository.FileRepository
+import org.monogram.domain.repository.ForumTopicsRepository
+import org.monogram.domain.repository.GifRepository
+import org.monogram.domain.repository.InlineBotRepository
+import org.monogram.domain.repository.LinkHandlerRepository
+import org.monogram.domain.repository.LocationRepository
+import org.monogram.domain.repository.MessageAiRepository
+import org.monogram.domain.repository.MessageDisplayer
+import org.monogram.domain.repository.MessageRepository
+import org.monogram.domain.repository.NetworkStatisticsRepository
+import org.monogram.domain.repository.NotificationSettingsRepository
+import org.monogram.domain.repository.PaymentRepository
+import org.monogram.domain.repository.PremiumRepository
+import org.monogram.domain.repository.PrivacyRepository
+import org.monogram.domain.repository.ProfilePhotoRepository
+import org.monogram.domain.repository.PushDebugRepository
+import org.monogram.domain.repository.SessionRepository
+import org.monogram.domain.repository.SponsorRepository
+import org.monogram.domain.repository.StickerRepository
+import org.monogram.domain.repository.StorageRepository
+import org.monogram.domain.repository.StringProvider
+import org.monogram.domain.repository.UpdateRepository
+import org.monogram.domain.repository.UserProfileEditRepository
+import org.monogram.domain.repository.UserRepository
+import org.monogram.domain.repository.WallpaperRepository
+import org.monogram.domain.repository.WebAppRepository
import org.monogram.presentation.core.util.AppPreferences
import org.monogram.presentation.core.util.IDownloadUtils
import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache
@@ -64,6 +112,7 @@ interface RepositoriesContainer {
val gifRepository: GifRepository
val emojiRepository: EmojiRepository
val updateRepository: UpdateRepository
+ val pushDebugRepository: PushDebugRepository
}
interface UtilsContainer {
diff --git a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt
index 50f44bd9..b8291666 100644
--- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt
+++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt
@@ -5,8 +5,56 @@ import kotlinx.coroutines.CoroutineScope
import org.koin.core.Koin
import org.monogram.core.DispatcherProvider
import org.monogram.core.Logger
-import org.monogram.domain.managers.*
-import org.monogram.domain.repository.*
+import org.monogram.domain.managers.AssetsManager
+import org.monogram.domain.managers.ClipManager
+import org.monogram.domain.managers.DistrManager
+import org.monogram.domain.managers.DomainManager
+import org.monogram.domain.managers.PhoneManager
+import org.monogram.domain.repository.AppPreferencesProvider
+import org.monogram.domain.repository.AttachMenuBotRepository
+import org.monogram.domain.repository.AuthRepository
+import org.monogram.domain.repository.BotPreferencesProvider
+import org.monogram.domain.repository.BotRepository
+import org.monogram.domain.repository.CacheProvider
+import org.monogram.domain.repository.ChatCreationRepository
+import org.monogram.domain.repository.ChatEventLogRepository
+import org.monogram.domain.repository.ChatFolderRepository
+import org.monogram.domain.repository.ChatInfoRepository
+import org.monogram.domain.repository.ChatListRepository
+import org.monogram.domain.repository.ChatOperationsRepository
+import org.monogram.domain.repository.ChatSearchRepository
+import org.monogram.domain.repository.ChatSettingsRepository
+import org.monogram.domain.repository.ChatStatisticsRepository
+import org.monogram.domain.repository.EditorSnippetProvider
+import org.monogram.domain.repository.EmojiRepository
+import org.monogram.domain.repository.ExternalNavigator
+import org.monogram.domain.repository.ExternalProxyRepository
+import org.monogram.domain.repository.FileRepository
+import org.monogram.domain.repository.ForumTopicsRepository
+import org.monogram.domain.repository.GifRepository
+import org.monogram.domain.repository.InlineBotRepository
+import org.monogram.domain.repository.LinkHandlerRepository
+import org.monogram.domain.repository.LocationRepository
+import org.monogram.domain.repository.MessageAiRepository
+import org.monogram.domain.repository.MessageDisplayer
+import org.monogram.domain.repository.MessageRepository
+import org.monogram.domain.repository.NetworkStatisticsRepository
+import org.monogram.domain.repository.NotificationSettingsRepository
+import org.monogram.domain.repository.PaymentRepository
+import org.monogram.domain.repository.PremiumRepository
+import org.monogram.domain.repository.PrivacyRepository
+import org.monogram.domain.repository.ProfilePhotoRepository
+import org.monogram.domain.repository.PushDebugRepository
+import org.monogram.domain.repository.SessionRepository
+import org.monogram.domain.repository.SponsorRepository
+import org.monogram.domain.repository.StickerRepository
+import org.monogram.domain.repository.StorageRepository
+import org.monogram.domain.repository.StringProvider
+import org.monogram.domain.repository.UpdateRepository
+import org.monogram.domain.repository.UserProfileEditRepository
+import org.monogram.domain.repository.UserRepository
+import org.monogram.domain.repository.WallpaperRepository
+import org.monogram.domain.repository.WebAppRepository
import org.monogram.presentation.core.util.AppPreferences
import org.monogram.presentation.core.util.IDownloadUtils
import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache
@@ -65,6 +113,7 @@ class KoinRepositoriesContainer(private val koin: Koin) : RepositoriesContainer
override val gifRepository: GifRepository by lazy { koin.get() }
override val emojiRepository: EmojiRepository by lazy { koin.get() }
override val updateRepository: UpdateRepository by lazy { koin.get() }
+ override val pushDebugRepository: PushDebugRepository by lazy { koin.get() }
}
class KoinUtilsContainer(private val koin: Koin) : UtilsContainer {
diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt
index 85238d0c..7d5c0433 100644
--- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt
@@ -1,12 +1,37 @@
package org.monogram.presentation.settings.debug
+import com.arkivanov.decompose.value.Value
+import org.monogram.domain.repository.PushProvider
+import org.monogram.domain.repository.UnifiedPushDebugStatus
+
interface DebugComponent {
+ val state: Value
+
fun onBackClicked()
fun onCrashClicked()
fun onShowSponsorSheetClicked()
fun onForceSponsorSyncClicked()
+ fun onTestPushClicked()
fun onDropDatabasesClicked()
fun onDropCachePrefsClicked()
fun onDropPrefsClicked()
fun onDropDatabaseCacheClicked()
-}
\ No newline at end of file
+
+ data class State(
+ val pushProvider: PushProvider = PushProvider.FCM,
+ val backgroundServiceEnabled: Boolean = true,
+ val hideForegroundNotification: Boolean = false,
+ val isPowerSavingMode: Boolean = false,
+ val isWakeLockEnabled: Boolean = true,
+ val batteryOptimizationEnabled: Boolean = false,
+ val isTdNotificationServiceRunning: Boolean = false,
+ val unifiedPushStatus: UnifiedPushDebugStatus = UnifiedPushDebugStatus.IDLE,
+ val unifiedPushEndpoint: String? = null,
+ val unifiedPushSavedDistributor: String? = null,
+ val unifiedPushAckDistributor: String? = null,
+ val unifiedPushDistributorsCount: Int = 0,
+ val isGmsAvailable: Boolean = false,
+ val isFcmAvailable: Boolean = false,
+ val isUnifiedPushDistributorAvailable: Boolean = false
+ )
+}
diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt
index 7384ee7f..3ee0a315 100644
--- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt
@@ -1,26 +1,74 @@
package org.monogram.presentation.settings.debug
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
-import androidx.compose.material.icons.rounded.*
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.compose.material.icons.rounded.BatteryAlert
+import androidx.compose.material.icons.rounded.BatterySaver
+import androidx.compose.material.icons.rounded.Bookmark
+import androidx.compose.material.icons.rounded.BugReport
+import androidx.compose.material.icons.rounded.Cloud
+import androidx.compose.material.icons.rounded.CloudDone
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material.icons.rounded.DeleteSweep
+import androidx.compose.material.icons.rounded.DoneAll
+import androidx.compose.material.icons.rounded.Favorite
+import androidx.compose.material.icons.rounded.FilterList
+import androidx.compose.material.icons.rounded.Hub
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Link
+import androidx.compose.material.icons.rounded.NotificationAdd
+import androidx.compose.material.icons.rounded.Notifications
+import androidx.compose.material.icons.rounded.Power
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material.icons.rounded.Storage
+import androidx.compose.material.icons.rounded.Sync
+import androidx.compose.material.icons.rounded.VisibilityOff
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.arkivanov.decompose.extensions.compose.subscribeAsState
+import org.monogram.domain.repository.PushProvider
+import org.monogram.domain.repository.UnifiedPushDebugStatus
import org.monogram.presentation.R
import org.monogram.presentation.core.ui.ItemPosition
+import org.monogram.presentation.core.ui.SectionHeader
import org.monogram.presentation.core.ui.SettingsItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DebugContent(component: DebugComponent) {
+ val state by component.state.subscribeAsState()
var isSponsorSheetVisible by remember { mutableStateOf(false) }
if (isSponsorSheetVisible) {
@@ -85,10 +133,20 @@ fun DebugContent(component: DebugComponent) {
Scaffold(
topBar = {
TopAppBar(
- title = { Text("Debug", fontWeight = FontWeight.Bold) },
+ title = {
+ Text(
+ text = stringResource(R.string.debug_title),
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ },
navigationIcon = {
IconButton(onClick = component::onBackClicked) {
- Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
+ Icon(
+ Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.cd_back)
+ )
}
}
)
@@ -98,9 +156,162 @@ fun DebugContent(component: DebugComponent) {
modifier = Modifier
.fillMaxSize()
.padding(padding),
- contentPadding = PaddingValues(16.dp)
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
+ SectionHeader(stringResource(R.string.debug_section_push_diagnostics))
+ SettingsItem(
+ icon = Icons.Rounded.Notifications,
+ title = "Push provider",
+ subtitle = when (state.pushProvider) {
+ PushProvider.FCM -> "FCM"
+ PushProvider.UNIFIED_PUSH -> "UnifiedPush"
+ PushProvider.GMS_LESS -> "GMS-less"
+ },
+ iconBackgroundColor = Color(0xFF4CAF50),
+ position = ItemPosition.TOP,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.Sync,
+ title = "Push service running",
+ subtitle = state.isTdNotificationServiceRunning.toUiToggle(),
+ iconBackgroundColor = Color(0xFF00ACC1),
+ position = ItemPosition.MIDDLE,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.NotificationAdd,
+ title = "Test Push",
+ subtitle = "Show local notification and trigger sync",
+ iconBackgroundColor = Color(0xFF009688),
+ position = ItemPosition.BOTTOM,
+ onClick = component::onTestPushClicked
+ )
+ }
+
+ item {
+ SectionHeader(stringResource(R.string.debug_section_runtime_flags))
+ SettingsItem(
+ icon = Icons.Rounded.Settings,
+ title = "Keep-alive enabled",
+ subtitle = state.backgroundServiceEnabled.toUiToggle(),
+ iconBackgroundColor = Color(0xFF607D8B),
+ position = ItemPosition.TOP,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.BatteryAlert,
+ title = "Power saving mode",
+ subtitle = state.isPowerSavingMode.toUiToggle(),
+ iconBackgroundColor = Color(0xFFFF9800),
+ position = ItemPosition.MIDDLE,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.Power,
+ title = "Wake lock",
+ subtitle = state.isWakeLockEnabled.toUiToggle(),
+ iconBackgroundColor = Color(0xFF3F51B5),
+ position = ItemPosition.MIDDLE,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.BatterySaver,
+ title = "Battery optimization",
+ subtitle = state.batteryOptimizationEnabled.toUiToggle(),
+ iconBackgroundColor = Color(0xFF8BC34A),
+ position = ItemPosition.MIDDLE,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.VisibilityOff,
+ title = "Hide foreground notification",
+ subtitle = state.hideForegroundNotification.toUiToggle(),
+ iconBackgroundColor = Color(0xFF9E9E9E),
+ position = ItemPosition.BOTTOM,
+ onClick = { }
+ )
+ }
+
+ item {
+ SectionHeader(stringResource(R.string.debug_section_push_environment))
+ SettingsItem(
+ icon = Icons.Rounded.CloudDone,
+ title = "GMS available",
+ subtitle = state.isGmsAvailable.toUiToggle(),
+ iconBackgroundColor = Color(0xFF4285F4),
+ position = ItemPosition.TOP,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.Cloud,
+ title = "FCM available",
+ subtitle = state.isFcmAvailable.toUiToggle(),
+ iconBackgroundColor = Color(0xFF1E88E5),
+ position = ItemPosition.MIDDLE,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.Hub,
+ title = "UnifiedPush distributor",
+ subtitle = state.isUnifiedPushDistributorAvailable.toUiToggle(),
+ iconBackgroundColor = Color(0xFF7E57C2),
+ position = ItemPosition.BOTTOM,
+ onClick = { }
+ )
+ }
+
+ item {
+ SectionHeader(stringResource(R.string.debug_section_unifiedpush_details))
+ SettingsItem(
+ icon = Icons.Rounded.Info,
+ title = "UnifiedPush status",
+ subtitle = state.unifiedPushStatus.toUiText(),
+ iconBackgroundColor = Color(0xFF3949AB),
+ position = ItemPosition.TOP,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.Link,
+ title = "UnifiedPush endpoint",
+ subtitle = state.unifiedPushEndpoint?.takeIf { it.isNotBlank() }
+ ?: "Not registered",
+ iconBackgroundColor = Color(0xFF5C6BC0),
+ position = ItemPosition.MIDDLE,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.Bookmark,
+ title = "Saved distributor",
+ subtitle = state.unifiedPushSavedDistributor?.takeIf { it.isNotBlank() }
+ ?: "None",
+ iconBackgroundColor = Color(0xFF6A1B9A),
+ position = ItemPosition.MIDDLE,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.DoneAll,
+ title = "Ack distributor",
+ subtitle = state.unifiedPushAckDistributor?.takeIf { it.isNotBlank() }
+ ?: "None",
+ iconBackgroundColor = Color(0xFF512DA8),
+ position = ItemPosition.MIDDLE,
+ onClick = { }
+ )
+ SettingsItem(
+ icon = Icons.Rounded.FilterList,
+ title = "Distributors count",
+ subtitle = state.unifiedPushDistributorsCount.toString(),
+ iconBackgroundColor = Color(0xFF9575CD),
+ position = ItemPosition.BOTTOM,
+ onClick = { }
+ )
+ }
+
+ item {
+ SectionHeader(stringResource(R.string.debug_section_sponsor))
SettingsItem(
icon = Icons.Rounded.Favorite,
title = stringResource(R.string.debug_sponsor_sheet_title),
@@ -109,24 +320,24 @@ fun DebugContent(component: DebugComponent) {
position = ItemPosition.TOP,
onClick = { isSponsorSheetVisible = true }
)
- }
- item {
SettingsItem(
icon = Icons.Rounded.Sync,
title = stringResource(R.string.debug_force_sponsor_sync_title),
subtitle = stringResource(R.string.debug_force_sponsor_sync_subtitle),
iconBackgroundColor = Color(0xFF00ACC1),
- position = ItemPosition.MIDDLE,
+ position = ItemPosition.BOTTOM,
onClick = component::onForceSponsorSyncClicked
)
}
+
item {
+ SectionHeader(stringResource(R.string.debug_section_danger_zone))
SettingsItem(
icon = Icons.Rounded.BugReport,
title = "Crash App",
subtitle = "Trigger a manual RuntimeException",
iconBackgroundColor = Color.Red,
- position = ItemPosition.MIDDLE,
+ position = ItemPosition.TOP,
onClick = component::onCrashClicked
)
}
@@ -169,7 +380,23 @@ fun DebugContent(component: DebugComponent) {
position = ItemPosition.BOTTOM,
onClick = component::onDropPrefsClicked
)
+ Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
}
}
+
+@Composable
+private fun Boolean.toUiToggle(): String = if (this) {
+ stringResource(R.string.on_label)
+} else {
+ stringResource(R.string.off_label)
+}
+
+private fun UnifiedPushDebugStatus.toUiText(): String = when (this) {
+ UnifiedPushDebugStatus.IDLE -> "Idle"
+ UnifiedPushDebugStatus.REGISTERING -> "Registering"
+ UnifiedPushDebugStatus.REGISTERED -> "Registered"
+ UnifiedPushDebugStatus.FAILED -> "Failed"
+ UnifiedPushDebugStatus.UNREGISTERED -> "Unregistered"
+}
diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt
index 484ca8e5..74e182f8 100644
--- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt
@@ -1,5 +1,11 @@
package org.monogram.presentation.settings.debug
+import com.arkivanov.decompose.value.MutableValue
+import com.arkivanov.decompose.value.Value
+import com.arkivanov.decompose.value.update
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.monogram.presentation.core.util.componentScope
import org.monogram.presentation.root.AppComponentContext
import java.io.File
@@ -11,7 +17,40 @@ class DefaultDebugComponent(
private val messageDisplayer = container.utils.messageDisplayer()
private val assetsManager = container.utils.assetsManager()
private val externalNavigator = container.utils.externalNavigator()
+ private val distrManager = container.utils.distrManager()
+ private val pushDebugRepository = container.repositories.pushDebugRepository
private val sponsorRepository = container.repositories.sponsorRepository
+ private val scope = componentScope
+
+ private val _state = MutableValue(
+ DebugComponent.State(
+ isGmsAvailable = distrManager.isGmsAvailable(),
+ isFcmAvailable = distrManager.isFcmAvailable(),
+ isUnifiedPushDistributorAvailable = distrManager.isUnifiedPushDistributorAvailable()
+ )
+ )
+ override val state: Value = _state
+
+ init {
+ pushDebugRepository.diagnostics.onEach { diagnostics ->
+ _state.update {
+ it.copy(
+ pushProvider = diagnostics.pushProvider,
+ backgroundServiceEnabled = diagnostics.backgroundServiceEnabled,
+ hideForegroundNotification = diagnostics.hideForegroundNotification,
+ isPowerSavingMode = diagnostics.isPowerSavingMode,
+ isWakeLockEnabled = diagnostics.isWakeLockEnabled,
+ batteryOptimizationEnabled = diagnostics.batteryOptimizationEnabled,
+ isTdNotificationServiceRunning = diagnostics.isTdNotificationServiceRunning,
+ unifiedPushStatus = diagnostics.unifiedPushStatus,
+ unifiedPushEndpoint = diagnostics.unifiedPushEndpoint,
+ unifiedPushSavedDistributor = diagnostics.unifiedPushSavedDistributor,
+ unifiedPushAckDistributor = diagnostics.unifiedPushAckDistributor,
+ unifiedPushDistributorsCount = diagnostics.unifiedPushDistributorsCount
+ )
+ }
+ }.launchIn(scope)
+ }
override fun onBackClicked() {
onBack()
@@ -30,6 +69,11 @@ class DefaultDebugComponent(
messageDisplayer.show("Sponsor sync started")
}
+ override fun onTestPushClicked() {
+ pushDebugRepository.triggerTestPush()
+ messageDisplayer.show("Debug push dispatched")
+ }
+
override fun onDropDatabasesClicked() {
messageDisplayer.show("Dropping databases and restarting...")
assetsManager.getDatabasePath("monogram_db").delete()
diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt
index 075ba5bf..126d03cf 100644
--- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt
@@ -1,7 +1,11 @@
package org.monogram.presentation.settings.notifications
import android.os.Parcelable
-import com.arkivanov.decompose.router.stack.*
+import com.arkivanov.decompose.router.stack.ChildStack
+import com.arkivanov.decompose.router.stack.StackNavigation
+import com.arkivanov.decompose.router.stack.childStack
+import com.arkivanov.decompose.router.stack.pop
+import com.arkivanov.decompose.router.stack.push
import com.arkivanov.decompose.value.MutableValue
import com.arkivanov.decompose.value.Value
import com.arkivanov.decompose.value.update
@@ -61,6 +65,7 @@ interface NotificationsComponent {
val showSenderOnly: Boolean = false,
val pushProvider: PushProvider = PushProvider.FCM,
val isGmsAvailable: Boolean = false,
+ val isUnifiedPushAvailable: Boolean = false,
val privateExceptions: List? = null,
val groupExceptions: List? = null,
val channelExceptions: List? = null
@@ -163,7 +168,12 @@ class DefaultNotificationsComponent(
_state.update { it.copy(pushProvider = value) }
}.launchIn(scope)
- _state.update { it.copy(isGmsAvailable = distrManager.isGmsAvailable()) }
+ _state.update {
+ it.copy(
+ isGmsAvailable = distrManager.isGmsAvailable() && distrManager.isFcmAvailable(),
+ isUnifiedPushAvailable = distrManager.isUnifiedPushDistributorAvailable()
+ )
+ }
syncSettings()
}
@@ -296,7 +306,12 @@ class DefaultNotificationsComponent(
onPriorityChanged(1)
onRepeatNotificationsChanged(0)
onShowSenderOnlyToggled(false)
- onPushProviderChanged(if (_state.value.isGmsAvailable) PushProvider.FCM else PushProvider.GMS_LESS)
+ val defaultProvider = when {
+ _state.value.isGmsAvailable -> PushProvider.FCM
+ _state.value.isUnifiedPushAvailable -> PushProvider.UNIFIED_PUSH
+ else -> PushProvider.GMS_LESS
+ }
+ onPushProviderChanged(defaultProvider)
}
override fun onExceptionClicked(scope: TdNotificationScope) {
diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt
index 15ab5036..0eef28c1 100644
--- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt
@@ -1,19 +1,59 @@
package org.monogram.presentation.settings.notifications
+import android.widget.Toast
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.VolumeUp
-import androidx.compose.material.icons.rounded.*
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.compose.material.icons.rounded.Campaign
+import androidx.compose.material.icons.rounded.ChatBubbleOutline
+import androidx.compose.material.icons.rounded.Group
+import androidx.compose.material.icons.rounded.NotificationsActive
+import androidx.compose.material.icons.rounded.Person
+import androidx.compose.material.icons.rounded.PersonAdd
+import androidx.compose.material.icons.rounded.PriorityHigh
+import androidx.compose.material.icons.rounded.PushPin
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material.icons.rounded.Repeat
+import androidx.compose.material.icons.rounded.Sync
+import androidx.compose.material.icons.rounded.Vibration
+import androidx.compose.material.icons.rounded.Visibility
+import androidx.compose.material.icons.rounded.VisibilityOff
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -29,6 +69,8 @@ import org.monogram.presentation.core.ui.ExpressiveDefaults
import org.monogram.presentation.core.ui.ItemPosition
import org.monogram.presentation.core.ui.SettingsItem
import org.monogram.presentation.core.ui.SettingsSwitchTile
+import org.monogram.presentation.core.util.findActivity
+import org.unifiedpush.android.connector.UnifiedPush
@Composable
fun NotificationsContent(component: NotificationsComponent) {
@@ -44,6 +86,7 @@ fun NotificationsContent(component: NotificationsComponent) {
@Composable
private fun NotificationsMainContent(component: NotificationsComponent) {
val state by component.state.subscribeAsState()
+ val context = LocalContext.current
var showVibrationSheet by remember { mutableStateOf(false) }
var showPrioritySheet by remember { mutableStateOf(false) }
var showRepeatSheet by remember { mutableStateOf(false) }
@@ -196,6 +239,7 @@ private fun NotificationsMainContent(component: NotificationsComponent) {
title = stringResource(R.string.push_provider_title),
subtitle = when (state.pushProvider) {
PushProvider.FCM -> stringResource(R.string.push_provider_fcm)
+ PushProvider.UNIFIED_PUSH -> stringResource(R.string.push_provider_unified)
PushProvider.GMS_LESS -> stringResource(R.string.push_provider_gms_less)
},
iconBackgroundColor = Color(0xFF4CAF50),
@@ -209,6 +253,7 @@ private fun NotificationsMainContent(component: NotificationsComponent) {
checked = state.backgroundServiceEnabled,
iconColor = Color(0xFF607D8B),
position = ItemPosition.MIDDLE,
+ enabled = state.pushProvider == PushProvider.GMS_LESS,
onCheckedChange = component::onBackgroundServiceToggled
)
SettingsSwitchTile(
@@ -218,6 +263,7 @@ private fun NotificationsMainContent(component: NotificationsComponent) {
checked = state.hideForegroundNotification,
iconColor = Color(0xFF9E9E9E),
position = ItemPosition.BOTTOM,
+ enabled = state.pushProvider == PushProvider.GMS_LESS,
onCheckedChange = component::onHideForegroundNotificationToggled
)
}
@@ -349,6 +395,9 @@ private fun NotificationsMainContent(component: NotificationsComponent) {
if (state.isGmsAvailable) {
options.add(PushProvider.FCM.name to stringResource(R.string.push_provider_fcm))
}
+ if (state.isUnifiedPushAvailable) {
+ options.add(PushProvider.UNIFIED_PUSH.name to stringResource(R.string.push_provider_unified))
+ }
options.add(PushProvider.GMS_LESS.name to stringResource(R.string.push_provider_gms_less))
NotificationOptionSheet(
@@ -356,8 +405,35 @@ private fun NotificationsMainContent(component: NotificationsComponent) {
options = options,
selectedOption = state.pushProvider.name,
onOptionSelected = {
- component.onPushProviderChanged(PushProvider.valueOf(it))
- showPushProviderSheet = false
+ val selected = PushProvider.valueOf(it)
+ if (selected != PushProvider.UNIFIED_PUSH) {
+ component.onPushProviderChanged(selected)
+ showPushProviderSheet = false
+ return@NotificationOptionSheet
+ }
+
+ val activity = context.findActivity()
+ if (activity == null) {
+ Toast.makeText(
+ context,
+ "Cannot select UnifiedPush without active activity",
+ Toast.LENGTH_SHORT
+ ).show()
+ return@NotificationOptionSheet
+ }
+
+ UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success ->
+ if (success) {
+ component.onPushProviderChanged(PushProvider.UNIFIED_PUSH)
+ showPushProviderSheet = false
+ } else {
+ Toast.makeText(
+ context,
+ "UnifiedPush distributor not selected",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
},
onDismiss = { showPushProviderSheet = false }
)
diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml
index 33604687..dd863ca3 100644
--- a/presentation/src/main/res/values/string.xml
+++ b/presentation/src/main/res/values/string.xml
@@ -381,6 +381,12 @@
Open sponsor info bottom sheet
Force sponsor sync
Fetch sponsor IDs from channel now
+ Push Diagnostics
+ Runtime Flags
+ Push Environment
+ UnifiedPush Details
+ Sponsor
+ Danger Zone
Log Out
Disconnect from account
@@ -612,6 +618,7 @@
Every 1 hour
Every %1$d hours
Firebase Cloud Messaging
+ UnifiedPush (Simple Push)
GMS-less (Background Service)
From 4f5ae0a912dacdca96af0f3cd1f265ab06cd26a3 Mon Sep 17 00:00:00 2001
From: Artur Skubei <41114720+gdlbo@users.noreply.github.com>
Date: Mon, 13 Apr 2026 01:30:41 +0300
Subject: [PATCH 2/3] refactor notification mute logic and improve push sync
reliability
- introduce `NotificationMuteResolver` and `NotificationMuteDecision` to centralize chat mute state logic across `TdNotificationManager` and `ChatModelFactory`
- enhance `TdNotificationManager` with extensive logging for message processing and notification lifecycle events
- update `PushSyncTrigger` to include a delayed authentication check and `GetMe` probe to ensure TDLib is synchronized after a push message
- improve `UnifiedPushManager` and `UnifiedPushService` with detailed logging and distributor selection debugging
- fix potential notification leaks by adding extra validation for outgoing, duplicate, or empty messages
- transition `TdNotificationManager` to use `TdNotificationScope` directly instead of internal enum mapping
- add more descriptive "reason" metadata to push synchronization requests and notification mute decisions
---
.../monogram/data/chats/ChatModelFactory.kt | 37 +++-
.../monogram/data/di/TdNotificationManager.kt | 191 +++++++++++++-----
.../java/org/monogram/data/di/dataModule.kt | 16 +-
.../notifications/NotificationMuteDecision.kt | 19 ++
.../notifications/NotificationMuteResolver.kt | 86 ++++++++
.../notifications/NotificationScopeState.kt | 8 +
.../org/monogram/data/push/PushSyncTrigger.kt | 37 +++-
.../monogram/data/push/UnifiedPushManager.kt | 15 +-
.../data/service/UnifiedPushService.kt | 35 +++-
9 files changed, 370 insertions(+), 74 deletions(-)
create mode 100644 data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt
create mode 100644 data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt
create mode 100644 data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt
diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt
index 8bf1710a..ad8fa383 100644
--- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt
+++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt
@@ -9,12 +9,19 @@ import org.monogram.core.DispatcherProvider
import org.monogram.data.core.coRunCatching
import org.monogram.data.db.dao.UserFullInfoDao
import org.monogram.data.gateway.TelegramGateway
-import org.monogram.data.mapper.*
+import org.monogram.data.mapper.ChatMapper
+import org.monogram.data.mapper.isForcedVerifiedChat
+import org.monogram.data.mapper.isForcedVerifiedUser
+import org.monogram.data.mapper.isSponsoredUser
+import org.monogram.data.mapper.isValidFilePath
import org.monogram.data.mapper.user.toEntity
import org.monogram.data.mapper.user.toTdApi
+import org.monogram.data.notifications.NotificationMuteResolver
+import org.monogram.data.notifications.NotificationScopeState
import org.monogram.domain.models.ChatModel
import org.monogram.domain.models.UsernamesModel
import org.monogram.domain.repository.AppPreferencesProvider
+import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope
import java.util.concurrent.ConcurrentHashMap
class ChatModelFactory(
@@ -27,6 +34,7 @@ class ChatModelFactory(
private val typingManager: ChatTypingManager,
private val appPreferences: AppPreferencesProvider,
private val userFullInfoDao: UserFullInfoDao,
+ private val muteResolver: NotificationMuteResolver = NotificationMuteResolver(),
private val triggerUpdate: (Long?) -> Unit,
private val fetchUser: (Long) -> Unit
) {
@@ -260,15 +268,24 @@ class ChatModelFactory(
cache.usersCache[userId]?.firstName ?: run { fetchUser(userId); null }
}
- val isMuted = when {
- chat.notificationSettings.muteFor > 0 -> true
- chat.notificationSettings.useDefaultMuteFor -> when {
- isChannel -> !appPreferences.channelsNotifications.value
- isSupergroup || chat.type is TdApi.ChatTypeBasicGroup -> !appPreferences.groupsNotifications.value
- else -> !appPreferences.privateChatsNotifications.value
- }
- else -> false
- }
+ val scopeState = NotificationScopeState(
+ loadedScopes = setOf(
+ TdNotificationScope.PRIVATE_CHATS,
+ TdNotificationScope.GROUPS,
+ TdNotificationScope.CHANNELS
+ ),
+ enabledByScope = mapOf(
+ TdNotificationScope.PRIVATE_CHATS to appPreferences.privateChatsNotifications.value,
+ TdNotificationScope.GROUPS to appPreferences.groupsNotifications.value,
+ TdNotificationScope.CHANNELS to appPreferences.channelsNotifications.value
+ )
+ )
+
+ val isMuted = muteResolver.resolve(
+ chat = chat,
+ cachedSettings = null,
+ scopeState = scopeState
+ ).isMuted
return chatMapper.mapChatToModel(
chat = chat,
diff --git a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt
index a887605f..f9b18ed7 100644
--- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt
+++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt
@@ -44,6 +44,9 @@ import org.monogram.data.db.dao.NotificationSettingDao
import org.monogram.data.db.model.NotificationSettingEntity
import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.infra.FileDownloadQueue
+import org.monogram.data.notifications.NotificationMuteDecision
+import org.monogram.data.notifications.NotificationMuteResolver
+import org.monogram.data.notifications.NotificationScopeState
import org.monogram.data.push.UnifiedPushManager
import org.monogram.data.service.NotificationDismissReceiver
import org.monogram.data.service.NotificationReadReceiver
@@ -64,7 +67,8 @@ class TdNotificationManager(
private val notificationSettingDao: NotificationSettingDao,
private val fileQueue: FileDownloadQueue,
private val stringProvider: StringProvider,
- private val unifiedPushManager: UnifiedPushManager
+ private val unifiedPushManager: UnifiedPushManager,
+ private val muteResolver: NotificationMuteResolver
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val notificationManager = NotificationManagerCompat.from(context)
@@ -80,18 +84,12 @@ class TdNotificationManager(
}
private val activeDownloads = ConcurrentHashMap Unit>>()
private val notificationSettingsCache = ConcurrentHashMap()
- private val scopeNotificationsEnabled = ConcurrentHashMap()
- private val loadedScopeSettings = ConcurrentHashMap.newKeySet()
+ private val scopeNotificationsEnabled = ConcurrentHashMap()
+ private val loadedScopeSettings = ConcurrentHashMap.newKeySet()
@Volatile
private var myUserId: Long = 0L
- private enum class NotificationScopeKey {
- PRIVATE,
- GROUPS,
- CHANNELS
- }
-
companion object {
private const val TAG = "TdNotificationManager"
const val GROUP_CHATS = "group_chats"
@@ -138,7 +136,15 @@ class TdNotificationManager(
scope.launch {
gateway.updates.collect { update ->
when (update) {
- is TdApi.UpdateNewMessage -> handleNewMessage(update.message)
+ is TdApi.UpdateNewMessage -> {
+ val senderDebug = senderIdToDebug(update.message.senderId)
+ Log.d(
+ TAG,
+ "UpdateNewMessage chatId=${update.message.chatId} messageId=${update.message.id} " +
+ "outgoing=${update.message.isOutgoing} sender=$senderDebug"
+ )
+ handleNewMessage(update.message)
+ }
is TdApi.UpdateUser -> userCache[update.user.id] = update.user
is TdApi.UpdateFile -> {
val file = update.file
@@ -200,6 +206,10 @@ class TdNotificationManager(
scope.launch {
unifiedPushManager.endpoint.collect {
if (appPreferences.pushProvider.value == PushProvider.UNIFIED_PUSH && !it.isNullOrBlank()) {
+ Log.d(
+ TAG,
+ "UnifiedPush endpoint update observed, refreshing TDLib registration"
+ )
updatePushRegistration()
}
}
@@ -232,6 +242,7 @@ class TdNotificationManager(
longArrayOf()
)
)
+ Log.d(TAG, "RegisterDevice success for FCM")
}.onFailure { Log.e(TAG, "FCM token registration failed", it) }
}
@@ -250,6 +261,10 @@ class TdNotificationManager(
longArrayOf()
)
)
+ Log.d(
+ TAG,
+ "RegisterDevice success for UnifiedPush endpoint=${endpoint.take(120)}"
+ )
}.onFailure { Log.e(TAG, "UnifiedPush registration failed", it) }
}
@@ -262,6 +277,7 @@ class TdNotificationManager(
longArrayOf()
)
)
+ Log.d(TAG, "RegisterDevice success for GMS-less fallback")
}.onFailure { Log.e(TAG, "GMS-less token registration failed", it) }
}
}
@@ -299,57 +315,42 @@ class TdNotificationManager(
if (!gateway.isAuthenticated.value) return
val scopes = listOf(
- NotificationScopeKey.PRIVATE to TdNotificationScope.PRIVATE_CHATS,
- NotificationScopeKey.GROUPS to TdNotificationScope.GROUPS,
- NotificationScopeKey.CHANNELS to TdNotificationScope.CHANNELS
+ TdNotificationScope.PRIVATE_CHATS,
+ TdNotificationScope.GROUPS,
+ TdNotificationScope.CHANNELS
)
- scopes.forEach { (key, scope) ->
+ scopes.forEach { scope ->
val enabled = coRunCatching { notificationSettingsRepository.getNotificationSettings(scope) }
.getOrDefault(false)
- scopeNotificationsEnabled[key] = enabled
- loadedScopeSettings.add(key)
+ scopeNotificationsEnabled[scope] = enabled
+ loadedScopeSettings.add(scope)
- when (key) {
- NotificationScopeKey.PRIVATE -> appPreferences.setPrivateChatsNotifications(enabled)
- NotificationScopeKey.GROUPS -> appPreferences.setGroupsNotifications(enabled)
- NotificationScopeKey.CHANNELS -> appPreferences.setChannelsNotifications(enabled)
+ when (scope) {
+ TdNotificationScope.PRIVATE_CHATS -> appPreferences.setPrivateChatsNotifications(
+ enabled
+ )
+
+ TdNotificationScope.GROUPS -> appPreferences.setGroupsNotifications(enabled)
+ TdNotificationScope.CHANNELS -> appPreferences.setChannelsNotifications(enabled)
}
}
}
fun isChatMuted(chat: TdApi.Chat): Boolean {
- val cached = notificationSettingsCache[chat.id]
- val chatSettings = chat.notificationSettings
- val muteFor = cached?.muteFor ?: chatSettings?.muteFor ?: 0
- val useDefault = cached?.useDefault ?: chatSettings?.useDefaultMuteFor ?: true
-
- return if (useDefault) {
- val chatType = chat.type ?: return muteFor > 0
- val scopeKey = when (chatType) {
- is TdApi.ChatTypePrivate -> NotificationScopeKey.PRIVATE
- is TdApi.ChatTypeBasicGroup -> NotificationScopeKey.GROUPS
- is TdApi.ChatTypeSupergroup -> {
- if (chatType.isChannel) NotificationScopeKey.CHANNELS else NotificationScopeKey.GROUPS
- }
-
- else -> null
- }
-
- if (scopeKey == null) {
- return muteFor > 0
- }
-
- if (!loadedScopeSettings.contains(scopeKey)) {
- return false
- }
+ return resolveMuteDecision(chat).isMuted
+ }
- val globalEnabled = scopeNotificationsEnabled[scopeKey] ?: true
- !globalEnabled
- } else {
- muteFor > 0
- }
+ private fun resolveMuteDecision(chat: TdApi.Chat): NotificationMuteDecision {
+ return muteResolver.resolve(
+ chat = chat,
+ cachedSettings = notificationSettingsCache[chat.id],
+ scopeState = NotificationScopeState(
+ loadedScopes = loadedScopeSettings.toSet(),
+ enabledByScope = scopeNotificationsEnabled.toMap()
+ )
+ )
}
fun clearHistory(chatId: Long) {
@@ -383,7 +384,19 @@ class TdNotificationManager(
}
private fun handleNewMessage(message: TdApi.Message) {
- if (message.isOutgoing) return
+ Log.d(
+ TAG,
+ "handleNewMessage enter chatId=${message.chatId} messageId=${message.id} outgoing=${message.isOutgoing} " +
+ "content=${message.content?.javaClass?.simpleName ?: "null"}"
+ )
+
+ if (message.isOutgoing) {
+ Log.d(
+ TAG,
+ "Skip notification: outgoing message, chatId=${message.chatId}, messageId=${message.id}"
+ )
+ return
+ }
val messageContent = message.content
if (messageContent == null) {
@@ -398,17 +411,32 @@ class TdNotificationManager(
}
if (senderId is TdApi.MessageSenderUser && senderId.userId != 0L && senderId.userId == myUserId) {
+ Log.d(
+ TAG,
+ "Skip notification: sender is self, chatId=${message.chatId}, messageId=${message.id}"
+ )
return
}
val lastId = lastMessageIds[message.chatId]
if (lastId != null && message.id <= lastId) {
+ Log.d(
+ TAG,
+ "Skip notification: stale/duplicate message, chatId=${message.chatId}, messageId=${message.id}, lastId=$lastId"
+ )
return
}
lastMessageIds[message.chatId] = message.id
scope.launch {
- val chat = getChatSuspend(message.chatId) ?: return@launch
+ val chat = getChatSuspend(message.chatId)
+ if (chat == null) {
+ Log.d(
+ TAG,
+ "Skip notification: chat unavailable, chatId=${message.chatId}, messageId=${message.id}"
+ )
+ return@launch
+ }
val chatType = chat.type
if (chatType == null) {
@@ -422,20 +450,50 @@ class TdNotificationManager(
return@launch
}
- if (isChatMuted(chat)) return@launch
+ val muteDecision = resolveMuteDecision(chat)
+ if (muteDecision.isMuted) {
+ Log.d(
+ TAG,
+ "Skip notification: muted reason=${muteDecision.reason} scope=${muteDecision.scope} " +
+ "muteFor=${muteDecision.muteFor} useDefault=${muteDecision.useDefault} " +
+ "chatId=${chat.id} messageId=${message.id}"
+ )
+ return@launch
+ }
val contentText =
if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText(
messageContent
)
- if (contentText.isBlank()) return@launch
+ if (contentText.isBlank()) {
+ Log.d(
+ TAG,
+ "Skip notification: empty content text, chatId=${chat.id}, messageId=${message.id}"
+ )
+ return@launch
+ }
val timestamp = message.date.toLong() * 1000
val shouldPreloadAvatar =
!appPreferences.isPowerSavingMode.value && !appPreferences.batteryOptimizationEnabled.value
resolveSender(senderId, chat, true) { senderName, senderBitmap ->
+ Log.d(
+ TAG,
+ "Resolved sender for notification chatId=${chat.id} messageId=${message.id} " +
+ "senderName=$senderName hasBitmap=${senderBitmap != null}"
+ )
+
+ Log.d(
+ TAG,
+ "Append notification chatId=${chat.id} messageId=${message.id} " +
+ "chatType=${chatType.javaClass.simpleName} text=${
+ previewText(
+ contentText
+ )
+ }"
+ )
appendMessageToNotification(
chatId = chat.id,
messageId = message.id,
@@ -495,9 +553,16 @@ class TdNotificationManager(
text: String,
timestamp: Long
) {
- if (text.isBlank()) return
+ if (text.isBlank()) {
+ Log.d(
+ TAG,
+ "Skip appendMessageToNotification: blank text, chatId=$chatId, messageId=$messageId"
+ )
+ return
+ }
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ Log.w(TAG, "Skip appendMessageToNotification: missing POST_NOTIFICATIONS permission")
return
}
@@ -530,6 +595,11 @@ class TdNotificationManager(
history.removeAt(0)
}
+ Log.d(
+ TAG,
+ "Notification history updated chatId=$chatId size=${history.size} notificationId=${chatId.toInt()}"
+ )
+
val notificationId = chatId.toInt()
activeNotifications.getOrPut(chatId) { ConcurrentHashMap.newKeySet() }.add(notificationId)
@@ -658,9 +728,22 @@ class TdNotificationManager(
}
notificationManager.notify(notificationId, builder.build())
+ Log.d(TAG, "Notification posted chatId=$chatId notificationId=$notificationId")
updateSummary()
}
+ private fun senderIdToDebug(senderId: TdApi.MessageSender?): String = when (senderId) {
+ null -> "null"
+ is TdApi.MessageSenderUser -> "user:${senderId.userId}"
+ is TdApi.MessageSenderChat -> "chat:${senderId.chatId}"
+ else -> senderId.javaClass.simpleName
+ }
+
+ private fun previewText(text: String, max: Int = 80): String {
+ val normalized = text.replace('\n', ' ').trim()
+ return if (normalized.length <= max) normalized else normalized.take(max) + "..."
+ }
+
private fun getCircularBitmap(bitmap: Bitmap): Bitmap {
val size = min(bitmap.width, bitmap.height)
diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt
index 15f057d9..15c3ad88 100644
--- a/data/src/main/java/org/monogram/data/di/dataModule.kt
+++ b/data/src/main/java/org/monogram/data/di/dataModule.kt
@@ -81,6 +81,7 @@ import org.monogram.data.mapper.WebPageMapper
import org.monogram.data.mapper.message.MessageContentMapper
import org.monogram.data.mapper.message.MessagePersistenceMapper
import org.monogram.data.mapper.message.MessageSenderResolver
+import org.monogram.data.notifications.NotificationMuteResolver
import org.monogram.data.push.PushSyncTrigger
import org.monogram.data.push.UnifiedPushManager
import org.monogram.data.repository.AttachMenuBotRepositoryImpl
@@ -489,8 +490,9 @@ val dataModule = module {
)
}
- single { PushSyncTrigger(connectionManager = get()) }
+ single { PushSyncTrigger(connectionManager = get(), gateway = get()) }
single { UnifiedPushManager(androidContext()) }
+ single { NotificationMuteResolver() }
single {
ChatsListRepositoryImpl(
@@ -814,6 +816,16 @@ val dataModule = module {
}
single(createdAtStart = true) {
- TdNotificationManager(androidContext(), get(), get(), get(), get(), get(), get(), get())
+ TdNotificationManager(
+ androidContext(),
+ get(),
+ get(),
+ get(),
+ get(),
+ get(),
+ get(),
+ get(),
+ get()
+ )
}
}
diff --git a/data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt b/data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt
new file mode 100644
index 00000000..e669fe35
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt
@@ -0,0 +1,19 @@
+package org.monogram.data.notifications
+
+import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope
+
+data class NotificationMuteDecision(
+ val isMuted: Boolean,
+ val scope: TdNotificationScope?,
+ val reason: Reason,
+ val muteFor: Int,
+ val useDefault: Boolean
+) {
+ enum class Reason {
+ CHAT_MUTED,
+ SCOPE_DISABLED,
+ NOT_MUTED,
+ SCOPE_NOT_LOADED,
+ UNKNOWN_SCOPE
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt b/data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt
new file mode 100644
index 00000000..6e80a158
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt
@@ -0,0 +1,86 @@
+package org.monogram.data.notifications
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.db.model.NotificationSettingEntity
+import org.monogram.data.notifications.NotificationMuteDecision.Reason
+import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope
+
+class NotificationMuteResolver {
+
+ fun resolve(
+ chat: TdApi.Chat,
+ cachedSettings: NotificationSettingEntity?,
+ scopeState: NotificationScopeState
+ ): NotificationMuteDecision {
+ val muteFor = cachedSettings?.muteFor ?: chat.notificationSettings?.muteFor ?: 0
+ val useDefault =
+ cachedSettings?.useDefault ?: chat.notificationSettings?.useDefaultMuteFor ?: true
+
+ if (!useDefault) {
+ return if (muteFor > 0) {
+ NotificationMuteDecision(
+ isMuted = true,
+ scope = null,
+ reason = Reason.CHAT_MUTED,
+ muteFor = muteFor,
+ useDefault = false
+ )
+ } else {
+ NotificationMuteDecision(
+ isMuted = false,
+ scope = null,
+ reason = Reason.NOT_MUTED,
+ muteFor = muteFor,
+ useDefault = false
+ )
+ }
+ }
+
+ val scope = resolveScope(chat.type)
+ if (scope == null) {
+ return NotificationMuteDecision(
+ isMuted = muteFor > 0,
+ scope = null,
+ reason = if (muteFor > 0) Reason.CHAT_MUTED else Reason.UNKNOWN_SCOPE,
+ muteFor = muteFor,
+ useDefault = true
+ )
+ }
+
+ if (!scopeState.loadedScopes.contains(scope)) {
+ return NotificationMuteDecision(
+ isMuted = false,
+ scope = scope,
+ reason = Reason.SCOPE_NOT_LOADED,
+ muteFor = muteFor,
+ useDefault = true
+ )
+ }
+
+ val enabled = scopeState.enabledByScope[scope] ?: true
+ return if (enabled) {
+ NotificationMuteDecision(
+ isMuted = false,
+ scope = scope,
+ reason = Reason.NOT_MUTED,
+ muteFor = muteFor,
+ useDefault = true
+ )
+ } else {
+ NotificationMuteDecision(
+ isMuted = true,
+ scope = scope,
+ reason = Reason.SCOPE_DISABLED,
+ muteFor = muteFor,
+ useDefault = true
+ )
+ }
+ }
+
+ private fun resolveScope(chatType: TdApi.ChatType?): TdNotificationScope? = when (chatType) {
+ is TdApi.ChatTypePrivate -> TdNotificationScope.PRIVATE_CHATS
+ is TdApi.ChatTypeBasicGroup -> TdNotificationScope.GROUPS
+ is TdApi.ChatTypeSupergroup -> if (chatType.isChannel) TdNotificationScope.CHANNELS else TdNotificationScope.GROUPS
+ else -> null
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt b/data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt
new file mode 100644
index 00000000..b80dc9ba
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt
@@ -0,0 +1,8 @@
+package org.monogram.data.notifications
+
+import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope
+
+data class NotificationScopeState(
+ val loadedScopes: Set,
+ val enabledByScope: Map
+)
diff --git a/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt
index 8d091d97..33b1cb4f 100644
--- a/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt
+++ b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt
@@ -4,12 +4,17 @@ import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.infra.ConnectionManager
import java.util.concurrent.atomic.AtomicLong
class PushSyncTrigger(
- private val connectionManager: ConnectionManager
+ private val connectionManager: ConnectionManager,
+ private val gateway: TelegramGateway
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val lastSyncAt = AtomicLong(0L)
@@ -17,17 +22,41 @@ class PushSyncTrigger(
fun requestSync(reason: String) {
val now = System.currentTimeMillis()
val previous = lastSyncAt.get()
- if (now - previous < MIN_SYNC_GAP_MS) return
- if (!lastSyncAt.compareAndSet(previous, now)) return
+ if (now - previous < MIN_SYNC_GAP_MS) {
+ Log.d(TAG, "Skip push sync by rate limit: reason=$reason")
+ return
+ }
+ if (!lastSyncAt.compareAndSet(previous, now)) {
+ Log.d(TAG, "Skip push sync by CAS race: reason=$reason")
+ return
+ }
scope.launch {
- Log.d(TAG, "Triggering TDLib sync from push: $reason")
+ if (!gateway.isAuthenticated.value) {
+ Log.d(TAG, "Skip push sync: not authenticated, reason=$reason")
+ return@launch
+ }
+
+ Log.d(TAG, "Triggering TDLib sync from push: reason=$reason")
connectionManager.retryConnection()
+
+ delay(PUSH_SYNC_DELAY_MS)
+
+ val me = withTimeoutOrNull(REQUEST_TIMEOUT_MS) {
+ runCatching { gateway.execute(TdApi.GetMe()) }.getOrNull()
+ }
+ if (me != null) {
+ Log.d(TAG, "Push sync probe success: me=${me.id}")
+ } else {
+ Log.w(TAG, "Push sync probe failed (GetMe timeout/error)")
+ }
}
}
private companion object {
const val TAG = "PushSyncTrigger"
const val MIN_SYNC_GAP_MS = 1500L
+ const val PUSH_SYNC_DELAY_MS = 350L
+ const val REQUEST_TIMEOUT_MS = 5000L
}
}
diff --git a/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt
index d1143ce5..1cb5b33b 100644
--- a/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt
+++ b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt
@@ -41,31 +41,43 @@ class UnifiedPushManager(
val distributors = getDistributors()
val ack = getAckDistributor()
if (!ack.isNullOrBlank() && distributors.contains(ack)) {
+ Log.d(TAG, "Select distributor from ack: $ack")
return ack
}
val saved = getSavedDistributor()
if (!saved.isNullOrBlank() && distributors.contains(saved)) {
+ Log.d(TAG, "Select distributor from saved: $saved")
return saved
}
val resolved = UnifiedPush.resolveDefaultDistributor(context)
if (resolved is ResolvedDistributor.Found && distributors.contains(resolved.packageName)) {
+ Log.d(TAG, "Select distributor from default resolver: ${resolved.packageName}")
return resolved.packageName
}
- return distributors.firstOrNull()
+ val first = distributors.firstOrNull()
+ if (first != null) {
+ Log.d(TAG, "Select distributor fallback first installed: $first")
+ }
+ return first
}
fun ensureRegistered(force: Boolean = false): Boolean {
val distributor = currentDistributor() ?: return false
val endpointKnown = !_endpoint.value.isNullOrBlank()
if (!force && endpointKnown && _status.value == Status.REGISTERED && !shouldRefreshRegistration()) {
+ Log.d(TAG, "Skip re-register: endpoint already known and fresh")
return true
}
_status.value = Status.REGISTERING
markRegistrationAttempt()
+ Log.d(
+ TAG,
+ "Request UnifiedPush register: distributor=$distributor force=$force endpointKnown=$endpointKnown"
+ )
return runCatching {
UnifiedPush.saveDistributor(context, distributor)
@@ -103,6 +115,7 @@ class UnifiedPushManager(
.apply()
_endpoint.value = value
_status.value = Status.REGISTERED
+ Log.d(TAG, "UnifiedPush endpoint saved: ${value.take(140)}")
}
fun onRegistrationFailed(reason: FailedReason?) {
diff --git a/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt
index db2e0f00..15244aa4 100644
--- a/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt
+++ b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt
@@ -18,21 +18,50 @@ class UnifiedPushService : PushService(), KoinComponent {
private val appPreferences: AppPreferencesProvider by inject()
override fun onMessage(message: PushMessage, instance: String) {
- if (!isUnifiedPushSelected()) return
- pushSyncTrigger.requestSync("unified_push_message")
+ val payload =
+ runCatching { message.content.toString(Charsets.UTF_8) }.getOrDefault("")
+ Log.d(
+ TAG,
+ "onMessage instance=$instance decrypted=${message.decrypted} bytes=${message.content.size} payload=${
+ payload.take(
+ 96
+ )
+ }"
+ )
+
+ if (!isUnifiedPushSelected()) {
+ Log.d(TAG, "Ignore UnifiedPush message: provider is not UnifiedPush")
+ return
+ }
+
+ val reason =
+ if (payload.startsWith("version=")) "unified_push_telegram_simple" else "unified_push_message"
+ pushSyncTrigger.requestSync(reason)
}
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
- if (!isUnifiedPushSelected()) return
+ Log.d(
+ TAG,
+ "onNewEndpoint instance=$instance temporary=${endpoint.temporary} hasPubKeySet=${endpoint.pubKeySet != null} url=${
+ endpoint.url.take(
+ 120
+ )
+ }"
+ )
+
unifiedPushManager.onNewEndpoint(endpoint)
+
+ if (!isUnifiedPushSelected()) return
pushSyncTrigger.requestSync("unified_push_new_endpoint")
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
+ Log.w(TAG, "onRegistrationFailed instance=$instance reason=$reason")
unifiedPushManager.onRegistrationFailed(reason)
}
override fun onUnregistered(instance: String) {
+ Log.w(TAG, "onUnregistered instance=$instance")
unifiedPushManager.onUnregistered()
}
From b2875581ee7c1da68d15ada675b134c22fd4c863 Mon Sep 17 00:00:00 2001
From: Artur Skubei <41114720+gdlbo@users.noreply.github.com>
Date: Mon, 13 Apr 2026 01:39:05 +0300
Subject: [PATCH 3/3] consolidate debug settings items into a single list item
- remove individual `item` wrappers for database and preference settings in `DebugContent`
- group related debug actions under a single `item` block to improve list structure
- ensure consistent bottom spacing by moving the `Spacer` inside the consolidated item block
---
.../presentation/settings/debug/DebugContent.kt | 13 +++++--------
1 file changed, 5 insertions(+), 8 deletions(-)
diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt
index 3ee0a315..b7cd2f97 100644
--- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt
@@ -340,8 +340,7 @@ fun DebugContent(component: DebugComponent) {
position = ItemPosition.TOP,
onClick = component::onCrashClicked
)
- }
- item {
+
SettingsItem(
icon = Icons.Rounded.Storage,
title = "Drop Databases",
@@ -350,8 +349,7 @@ fun DebugContent(component: DebugComponent) {
position = ItemPosition.MIDDLE,
onClick = component::onDropDatabasesClicked
)
- }
- item {
+
SettingsItem(
icon = Icons.Rounded.Storage,
title = "Drop Cache Database",
@@ -360,8 +358,7 @@ fun DebugContent(component: DebugComponent) {
position = ItemPosition.MIDDLE,
onClick = component::onDropDatabaseCacheClicked
)
- }
- item {
+
SettingsItem(
icon = Icons.Rounded.DeleteSweep,
title = "Drop Cache",
@@ -370,8 +367,7 @@ fun DebugContent(component: DebugComponent) {
position = ItemPosition.MIDDLE,
onClick = component::onDropCachePrefsClicked
)
- }
- item {
+
SettingsItem(
icon = Icons.Rounded.Delete,
title = "Drop Prefs",
@@ -380,6 +376,7 @@ fun DebugContent(component: DebugComponent) {
position = ItemPosition.BOTTOM,
onClick = component::onDropPrefsClicked
)
+
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}