Добавлен foreground сервис

This commit is contained in:
2026-04-18 21:38:09 +03:00
parent db9463c2c6
commit d806e3a8a1
33 changed files with 972 additions and 36 deletions

View File

@@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -24,6 +28,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".tasks.TaskPipelineForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View File

@@ -1,9 +1,14 @@
package com.github.nullptroma.wallenc.app
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.github.nullptroma.wallenc.presentation.WallencUi
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
@@ -15,6 +20,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermissionIfNeeded()
Timber.plant(Timber.DebugTree())
// val sdk = YandexAuthSdk.create(YandexAuthOptions(applicationContext, true))
@@ -27,6 +33,24 @@ class MainActivity : ComponentActivity() {
}
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (granted) return
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
}
companion object {
private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 100
}
// private fun handleResult(result: YandexAuthResult) {
// when (result) {
// is YandexAuthResult.Success -> Toast.makeText(applicationContext, "Success: ${result.token}", Toast.LENGTH_SHORT).show()

View File

@@ -1,7 +1,18 @@
package com.github.nullptroma.wallenc.app
import android.app.Application
import com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class WallencApplication : Application()
class WallencApplication : Application() {
@Inject
lateinit var taskPipelineForegroundBootstrap: TaskPipelineForegroundBootstrap
override fun onCreate() {
super.onCreate()
taskPipelineForegroundBootstrap.start()
}
}

View File

@@ -7,9 +7,11 @@ import com.github.nullptroma.wallenc.data.db.app.dao.StorageMetaInfoDao
import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository
import com.github.nullptroma.wallenc.data.db.app.repository.StorageMetaInfoRepository
import com.github.nullptroma.wallenc.data.storages.UnlockManager
import com.github.nullptroma.wallenc.data.tasks.TaskOrchestrator
import com.github.nullptroma.wallenc.data.vaults.VaultsManager
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -21,6 +23,12 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class SingletonModule {
@Provides
@Singleton
fun provideTaskOrchestrator(
@IoDispatcher ioDispatcher: CoroutineDispatcher,
): ITaskOrchestrator = TaskOrchestrator(ioDispatcher)
@Provides
@Singleton
fun provideVaultsManager(

View File

@@ -0,0 +1,35 @@
package com.github.nullptroma.wallenc.app.tasks
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TaskPipelineForegroundBootstrap @Inject constructor(
@ApplicationContext private val app: Context,
private val orchestrator: ITaskOrchestrator,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun start() {
scope.launch {
orchestrator.foregroundUi.collect { ui ->
if (ui is TaskForegroundUiState.Visible) {
ContextCompat.startForegroundService(
app,
Intent(app, TaskPipelineForegroundService::class.java),
)
}
}
}
}
}

View File

@@ -0,0 +1,172 @@
package com.github.nullptroma.wallenc.app.tasks
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.github.nullptroma.wallenc.app.R
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class TaskPipelineForegroundService : Service() {
@Inject
lateinit var orchestrator: ITaskOrchestrator
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(serviceJob + Dispatchers.Main.immediate)
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
ensureChannel()
startForeground(NOTIFICATION_ID, buildPlaceholderNotification())
serviceScope.launch {
var sawVisible = false
var pendingUi: TaskForegroundUiState.Visible? = null
var lastNotificationAtMs = 0L
var delayedFlushJob: Job? = null
val nm = getSystemService(NotificationManager::class.java)
fun pushVisible(ui: TaskForegroundUiState.Visible) {
val notification = buildProgressNotification(ui.title, ui.progress)
nm.notify(NOTIFICATION_ID, notification)
lastNotificationAtMs = System.currentTimeMillis()
}
orchestrator.foregroundUi.collect { ui ->
when (ui) {
is TaskForegroundUiState.Visible -> {
sawVisible = true
pendingUi = ui
val now = System.currentTimeMillis()
val elapsed = now - lastNotificationAtMs
if (elapsed >= MIN_NOTIFICATION_UPDATE_INTERVAL_MS) {
delayedFlushJob?.cancel()
delayedFlushJob = null
pushVisible(ui)
pendingUi = null
} else if (delayedFlushJob == null) {
delayedFlushJob = serviceScope.launch {
delay(MIN_NOTIFICATION_UPDATE_INTERVAL_MS - elapsed)
pendingUi?.let { last ->
pushVisible(last)
pendingUi = null
}
delayedFlushJob = null
}
}
}
TaskForegroundUiState.Hidden -> {
if (sawVisible) {
delayedFlushJob?.cancel()
delayedFlushJob = null
pendingUi?.let { last ->
// Flush latest state before removing notification.
pushVisible(last)
pendingUi = null
}
sawVisible = false
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL_ALL) {
orchestrator.cancelAll()
}
return START_NOT_STICKY
}
override fun onDestroy() {
serviceScope.cancel()
super.onDestroy()
}
private fun ensureChannel() {
val nm = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.task_notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
)
nm.createNotificationChannel(channel)
}
private fun buildPlaceholderNotification(): Notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.task_notification_title))
.setContentText(getString(R.string.task_notification_preparing))
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
private fun buildProgressNotification(title: String, progress: TaskProgress?): Notification {
val cancelIntent = Intent(this, TaskPipelineForegroundService::class.java).apply {
action = ACTION_CANCEL_ALL
}
val cancelPending = PendingIntent.getService(
this,
1,
cancelIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
getString(R.string.task_notification_cancel),
cancelPending,
)
val label = progress?.label
val fraction = progress?.fraction
if (fraction != null) {
val pct = (fraction.coerceIn(0f, 1f) * 100).roundToInt()
builder.setContentText(label ?: "$pct%")
builder.setProgress(100, pct, false)
} else {
builder.setContentText(label ?: getString(R.string.task_notification_indeterminate))
builder.setProgress(0, 0, true)
}
return builder.build()
}
companion object {
private const val CHANNEL_ID = "wallenc_task_pipeline"
private const val NOTIFICATION_ID = 1001
private const val MIN_NOTIFICATION_UPDATE_INTERVAL_MS = 500L
const val ACTION_CANCEL_ALL = "com.github.nullptroma.wallenc.CANCEL_ALL_TASKS"
}
}

View File

@@ -1,3 +1,8 @@
<resources>
<string name="app_name">Wallenc</string>
<string name="task_notification_channel_name">Background tasks</string>
<string name="task_notification_title">Wallenc tasks</string>
<string name="task_notification_preparing">Preparing…</string>
<string name="task_notification_indeterminate">Working…</string>
<string name="task_notification_cancel">Cancel</string>
</resources>