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/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 dad7bc6a..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,10 @@ 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 import org.monogram.data.service.NotificationReplyReceiver @@ -62,7 +66,9 @@ 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 muteResolver: NotificationMuteResolver ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val notificationManager = NotificationManagerCompat.from(context) @@ -78,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" @@ -136,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 @@ -194,6 +202,18 @@ class TdNotificationManager( updatePushRegistration() } } + + 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() + } + } + } } private fun updateChatNotificationSettings(chatId: Long, settings: TdApi.ChatNotificationSettings) { @@ -214,6 +234,7 @@ class TdNotificationManager( when (appPreferences.pushProvider.value) { PushProvider.FCM -> { coRunCatching { + unifiedPushManager.unregister() val token = FirebaseMessaging.getInstance().token.await() gateway.execute( TdApi.RegisterDevice( @@ -221,17 +242,42 @@ class TdNotificationManager( longArrayOf() ) ) + Log.d(TAG, "RegisterDevice success for FCM") }.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() + ) + ) + Log.d( + TAG, + "RegisterDevice success for UnifiedPush endpoint=${endpoint.take(120)}" + ) + }.onFailure { Log.e(TAG, "UnifiedPush registration failed", it) } + } + PushProvider.GMS_LESS -> { coRunCatching { + unifiedPushManager.unregister() gateway.execute( TdApi.RegisterDevice( TdApi.DeviceTokenFirebaseCloudMessaging("", false), longArrayOf() ) ) + Log.d(TAG, "RegisterDevice success for GMS-less fallback") }.onFailure { Log.e(TAG, "GMS-less token registration failed", it) } } } @@ -269,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 (scope) { + TdNotificationScope.PRIVATE_CHATS -> appPreferences.setPrivateChatsNotifications( + enabled + ) - when (key) { - NotificationScopeKey.PRIVATE -> appPreferences.setPrivateChatsNotifications(enabled) - NotificationScopeKey.GROUPS -> appPreferences.setGroupsNotifications(enabled) - NotificationScopeKey.CHANNELS -> appPreferences.setChannelsNotifications(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) { @@ -353,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) { @@ -368,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) { @@ -392,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, @@ -465,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 } @@ -500,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) @@ -628,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 d1c19d29..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,9 @@ 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 import org.monogram.data.repository.AuthRepositoryImpl import org.monogram.data.repository.BotRepositoryImpl @@ -100,6 +103,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 +144,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 +490,10 @@ val dataModule = module { ) } + single { PushSyncTrigger(connectionManager = get(), gateway = get()) } + single { UnifiedPushManager(androidContext()) } + single { NotificationMuteResolver() } + single { ChatsListRepositoryImpl( remoteDataSource = get(), @@ -796,5 +805,27 @@ 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(), + 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 new file mode 100644 index 00000000..33b1cb4f --- /dev/null +++ b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt @@ -0,0 +1,62 @@ +package org.monogram.data.push + +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 gateway: TelegramGateway +) { + 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) { + 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 { + 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 new file mode 100644 index 00000000..1cb5b33b --- /dev/null +++ b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt @@ -0,0 +1,169 @@ +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)) { + 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 + } + + 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) + 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 + Log.d(TAG, "UnifiedPush endpoint saved: ${value.take(140)}") + } + + 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..15244aa4 --- /dev/null +++ b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt @@ -0,0 +1,80 @@ +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) { + 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) { + 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() + } + + 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..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 @@ -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,28 +320,27 @@ 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 ) - } - item { + SettingsItem( icon = Icons.Rounded.Storage, title = "Drop Databases", @@ -139,8 +349,7 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.MIDDLE, onClick = component::onDropDatabasesClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.Storage, title = "Drop Cache Database", @@ -149,8 +358,7 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.MIDDLE, onClick = component::onDropDatabaseCacheClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.DeleteSweep, title = "Drop Cache", @@ -159,8 +367,7 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.MIDDLE, onClick = component::onDropCachePrefsClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.Delete, title = "Drop Prefs", @@ -169,7 +376,24 @@ 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)