Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions sphinx/application/sphinx/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />

<application
android:name=".SphinxApp"
Expand Down
1 change: 1 addition & 0 deletions sphinx/screens/dashboard/dashboard/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ dependencies {
implementation project(path: ':sphinx:application:data:concepts:repositories:concept-repository-message')

api project(path: ':sphinx:service:concepts:concept-service-media-player')
implementation project(path: ':sphinx:service:features:restore:feature-service-restore-android')

implementation deps.androidx.swipeRefreshLayout
implementation deps.androidx.recyclerView
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package chat.sphinx.dashboard.ui

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
Expand Down Expand Up @@ -40,6 +44,7 @@ import chat.sphinx.wrapper_common.HideBalance
import chat.sphinx.wrapper_common.chat.PushNotificationLink
import chat.sphinx.wrapper_common.lightning.*
import chat.sphinx.wrapper_lightning.NodeBalance
import chat.sphinx.feature_service_restore_android.RestoreForegroundService
import chat.sphinx.wrapper_view.Px
import chat.sphinx.resources.R as R_common
import com.google.android.material.tabs.TabLayoutMediator
Expand Down Expand Up @@ -96,6 +101,18 @@ internal class DashboardFragment : MotionLayoutFragment<

var timeTrackerStart: Long = 0
private var isRestoreCancelled = false
private var isRestoreServiceRunning = false

private val restoreCancelledReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == RestoreForegroundService.ACTION_RESTORE_CANCELLED) {
isRestoreCancelled = true
isRestoreServiceRunning = false
viewModel.cancelRestore()
}
}
}
private var restoreCancelledReceiverRegistered = false

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Expand All @@ -114,6 +131,18 @@ internal class DashboardFragment : MotionLayoutFragment<
setupSignerManager()
}

override fun onStop() {
super.onStop()
if (restoreCancelledReceiverRegistered) {
try {
requireContext().unregisterReceiver(restoreCancelledReceiver)
} catch (e: IllegalArgumentException) {
// Not registered — safe to ignore
}
restoreCancelledReceiverRegistered = false
}
}

override fun onPause() {
super.onPause()

Expand Down Expand Up @@ -493,6 +522,16 @@ internal class DashboardFragment : MotionLayoutFragment<
override fun onStart() {
super.onStart()

if (!restoreCancelledReceiverRegistered) {
val filter = IntentFilter(RestoreForegroundService.ACTION_RESTORE_CANCELLED)
if (Build.VERSION.SDK_INT >= 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(
Expand Down Expand Up @@ -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) {
Expand All @@ -637,6 +685,7 @@ internal class DashboardFragment : MotionLayoutFragment<
}
root.visible
} else {
isRestoreServiceRunning = false
viewModel.fetchDeletedMessagesOnDb()

if (response != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Add project specific ProGuard rules here.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<application>
<service
android:name=".RestoreForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading