Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add ForegroundService API, and other extensions, needed for copy-free-upload #305

Merged
merged 12 commits into from
Mar 5, 2025
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@
package com.infomaniak.core.compose.basics

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch

fun <T> Flow<T>.withForwardTo(onNewValue: (T) -> Unit, defaultValue: T): Flow<T> = flow {
try {
Expand All @@ -42,3 +47,23 @@ fun <T> Flow<T>.withForwardTo(state: MutableState<T>, defaultValue: T): Flow<T>
state.value = defaultValue
}
}

fun <T> StateFlow<T>.collectAsStateIn(scope: CoroutineScope): State<T> {
return mutableStateOf(value).also { state ->
scope.launch {
collect { newValue ->
state.value = newValue
}
}
}
}

fun <T> Flow<T>.collectAsStateIn(scope: CoroutineScope, initialValue: T): State<T> {
return mutableStateOf(initialValue).also { state ->
scope.launch {
collect { newValue ->
state.value = newValue
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class NetworkAvailability(private val context: Context, private val ioDispatcher
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
connectivityManager.activeNetwork?.let(::hasInternetConnectivity) ?: false
} else {
@Suppress("deprecation")
connectivityManager.activeNetworkInfo?.isConnected ?: false
}
}
Expand Down
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ dependencies {
api(core.splitties.appctx)
api(core.splitties.systemservices)
api(core.splitties.coroutines)
api(core.androidx.lifecycle.service)
api(core.splitties.intents)
implementation(core.splitties.bitflags)
implementation(core.splitties.toast)
implementation(core.splitties.bundle)
implementation(core.splitties.mainhandler)
implementation(core.splitties.mainthread)
implementation(core.androidx.core)
Expand Down
4 changes: 4 additions & 0 deletions gradle/core.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ splitties = "3.0.0"
[libraries]
androidx-core = { group = "androidx.core", name = "core", version.ref = "androidxCore" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" }
Expand Down Expand Up @@ -55,8 +56,11 @@ room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
sentry-android = { module = "io.sentry:sentry-android", version.ref = "sentryAndroid" }
splitties-appctx = { module = "com.louiscad.splitties:splitties-appctx", version.ref = "splitties" }
splitties-bitflags = { module = "com.louiscad.splitties:splitties-bitflags", version.ref = "splitties" }
splitties-bundle = { module = "com.louiscad.splitties:splitties-bundle", version.ref = "splitties" }
splitties-collections = { module = "com.louiscad.splitties:splitties-collections", version.ref = "splitties" }
splitties-coroutines = { module = "com.louiscad.splitties:splitties-coroutines", version.ref = "splitties" }
splitties-intents = { module = "com.louiscad.splitties:splitties-intents", version.ref = "splitties" }
splitties-mainhandler = { module = "com.louiscad.splitties:splitties-mainhandler", version.ref = "splitties" }
splitties-mainthread = { module = "com.louiscad.splitties:splitties-mainthread", version.ref = "splitties" }
splitties-systemservices = { module = "com.louiscad.splitties:splitties-systemservices", version.ref = "splitties" }
Expand Down
31 changes: 31 additions & 0 deletions src/main/kotlin/com/infomaniak/core/Flow.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Infomaniak SwissTransfer - Android
* Copyright (C) 2025 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)

package com.infomaniak.core

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.transformLatest

suspend fun <R> Flow<Boolean>.tryCompletingWhileTrue(
block: suspend () -> R
): R = transformLatest {
if (it) emit(block())
}.first()
236 changes: 236 additions & 0 deletions src/main/kotlin/com/infomaniak/core/ForegroundService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/*
* Infomaniak SwissTransfer - Android
* Copyright (C) 2025 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

@file:Suppress("ObsoleteSdkInt")
@file:OptIn(ExperimentalSplittiesApi::class)

package com.infomaniak.core

import android.app.Notification
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build.VERSION.SDK_INT
import android.os.IBinder
import androidx.annotation.CallSuper
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import splitties.bitflags.hasFlag
import splitties.bundle.BundleSpec
import splitties.coroutines.raceOf
import splitties.experimental.ExperimentalSplittiesApi
import splitties.init.appCtx
import splitties.intents.ServiceIntentSpec
import splitties.intents.intent
import splitties.mainhandler.mainHandler
import splitties.mainthread.isMainThread

/**
* Usage:
* ```
* class MyForegroundService : ForegroundService(Companion) {
*
* companion object : ForegroundService.Companion.NoExtras<MyForegroundService>(
* intentSpec = serviceWithoutExtrasSpec<MyForegroundService>(),
* notificationId = MyNotificationController.Ids.MY_FG_SERVICE
* )
*
* override suspend fun run() {
* TODO("Call startForeground with a notification and run a coroutine.")
* }
*
* override fun getStartNotification(intent: Intent, isReDelivery: Boolean): Notification {
* TODO("Return the initial notification")
* }
* }
* ```
*/
abstract class ForegroundService(
private val companion: Companion<*, *>,
private val redeliverIntentIfKilled: Boolean
) : LifecycleService() {

@Retention(AnnotationRetention.BINARY)
@RequiresOptIn(level = RequiresOptIn.Level.ERROR)
private annotation class InternalApi

private val timeoutAsync = CompletableDeferred<TimeoutCancellationException>()
private var foregroundStarted = false

/**
* `stopSelf()` is automatically called on this Service once this function finishes its
* execution.
*/
open suspend fun run() {
awaitCancellation()
}

/**
* Called when [onStartCommand] is called.
* This function must return the id and the notification that will be used to call [startForeground].
*/
protected abstract fun getStartNotification(intent: Intent, isReDelivery: Boolean): Notification

@OptIn(InternalApi::class)
fun updateNotification(notification: Notification) {
if (isMainThread.not()) {
mainHandler.post { updateNotification(notification) }
return
}
startForegroundWithType(companion.notificationId, notification)
}

/**
* Return another constant from the [ServiceInfo] class if you don't want to default to the one
* provided in the `AndroidManifest.xml` file.
*/
@RequiresApi(29)
protected open fun foregroundServiceType(): Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST

class TimeoutCancellationException(
val fgsType: Int
) : CancellationException("timeout reached for foreground service type: $fgsType")

@CallSuper // Not final to allow Dagger/Hilt to be used by subclasses, if needed.
override fun onCreate() {
@OptIn(InternalApi::class)
companion.markCreated()
super.onCreate()
lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
try {
val job = launch { run() }
raceOf(
{ job.join() },
{ job.cancel(timeoutAsync.await()) }
)
} finally {
stopSelf()
}
}
}

@RequiresApi(35)
@CallSuper
override fun onTimeout(startId: Int, fgsType: Int) {
timeoutAsync.complete(TimeoutCancellationException(fgsType))
}

@RequiresApi(34)
@CallSuper
override fun onTimeout(startId: Int) {
timeoutAsync.complete(TimeoutCancellationException(foregroundServiceType))
}


@OptIn(InternalApi::class)
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
requireNotNull(intent) {
"Intent should never be null when using Service.START_REDELIVER_INTENT or Service.START_NOT_STICKY"
}
if (foregroundStarted.not()) {
val notification = getStartNotification(
intent = intent,
isReDelivery = flags.hasFlag(START_FLAG_REDELIVERY)
)
startForegroundWithType(companion.notificationId, notification)
}
return if (redeliverIntentIfKilled) START_REDELIVER_INTENT else START_NOT_STICKY
}

private fun startForegroundWithType(notificationId: Int, notification: Notification) {
if (SDK_INT >= 29) {
startForeground(notificationId, notification, foregroundServiceType())
} else {
startForeground(notificationId, notification)
}
foregroundStarted = true
}

final override fun onDestroy() {
@OptIn(InternalApi::class)
companion.markDestroyed()
if (SDK_INT >= 24) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("deprecation")
stopForeground(true)
}
super.onDestroy()
}

final override fun onBind(intent: Intent): IBinder? = super.onBind(intent)

abstract class Companion<S : ForegroundService, ExtrasSpec : BundleSpec>(
private val intentSpec: ServiceIntentSpec<S, ExtrasSpec>,
@property:InternalApi val notificationId: Int
) {

val isRunningFlow: StateFlow<Boolean>

abstract class NoExtras<S : ForegroundService>(
intentSpec: ServiceIntentSpec<S, Nothing>,
notificationId: Int
) : Companion<S, Nothing>(intentSpec, notificationId) {

suspend fun runUntilCancelled(): Nothing = try {
start()
awaitCancellation()
} finally {
stop()
}

@CallSuper
open fun start() = ContextCompat.startForegroundService(appCtx, intent)

private val intent: Intent = intentSpec.intent()
}

@CallSuper
open fun start(configIntent: Intent.(ServiceIntentSpec<S, ExtrasSpec>, ExtrasSpec) -> Unit) {
ContextCompat.startForegroundService(appCtx, intentSpec.intent(configIntent))
}

@CallSuper
open fun stop() = appCtx.stopService(intent)

@InternalApi // Discourage usage from same module.
internal fun markCreated() {
isRunningMutableStateFlow.value = true
}

@InternalApi // Discourage usage from same module.
internal fun markDestroyed() {
isRunningMutableStateFlow.value = false
}

private val intent: Intent = intentSpec.intent()
private val isRunningMutableStateFlow = MutableStateFlow(false).also {
isRunningFlow = it.asStateFlow()
}
}
}
25 changes: 25 additions & 0 deletions src/main/kotlin/com/infomaniak/core/Result.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Infomaniak SwissTransfer - Android
* Copyright (C) 2025 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.core

import kotlinx.coroutines.CancellationException

@Suppress("RedundantSuspendModifier")
suspend inline fun <T> Result<T>.cancellable(): Result<T> = onFailure {
if (it is CancellationException) throw it
}
Loading