From d2351476a2194986eff429b550737e9bfd0ddd40 Mon Sep 17 00:00:00 2001 From: tomastiminskas Date: Tue, 2 Jun 2026 16:23:31 +0000 Subject: [PATCH] Generated with Hive: Add RestoreForegroundService for persistent account restore in background --- settings.gradle | 1 + .../sphinx/src/main/AndroidManifest.xml | 1 + .../screens/dashboard/dashboard/build.gradle | 1 + .../sphinx/dashboard/ui/DashboardFragment.kt | 49 +++++ .../build.gradle | 40 ++++ .../consumer-rules.pro | 0 .../proguard-rules.pro | 1 + .../src/main/AndroidManifest.xml | 15 ++ .../RestoreForegroundService.kt | 171 ++++++++++++++++++ .../RestoreNotification.kt | 87 +++++++++ 10 files changed, 366 insertions(+) create mode 100644 sphinx/service/features/restore/feature-service-restore-android/build.gradle create mode 100644 sphinx/service/features/restore/feature-service-restore-android/consumer-rules.pro create mode 100644 sphinx/service/features/restore/feature-service-restore-android/proguard-rules.pro create mode 100644 sphinx/service/features/restore/feature-service-restore-android/src/main/AndroidManifest.xml create mode 100644 sphinx/service/features/restore/feature-service-restore-android/src/main/java/chat/sphinx/feature_service_restore_android/RestoreForegroundService.kt create mode 100644 sphinx/service/features/restore/feature-service-restore-android/src/main/java/chat/sphinx/feature_service_restore_android/RestoreNotification.kt diff --git a/settings.gradle b/settings.gradle index e265afae..972b209d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -187,6 +187,7 @@ include ':sphinx:service:features:media-player:feature-service-media-player-andr include ':sphinx:service:concepts:concept-service-notification' include ':sphinx:service:features:notifications:feature-service-notification-empty' include ':sphinx:service:features:notifications:feature-service-notification-firebase' +include ':sphinx:service:features:restore:feature-service-restore-android' include ':sphinx:service:features:feature-sphinx-service' // Testing diff --git a/sphinx/application/sphinx/src/main/AndroidManifest.xml b/sphinx/application/sphinx/src/main/AndroidManifest.xml index d424925d..dd13021c 100644 --- a/sphinx/application/sphinx/src/main/AndroidManifest.xml +++ b/sphinx/application/sphinx/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + = Build.VERSION_CODES.TIRAMISU) { + requireContext().registerReceiver(restoreCancelledReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + requireContext().registerReceiver(restoreCancelledReceiver, filter) + } + restoreCancelledReceiverRegistered = true + } + onStopSupervisor.scope.launch(viewModel.mainImmediate) { viewModel.newVersionAvailable.asStateFlow().collect { newVersionAvailable -> binding.layoutDashboardHeader.textViewDashboardHeaderUpgradeApp.goneIfFalse( @@ -620,6 +659,15 @@ internal class DashboardFragment : MotionLayoutFragment< viewModel.restoreProgressStateFlow.collect { response -> binding.layoutDashboardRestore.apply { if (response != null && response < 100 && !isRestoreCancelled) { + // Start the foreground service to keep restore alive in background + if (!isRestoreServiceRunning) { + isRestoreServiceRunning = true + ContextCompat.startForegroundService( + requireContext(), + Intent(requireContext(), RestoreForegroundService::class.java) + ) + } + layoutDashboardRestoreProgress.apply { val progressString = "${response}%" val label = if (response <= 10) { @@ -637,6 +685,7 @@ internal class DashboardFragment : MotionLayoutFragment< } root.visible } else { + isRestoreServiceRunning = false viewModel.fetchDeletedMessagesOnDb() if (response != null) { diff --git a/sphinx/service/features/restore/feature-service-restore-android/build.gradle b/sphinx/service/features/restore/feature-service-restore-android/build.gradle new file mode 100644 index 00000000..8c3a869a --- /dev/null +++ b/sphinx/service/features/restore/feature-service-restore-android/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'com.android.library' + id 'dagger.hilt.android.plugin' + id 'kotlin-android' + id 'kotlin-kapt' +} + +android { + compileSdkVersion versions.compileSdk + buildToolsVersion versions.buildTools + + defaultConfig { + minSdkVersion versions.minSdk + targetSdkVersion versions.targetSdk + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments disableAnalytics: 'true' + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + } + } + namespace 'chat.sphinx.feature_service_restore_android' +} + +dependencies { + implementation fileTree(dir: "libs", include: ["*.jar"]) + + // Sphinx + api project(path: ':sphinx:application:common:logger') + api project(path: ':sphinx:application:common:resources') + api project(path: ':sphinx:service:features:feature-sphinx-service') + api project(path: ':sphinx:application:data:concepts:repositories:concept-repository-connect-manager') + + implementation deps.google.hilt + kapt kaptDeps.google.hilt +} diff --git a/sphinx/service/features/restore/feature-service-restore-android/consumer-rules.pro b/sphinx/service/features/restore/feature-service-restore-android/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/sphinx/service/features/restore/feature-service-restore-android/proguard-rules.pro b/sphinx/service/features/restore/feature-service-restore-android/proguard-rules.pro new file mode 100644 index 00000000..fb164d66 --- /dev/null +++ b/sphinx/service/features/restore/feature-service-restore-android/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/sphinx/service/features/restore/feature-service-restore-android/src/main/AndroidManifest.xml b/sphinx/service/features/restore/feature-service-restore-android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..cb15ddd8 --- /dev/null +++ b/sphinx/service/features/restore/feature-service-restore-android/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/sphinx/service/features/restore/feature-service-restore-android/src/main/java/chat/sphinx/feature_service_restore_android/RestoreForegroundService.kt b/sphinx/service/features/restore/feature-service-restore-android/src/main/java/chat/sphinx/feature_service_restore_android/RestoreForegroundService.kt new file mode 100644 index 00000000..89e959f1 --- /dev/null +++ b/sphinx/service/features/restore/feature-service-restore-android/src/main/java/chat/sphinx/feature_service_restore_android/RestoreForegroundService.kt @@ -0,0 +1,171 @@ +package chat.sphinx.feature_service_restore_android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.PowerManager +import chat.sphinx.concept_repository_connect_manager.ConnectManagerRepository +import chat.sphinx.feature_sphinx_service.ApplicationServiceTracker +import chat.sphinx.feature_sphinx_service.SphinxService +import chat.sphinx.logger.SphinxLogger +import chat.sphinx.logger.d +import dagger.hilt.android.AndroidEntryPoint +import io.matthewnelson.concept_coroutines.CoroutineDispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class RestoreForegroundService : SphinxService() { + + companion object { + const val TAG = "RestoreForegroundService" + + /** + * Broadcast sent by the service when the Cancel action is tapped in the notification. + * [DashboardFragment] listens for this and calls [viewModel.cancelRestore()]. + */ + const val ACTION_RESTORE_CANCELLED = "chat.sphinx.ACTION_RESTORE_CANCELLED" + } + + override val mustComplete: Boolean + get() = true + + @Inject + @Suppress("PropertyName", "ProtectedInFinal") + protected lateinit var _applicationServiceTracker: ApplicationServiceTracker + + override val applicationServiceTracker: ApplicationServiceTracker + get() = _applicationServiceTracker + + @Inject + @Suppress("PropertyName", "ProtectedInFinal") + protected lateinit var _dispatchers: CoroutineDispatchers + + override val dispatchers: CoroutineDispatchers + get() = _dispatchers + + @Inject + @Suppress("PropertyName", "ProtectedInFinal") + protected lateinit var _connectManagerRepository: ConnectManagerRepository + + @Inject + @Suppress("PropertyName", "ProtectedInFinal") + protected lateinit var _LOG: SphinxLogger + + private val LOG: SphinxLogger + get() = _LOG + + private val notification: RestoreNotification by lazy { + RestoreNotification(this) + } + + private var wakeLock: PowerManager.WakeLock? = null + private var cancelReceiverRegistered = false + + private val cancelReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == RestoreNotification.ACTION_CANCEL_RESTORE) { + LOG.d(TAG, "Cancel broadcast received — broadcasting ACTION_RESTORE_CANCELLED") + // Notify DashboardFragment via a package-scoped broadcast + sendBroadcast( + Intent(ACTION_RESTORE_CANCELLED).apply { + setPackage(packageName) + } + ) + stopRestoreService(reason = "cancelled") + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + LOG.d(TAG, "Service started") + + // Acquire partial wake lock (30-minute safety cap) + (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.let { pm -> + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "sphinx:RestoreWakeLock" + ).also { lock -> + lock.acquire(30 * 60 * 1000L) + LOG.d(TAG, "WakeLock acquired") + } + } + + // Must call startForeground within onStartCommand to avoid ForegroundServiceDidNotStartInTimeException + val initialNotification = notification.buildInitialNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + RestoreNotification.NOTIFICATION_ID, + initialNotification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(RestoreNotification.NOTIFICATION_ID, initialNotification) + } + + // Register receiver for notification Cancel action + val filter = IntentFilter(RestoreNotification.ACTION_CANCEL_RESTORE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(cancelReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(cancelReceiver, filter) + } + cancelReceiverRegistered = true + + // Observe restore progress from the repository + serviceLifecycleScope.launch { + _connectManagerRepository.restoreProgress.collect { progress -> + when { + progress == null || progress >= 100 -> { + LOG.d(TAG, "Restore finished (progress=$progress) — stopping service") + stopRestoreService(reason = if (progress != null) "completed" else "null-progress") + } + else -> { + notification.updateNotification(progress) + } + } + } + } + + return START_NOT_STICKY + } + + private fun stopRestoreService(reason: String) { + releaseWakeLock() + notification.clear() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + LOG.d(TAG, "Service stopped — reason: $reason") + stopSelf() + } + + private fun releaseWakeLock() { + wakeLock?.let { lock -> + if (lock.isHeld) { + lock.release() + LOG.d(TAG, "WakeLock released") + } + } + wakeLock = null + } + + override fun onDestroy() { + if (cancelReceiverRegistered) { + try { + unregisterReceiver(cancelReceiver) + } catch (e: IllegalArgumentException) { + // Receiver was not registered — safe to ignore + } + cancelReceiverRegistered = false + } + releaseWakeLock() + super.onDestroy() + } +} diff --git a/sphinx/service/features/restore/feature-service-restore-android/src/main/java/chat/sphinx/feature_service_restore_android/RestoreNotification.kt b/sphinx/service/features/restore/feature-service-restore-android/src/main/java/chat/sphinx/feature_service_restore_android/RestoreNotification.kt new file mode 100644 index 00000000..28a63b0c --- /dev/null +++ b/sphinx/service/features/restore/feature-service-restore-android/src/main/java/chat/sphinx/feature_service_restore_android/RestoreNotification.kt @@ -0,0 +1,87 @@ +package chat.sphinx.feature_service_restore_android + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import chat.sphinx.resources.R as R_common + +internal class RestoreNotification( + private val context: Context +) { + + companion object { + internal const val NOTIFICATION_ID = 2025 + private const val CHANNEL_ID = "SphinxRestoreService" + private const val CHANNEL_DESCRIPTION = "Shows account restore progress for Sphinx Chat" + + internal const val ACTION_CANCEL_RESTORE = "chat.sphinx.feature_service_restore_android.ACTION_CANCEL_RESTORE" + private const val ACTION_CANCEL_CODE = 101 + } + + private val notificationManager: NotificationManager? + get() = context.applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + + init { + setupNotificationChannel() + } + + private fun setupNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_ID, + NotificationManager.IMPORTANCE_LOW + ).apply { + description = CHANNEL_DESCRIPTION + setSound(null, null) + } + notificationManager?.createNotificationChannel(channel) + } + } + + fun buildInitialNotification(): android.app.Notification { + return buildNotificationBuilder(0).build() + } + + fun updateNotification(progress: Int) { + val notification = buildNotificationBuilder(progress).build() + notificationManager?.notify(NOTIFICATION_ID, notification) + } + + private fun buildNotificationBuilder(progress: Int): NotificationCompat.Builder { + val contentText = when { + progress <= 10 -> "Restoring contacts… $progress%" + else -> "Restoring messages… $progress%" + } + + val cancelIntent = Intent(ACTION_CANCEL_RESTORE).apply { + setPackage(context.packageName) + } + val cancelPendingIntent = PendingIntent.getBroadcast( + context, + ACTION_CANCEL_CODE, + cancelIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(context.applicationContext, CHANNEL_ID) + .setContentTitle("Restoring Account") + .setContentText(contentText) + .setSmallIcon(R_common.drawable.sphinx_white_notification) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setSound(null) + .setProgress(100, progress, false) + .addAction(0, "Cancel", cancelPendingIntent) + } + + fun clear() { + notificationManager?.cancel(NOTIFICATION_ID) + } +}