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)
+ }
+}