From 8376686461d8237b51eb04806a715844f1f37a6a Mon Sep 17 00:00:00 2001 From: StageGuard Date: Thu, 30 Jan 2025 16:12:23 +0800 Subject: [PATCH 01/48] extract torrent connection logic to `commonMain` --- app/android/src/main/kotlin/AndroidModules.kt | 3 +- app/android/src/main/kotlin/AniApplication.kt | 23 +- .../torrent/client/RemoteAnitorrentEngine.kt | 4 +- .../torrent/service/AniTorrentService.kt | 7 + .../torrent/service/ServiceNotification.kt | 2 +- .../service/TorrentServiceConnection.kt | 266 ++++-------------- .../AbstractTorrentServiceConnection.kt | 172 +++++++++++ 7 files changed, 247 insertions(+), 230 deletions(-) create mode 100644 app/shared/app-data/src/commonMain/kotlin/domain/torrent/AbstractTorrentServiceConnection.kt diff --git a/app/android/src/main/kotlin/AndroidModules.kt b/app/android/src/main/kotlin/AndroidModules.kt index d7dc45710f..2fc4b89791 100644 --- a/app/android/src/main/kotlin/AndroidModules.kt +++ b/app/android/src/main/kotlin/AndroidModules.kt @@ -39,6 +39,7 @@ import me.him188.ani.app.domain.torrent.TorrentEngineFactory import me.him188.ani.app.domain.torrent.TorrentManager import me.him188.ani.app.domain.torrent.client.RemoteAnitorrentEngine import me.him188.ani.app.domain.torrent.peer.PeerFilterSettings +import me.him188.ani.app.domain.torrent.service.AniTorrentService import me.him188.ani.app.domain.torrent.service.TorrentServiceConnection import me.him188.ani.app.navigation.BrowserNavigator import me.him188.ani.app.platform.AndroidPermissionManager @@ -230,7 +231,7 @@ fun getAndroidModules( runBlocking(Dispatchers.Main.immediate) { (context.findActivity() as? BaseComponentActivity)?.finishAffinity() context.startService( - Intent(context, TorrentServiceConnection.anitorrentServiceClass) + Intent(context, AniTorrentService.actualServiceClass) .apply { putExtra("stopService", true) }, ) exitProcess(status) diff --git a/app/android/src/main/kotlin/AniApplication.kt b/app/android/src/main/kotlin/AniApplication.kt index 73585ada5d..36aedfa629 100644 --- a/app/android/src/main/kotlin/AniApplication.kt +++ b/app/android/src/main/kotlin/AniApplication.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.launch import me.him188.ani.android.activity.MainActivity import me.him188.ani.app.domain.media.cache.MediaCacheNotificationTask import me.him188.ani.app.domain.torrent.TorrentManager +import me.him188.ani.app.domain.torrent.service.AniTorrentService import me.him188.ani.app.domain.torrent.service.TorrentServiceConnection import me.him188.ani.app.platform.AndroidLoggingConfigurator import me.him188.ani.app.platform.JvmLogHelper @@ -32,7 +33,6 @@ import me.him188.ani.app.platform.startCommonKoinModule import me.him188.ani.app.ui.settings.tabs.getLogsDir import me.him188.ani.app.ui.settings.tabs.media.DEFAULT_TORRENT_CACHE_DIR_NAME import me.him188.ani.utils.coroutines.IO_ -import me.him188.ani.utils.coroutines.childScope import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.logger import org.koin.android.ext.android.getKoin @@ -89,26 +89,11 @@ class AniApplication : Application() { val torrentServiceConnection = TorrentServiceConnection( this, onRequiredRestartService = { startAniTorrentService() }, - scope.childScope(CoroutineName("TorrentServiceConnection")), - lifecycle = ProcessLifecycleOwner.get().lifecycle, + scope.coroutineContext, ) if (FEATURE_USE_TORRENT_SERVICE) { - torrentServiceConnection.startLifecycleJob() - try { - startAniTorrentService() - torrentServiceConnection.startServiceResultWhileAppStartup = true - } catch (ex: Exception) { - if (ex.toString().contains("ForegroundServiceStartNotAllowedException")) { - torrentServiceConnection.startServiceResultWhileAppStartup = false - Log.e("AniApplication", "Failed to start torrent service at background, defer at activity start.") - } else { - // rethrow other exceptions, - // because catch (ex: ForegroundServiceStartNotAllowedException) needs API 31. - throw ex - } - } + ProcessLifecycleOwner.get().lifecycle.addObserver(torrentServiceConnection) } - instance = Instance() scope.launch(Dispatchers.IO_) { @@ -166,7 +151,7 @@ class AniApplication : Application() { private fun startAniTorrentService(): ComponentName? { return startForegroundService( - Intent(this, TorrentServiceConnection.anitorrentServiceClass).apply { + Intent(this, AniTorrentService.actualServiceClass).apply { putExtra("app_name", me.him188.ani.R.string.app_name) putExtra("app_service_title_text_idle", me.him188.ani.R.string.app_service_title_text_idle) putExtra("app_service_title_text_working", me.him188.ani.R.string.app_service_title_text_working) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt index 1552fc4bfd..7be11451a4 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 OpenAni and contributors. + * Copyright (C) 2024-2025 OpenAni and contributors. * * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. @@ -113,7 +113,7 @@ class RemoteAnitorrentEngine( } private suspend fun getBinderOrFail(): IRemoteAniTorrentEngine { - return connection.awaitBinder() + return connection.getBinder() } override fun close() { diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt index 6f103b04f7..bd8fc13d64 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt @@ -13,6 +13,7 @@ import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.os.Build import android.os.IBinder import android.os.PowerManager import android.os.Process @@ -225,6 +226,12 @@ sealed class AniTorrentService : LifecycleService() { companion object { const val INTENT_STARTUP = "me.him188.ani.android.ANI_TORRENT_SERVICE_STARTUP" + + val actualServiceClass = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + AniTorrentServiceApi34::class.java + } else { + AniTorrentServiceApiDefault::class.java + } } } diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt index b4a3eaa30f..6eb0ad54fe 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt @@ -39,7 +39,7 @@ class ServiceNotification( private val stopServiceIntent by lazy { PendingIntent.getService( context, 0, - Intent(context, TorrentServiceConnection.anitorrentServiceClass).apply { putExtra("stopService", true) }, + Intent(context, AniTorrentService.actualServiceClass).apply { putExtra("stopService", true) }, PendingIntent.FLAG_IMMUTABLE, ) } diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index 04acbf30b2..8fd66acba2 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -9,41 +9,29 @@ package me.him188.ani.app.domain.torrent.service -import android.app.ForegroundServiceStartNotAllowedException import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection -import android.os.Build import android.os.IBinder import androidx.core.content.ContextCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.atomicfu.locks.SynchronizedObject -import kotlinx.atomicfu.locks.synchronized -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import me.him188.ani.app.domain.torrent.AbstractTorrentServiceConnection import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine -import me.him188.ani.utils.logging.* +import me.him188.ani.utils.logging.debug +import me.him188.ani.utils.logging.error +import me.him188.ani.utils.logging.warn +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.resume import kotlin.time.Duration.Companion.minutes /** * 管理与 [AniTorrentService] 的连接并获取 [IRemoteAniTorrentEngine] 远程访问接口. - * 通过 [awaitBinder] 获取服务接口, 再启动完成并绑定之前将挂起协程. - * - * 连接管理由传入的 [lifecycle] 机制控制. 在 [ON_CREATE][Lifecycle.Event.ON_CREATE] - * 和 [ON_DESTROY][Lifecycle.Event.ON_DESTROY] 范围控制服务的绑定与解绑. - * 在 [ON_RESUME][Lifecycle.Event.ON_RESUME] 和 [ON_STOP][Lifecycle.Event.ON_STOP] - * 范围控制服务始终保持运行. + * 通过 [getBinder] 获取服务接口, 再启动完成并绑定之前将挂起协程. * * 服务连接控制依赖的 lifecycle 应当尽可能大, 所以应该使用 * [ProcessLifecycleOwner][androidx.lifecycle.ProcessLifecycleOwner] @@ -52,22 +40,6 @@ import kotlin.time.Duration.Companion.minutes * 的生命周期, 因为在屏幕旋转 (例如竖屏转全屏播放) 的时候 Activity 可能会摧毁并重新创建, * 这会导致 [TorrentServiceConnection] 错误地重新绑定服务或重启服务. * - * ## 管理连接的逻辑 - * - * 需要调用 [startLifecycleJob] 来启动生命周期监听: - * - * * App 正在运行时 (生命周期在 [ON_RESUME][Lifecycle.Event.ON_RESUME] 至 [ON_STOP][Lifecycle.Event.ON_STOP] 期间) - * 如果 [服务断开][onServiceConnected], [TorrentServiceConnection] 会尝试重启服务并监听 - * [启动完成的广播][AniTorrentService.INTENT_STARTUP]. [AniTorrentService] 将在启动完成后发送此广播来触发绑定 Binder - * 并取消监听启动完成的广播. - * - * * App 在后台时 (生命周期在 [ON_STOP][Lifecycle.Event.ON_STOP] 至 [ON_DESTROY][Lifecycle.Event.ON_DESTROY] 期间) - * 如果 [服务断开][onServiceConnected], [TorrentServiceConnection] 不会尝试重启服务. - * 但在下一次进入 [ON_RESUME][Lifecycle.Event.ON_RESUME] 时会重启, 步骤和上面相同. - * - * 上方的三条逻辑保证了 app 在 [ON_RESUME][Lifecycle.Event.ON_RESUME] 至 [ON_STOP][Lifecycle.Event.ON_STOP] 期间服务一定存活. - * 所以前面建议应该使用 [ProcessLifecycleOwner][androidx.lifecycle.ProcessLifecycleOwner] 管理连接. - * * @see androidx.lifecycle.ProcessLifecycleOwner * @see ServiceConnection * @see AniTorrentService.onStartCommand @@ -76,196 +48,76 @@ import kotlin.time.Duration.Companion.minutes class TorrentServiceConnection( private val context: Context, private val onRequiredRestartService: () -> ComponentName?, - private val backgroundScope: CoroutineScope, - private val lifecycle: Lifecycle, -) : ServiceConnection, BroadcastReceiver() { - private val logger = logger() - - private var binder: CompletableDeferred = CompletableDeferred() - val connected: MutableStateFlow = MutableStateFlow(false) - - private val lock = SynchronizedObject() - - /** - * service 断开连接后是否需要立即重启, - */ - private var shouldRestartServiceImmediately = false - private val restartServiceIntentFilter = IntentFilter(AniTorrentService.INTENT_STARTUP) - - /** - * 在 [AniApplication][me.him188.ani.android.AniApplication] 启动时设置, 该方法设定了服务是否正常随着 app 的启动而启动. - * - * 因为 app 可能会在后台启动 (例如息屏的时候使用 adb am 指令), 此时启动前台服务会抛出 [ForegroundServiceStartNotAllowedException]. - * 如果出现了这种情况, 需要延迟启动服务到 Activity 出现时, 也就是 [ON_START][Lifecycle.Event.ON_START]. - * - * @see setStartServiceResultWhileAppStartup - */ - var startServiceResultWhileAppStartup = false - + coroutineContext: CoroutineContext = Dispatchers.Default, +) : ServiceConnection, AbstractTorrentServiceConnection(coroutineContext) { + private val startupIntentFilter by lazy { IntentFilter(AniTorrentService.INTENT_STARTUP) } private val acquireWakeLockIntent by lazy { - Intent(context, anitorrentServiceClass).apply { + Intent(context, AniTorrentService.actualServiceClass).apply { putExtra("acquireWakeLock", 1.minutes.inWholeMilliseconds) } } - private var startLifecycleJobCalled = false - - /** - * 启动 [Lifecycle] 监听, 在 [Lifecycle.State.RESUMED] 时自动 [restartService] - */ - fun startLifecycleJob() { - if (startLifecycleJobCalled) return - startLifecycleJobCalled = true - lifecycle.addObserver( - object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - this@TorrentServiceConnection.onStateChanged(event) - } - }, - ) - - backgroundScope.launch(CoroutineName("AniTorrentService auto restart")) { - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - shouldRestartServiceImmediately = true - - // auto restart if not connected - if (!connected.value) { - logger.debug { - "AniTorrentService is not started or stopped while app is switching to foreground, restarting." - } - while (!connected.value) { - restartService() - delay(1000) - if (connected.value) { - break - } else { - logger.warn { "AniTorrentService is not started while application is ON_RESUME, retrying." } - } + override suspend fun startService(): StartResult { + return suspendCancellableCoroutine { cont -> + val receiver = object : BroadcastReceiver() { + override fun onReceive(c: Context?, intent: Intent?) { + logger.debug { "Received service startup broadcast: $intent, starting bind service." } + context.unregisterReceiver(this) + + val bindResult = context.bindService( + Intent( + context, + AniTorrentService.actualServiceClass, + ), + this@TorrentServiceConnection, + Context.BIND_ABOVE_CLIENT, + ) + if (!bindResult) { + logger.error { "Failed to bind service, context.bindService returns false." } + cont.resume(StartResult.FAILED) + } else { + cont.resume(StartResult.STARTED) } } - - try { - awaitCancellation() - } finally { - // so shouldRestartServiceImmediately is true iff state is RESUMED - shouldRestartServiceImmediately = false - } - } - } - } - - private fun onStateChanged(event: Lifecycle.Event) { - when (event) { - Lifecycle.Event.ON_CREATE -> { - if (startServiceResultWhileAppStartup) { - bindService() - // 服务启动成功了,一定可以连接 - // 因为 onServiceConnected 如果在 Lifecycle.Event.ON_START 之后调用, 会尝试重新连接 - connected.value = true - } - } - - Lifecycle.Event.ON_STOP -> { - try { - // 请求 wake lock, 如果在 app 中息屏可以保证 service 正常跑 [acquireWakeLockIntent] 分钟. - context.startService(acquireWakeLockIntent) - } catch (ex: IllegalStateException) { - // 大概率是 ServiceStartForegroundException, 服务已经终止了, 不需要再请求 wakelock. - logger.warn(ex) { "Failed to acquire wake lock. Service has already died." } - } } - Lifecycle.Event.ON_DESTROY -> { - try { - context.unbindService(this) - connected.value = false - } catch (ex: IllegalArgumentException) { - logger.warn { "Failed to unregister AniTorrentService service." } - } + ContextCompat.registerReceiver( + context, + receiver, + startupIntentFilter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + + val result = onRequiredRestartService() + if (result == null) { + cont.resume(StartResult.FAILED) + logger.error { "Failed to start service, context.startForegroundService returns null component info." } + } else { + logger.debug { "Service started, component name: $result" } } - - else -> {} } } override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - logger.debug { "AniTorrentService is connected, name = $name" } - if (service != null) { - val result = IRemoteAniTorrentEngine.Stub.asInterface(service) - - synchronized(lock) { - if (binder.isCompleted) { - binder = CompletableDeferred(result) - } else { - binder.complete(result) - } - } - connected.value = true - } else { - logger.error { "Failed to get binder of AniTorrentService." } - connected.value = false + if (service == null) { + logger.warn { "Service is connected, but got null binder!" } } + val binder = IRemoteAniTorrentEngine.Stub.asInterface(service) + onServiceConnected(binder) } override fun onServiceDisconnected(name: ComponentName?) { - logger.debug { "AniTorrentService is disconnected, name = $name" } - synchronized(lock) { - binder.cancel() - binder = CompletableDeferred() - } - connected.value = false - - // app activity 还存在时必须重启 service - if (shouldRestartServiceImmediately) { - logger.debug { "AniTorrentService is disconnected while app is running, restarting." } - restartService() - } - } - - /** - * [AniTorrentService] 启动完成时发送广播, 随后 app 应该绑定服务获取接口 - * - * 首次启动 [AniTorrentService] 完成不在此绑定接口, 由 [onStateChanged] 中的 [Lifecycle.Event.ON_CREATE] 触发绑定. - * - * [onServiceDisconnected] 断开连接后将会注册此广播接收 [AniTorrentService] 启动完成事件. - */ - override fun onReceive(context: Context?, intent: Intent?) { - logger.debug { "AniTorrentService is restarted, rebinding." } - bindService() - } - - private val registerReceiver by lazy { - ContextCompat.registerReceiver( - context, - this, - restartServiceIntentFilter, - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - Unit - } - - private fun restartService() { - registerReceiver // init lazy - onRequiredRestartService() - } - - private fun bindService(): Boolean { - val bindResult = context.bindService( - Intent(context, anitorrentServiceClass), this, Context.BIND_ABOVE_CLIENT, - ) - if (!bindResult) logger.error { "Failed to bind AniTorrentService." } - return bindResult - } - - suspend fun awaitBinder(): IRemoteAniTorrentEngine { - return binder.await() + onServiceDisconnected() } - companion object { - val anitorrentServiceClass = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - AniTorrentServiceApi34::class.java - } else { - AniTorrentServiceApiDefault::class.java + override fun onPause(owner: LifecycleOwner) { + try { + // 请求 wake lock, 如果在 app 中息屏可以保证 service 正常跑 [acquireWakeLockIntent] 分钟. + context.startService(acquireWakeLockIntent) + } catch (ex: IllegalStateException) { + // 大概率是 ServiceStartForegroundException, 服务已经终止了, 不需要再请求 wakelock. + logger.warn(ex) { "Failed to acquire wake lock. Service has already died." } } + super.onPause(owner) } } \ No newline at end of file diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/AbstractTorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/AbstractTorrentServiceConnection.kt new file mode 100644 index 0000000000..4ec931a92f --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/AbstractTorrentServiceConnection.kt @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2024-2025 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.domain.torrent + +import androidx.annotation.CallSuper +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import me.him188.ani.utils.coroutines.childScope +import me.him188.ani.utils.logging.info +import me.him188.ani.utils.logging.logger +import me.him188.ani.utils.logging.warn +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * 管理与服务的连接. [T] 代表与服务进行通信的c. + * + * 这个类通过 [Lifecycle] 来约束与服务的连接状态, 保证了: + * - 在 [RESUMED][Lifecycle.State.RESUMED] 状态下, 根据[文档](https://developer.android.com/guide/components/activities/activity-lifecycle#onresume), APP 被视为在前台. + * 服务未连接或终止, 则会立刻启动或重启服务保证可用性. + * - 在 [CREATED][Lifecycle.State.CREATED] 和 [STARTED][Lifecycle.State.STARTED] 状态下, + * 若服务终止, 不会立刻重启服务, 直到再次进入 [RESUMED][Lifecycle.State.RESUMED] 状态. + * + * 实现细节: + * - 实现 [startService] 方法, 用于实际的启动服务. + * - 服务启动完成后,通过具体实现的监听方式调用 [onServiceConnected] 或 [onServiceDisconnected] 方法. + */ +abstract class AbstractTorrentServiceConnection( + coroutineContext: CoroutineContext = EmptyCoroutineContext, +) : DefaultLifecycleObserver { + protected val logger = logger(this::class.simpleName ?: "TorrentServiceConnection") + private val scope = coroutineContext.childScope() + + private val lock = Mutex() + private var binderDeferred by atomic(CompletableDeferred()) + + private val isAtForeground: MutableStateFlow = MutableStateFlow(false) + private val isServiceConnected: MutableStateFlow = MutableStateFlow(false) + + val connected: StateFlow = isServiceConnected + + /** + * 启动服务并返回启动结果. + * + * 注意:此处同步返回的结果仅代表服务是否成功启动, 不代表服务是否已连接. + * 换句话说, 此方法返回 [StartResult.STARTED] 或 [StartResult.ALREADY_RUNNING] 后, + * 实现类必须尽快调用 [onServiceConnected] 并传入服务通信接口对象. + * + * + * @return `true` if the service is started successfully, `false` otherwise. + */ + abstract suspend fun startService(): StartResult + + /** + * 服务已连接, 服务通信对象一定可用. + * 无论当前 [Lifecycle] 什么状态都要应用新的 [binder]. + */ + protected fun onServiceConnected(binder: T) { + scope.launch(CoroutineName("TorrentServiceConnection - On Service Connected")) { + lock.withLock { + logger.info { "Service is connected, got binder $binder" } + if (binderDeferred.isCompleted) { + binderDeferred = CompletableDeferred(binder) + } else { + binderDeferred.complete(binder) + } + isServiceConnected.value = true + } + } + } + + /** + * 服务已断开连接, 通信对象变为不可用. + * 如果目前 APP 还在前台, 就要尝试重启并重连服务. + */ + protected fun onServiceDisconnected() { + scope.launch(CoroutineName("TorrentServiceConnection - On Service Disconnected")) { + lock.withLock { + isServiceConnected.value = false + + binderDeferred.cancel(CancellationException("Service disconnected.")) + binderDeferred = CompletableDeferred() + + if (isAtForeground.value) { + logger.info { "Service is disconnected while app is at foreground, restarting." } + val startResult = startService() + if (startResult == StartResult.FAILED) { + logger.warn { "Failed to start service, all binder getter will suspended." } + } + } + } + } + } + + /** + * 获取当前 binder 对象. + * 如果服务未连接, 则会挂起直到服务连接成功. + */ + suspend fun getBinder(): T { + isServiceConnected.first { it } + return binderDeferred.await() + } + + /** + * APP 已进入前台, 此时需要保证服务可用. + */ + @CallSuper + override fun onResume(owner: LifecycleOwner) { + scope.launch(CoroutineName("TorrentServiceConnection - Lifecycle Resume")) { + isAtForeground.value = true + // 服务已经连接了, 不需要再次处理 + if (isServiceConnected.value) return@launch + + lock.withLock { + if (isServiceConnected.value) return@launch + + logger.info { "Service is not started, starting." } + val startResult = startService() + if (startResult == StartResult.FAILED) { + logger.warn { "Failed to start service, all binder getter will suspended." } + } + } + } + } + + @CallSuper + override fun onPause(owner: LifecycleOwner) { + scope.launch(CoroutineName("TorrentServiceConnection - Lifecycle Pause")) { + lock.withLock { + isAtForeground.value = false + } + } + } + + /** + * Start result of [startService] + */ + enum class StartResult { + /** + * Service is started, binder should be later retrieved by [onServiceConnected] + */ + STARTED, + + /** + * Service is already running + */ + ALREADY_RUNNING, + + /** + * Service started failed. + */ + FAILED + } +} \ No newline at end of file From 7a17094c472df9a090851ffe2685d6110a1981a2 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Thu, 30 Jan 2025 17:26:22 +0800 Subject: [PATCH 02/48] extract interface --- app/android/src/main/kotlin/AndroidModules.kt | 8 +- app/android/src/main/kotlin/AniApplication.kt | 4 +- .../torrent/client/RemoteAnitorrentEngine.kt | 4 +- ....kt => AndroidTorrentServiceConnection.kt} | 28 +++--- ...LifecycleAwareTorrentServiceConnection.kt} | 99 ++++++++++++------- 5 files changed, 87 insertions(+), 56 deletions(-) rename app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/{TorrentServiceConnection.kt => AndroidTorrentServiceConnection.kt} (86%) rename app/shared/app-data/src/commonMain/kotlin/domain/torrent/{AbstractTorrentServiceConnection.kt => LifecycleAwareTorrentServiceConnection.kt} (75%) diff --git a/app/android/src/main/kotlin/AndroidModules.kt b/app/android/src/main/kotlin/AndroidModules.kt index 2fc4b89791..5a28c640aa 100644 --- a/app/android/src/main/kotlin/AndroidModules.kt +++ b/app/android/src/main/kotlin/AndroidModules.kt @@ -33,14 +33,16 @@ import me.him188.ani.app.domain.media.resolver.MediaResolver import me.him188.ani.app.domain.media.resolver.TorrentMediaResolver import me.him188.ani.app.domain.settings.ProxyProvider import me.him188.ani.app.domain.torrent.DefaultTorrentManager +import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine import me.him188.ani.app.domain.torrent.LocalAnitorrentEngineFactory import me.him188.ani.app.domain.torrent.TorrentEngine import me.him188.ani.app.domain.torrent.TorrentEngineFactory import me.him188.ani.app.domain.torrent.TorrentManager +import me.him188.ani.app.domain.torrent.TorrentServiceConnection import me.him188.ani.app.domain.torrent.client.RemoteAnitorrentEngine import me.him188.ani.app.domain.torrent.peer.PeerFilterSettings +import me.him188.ani.app.domain.torrent.service.AndroidTorrentServiceConnection import me.him188.ani.app.domain.torrent.service.AniTorrentService -import me.him188.ani.app.domain.torrent.service.TorrentServiceConnection import me.him188.ani.app.navigation.BrowserNavigator import me.him188.ani.app.platform.AndroidPermissionManager import me.him188.ani.app.platform.AppTerminator @@ -76,7 +78,7 @@ import kotlin.system.exitProcess fun getAndroidModules( defaultTorrentCacheDir: File, - torrentServiceConnection: TorrentServiceConnection, + torrentServiceConnection: AndroidTorrentServiceConnection, coroutineScope: CoroutineScope, ) = module { single { @@ -100,7 +102,7 @@ fun getAndroidModules( } single { AndroidBrowserNavigator() } - single { torrentServiceConnection } + single> { torrentServiceConnection } single { val context = androidContext() diff --git a/app/android/src/main/kotlin/AniApplication.kt b/app/android/src/main/kotlin/AniApplication.kt index 36aedfa629..f63a3c018e 100644 --- a/app/android/src/main/kotlin/AniApplication.kt +++ b/app/android/src/main/kotlin/AniApplication.kt @@ -23,8 +23,8 @@ import kotlinx.coroutines.launch import me.him188.ani.android.activity.MainActivity import me.him188.ani.app.domain.media.cache.MediaCacheNotificationTask import me.him188.ani.app.domain.torrent.TorrentManager +import me.him188.ani.app.domain.torrent.service.AndroidTorrentServiceConnection import me.him188.ani.app.domain.torrent.service.AniTorrentService -import me.him188.ani.app.domain.torrent.service.TorrentServiceConnection import me.him188.ani.app.platform.AndroidLoggingConfigurator import me.him188.ani.app.platform.JvmLogHelper import me.him188.ani.app.platform.createAppRootCoroutineScope @@ -86,7 +86,7 @@ class AniApplication : Application() { val scope = createAppRootCoroutineScope() - val torrentServiceConnection = TorrentServiceConnection( + val torrentServiceConnection = AndroidTorrentServiceConnection( this, onRequiredRestartService = { startAniTorrentService() }, scope.coroutineContext, diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt index 7be11451a4..122d8a4a41 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt @@ -29,11 +29,11 @@ import me.him188.ani.app.data.models.preference.ProxyConfig import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine import me.him188.ani.app.domain.torrent.TorrentEngine import me.him188.ani.app.domain.torrent.TorrentEngineType +import me.him188.ani.app.domain.torrent.TorrentServiceConnection import me.him188.ani.app.domain.torrent.parcel.PAnitorrentConfig import me.him188.ani.app.domain.torrent.parcel.PProxyConfig import me.him188.ani.app.domain.torrent.parcel.toParceled import me.him188.ani.app.domain.torrent.peer.PeerFilterSettings -import me.him188.ani.app.domain.torrent.service.TorrentServiceConnection import me.him188.ani.app.torrent.api.TorrentDownloader import me.him188.ani.datasources.api.source.MediaSourceLocation import me.him188.ani.utils.coroutines.IO_ @@ -45,7 +45,7 @@ import kotlin.coroutines.CoroutineContext @RequiresApi(Build.VERSION_CODES.O_MR1) class RemoteAnitorrentEngine( - private val connection: TorrentServiceConnection, + private val connection: TorrentServiceConnection, anitorrentConfigFlow: Flow, proxyConfig: Flow, peerFilterConfig: Flow, diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt similarity index 86% rename from app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt rename to app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt index 8fd66acba2..a34b20ce87 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt @@ -20,8 +20,9 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine -import me.him188.ani.app.domain.torrent.AbstractTorrentServiceConnection import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine +import me.him188.ani.app.domain.torrent.LifecycleAwareTorrentServiceConnection +import me.him188.ani.app.domain.torrent.TorrentServiceConnection import me.him188.ani.utils.logging.debug import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.warn @@ -38,18 +39,19 @@ import kotlin.time.Duration.Companion.minutes * 或其他可以涵盖 app 全局生命周期的自定义 [LifecycleOwner] 来管理服务连接. * 不能使用 [Activity][android.app.Activity] (例如 [ComponentActivity][androidx.core.app.ComponentActivity]) * 的生命周期, 因为在屏幕旋转 (例如竖屏转全屏播放) 的时候 Activity 可能会摧毁并重新创建, - * 这会导致 [TorrentServiceConnection] 错误地重新绑定服务或重启服务. + * 这会导致 [AndroidTorrentServiceConnection] 错误地重新绑定服务或重启服务. * * @see androidx.lifecycle.ProcessLifecycleOwner * @see ServiceConnection * @see AniTorrentService.onStartCommand * @see me.him188.ani.android.AniApplication */ -class TorrentServiceConnection( +class AndroidTorrentServiceConnection( private val context: Context, private val onRequiredRestartService: () -> ComponentName?, coroutineContext: CoroutineContext = Dispatchers.Default, -) : ServiceConnection, AbstractTorrentServiceConnection(coroutineContext) { +) : ServiceConnection, + LifecycleAwareTorrentServiceConnection(coroutineContext) { private val startupIntentFilter by lazy { IntentFilter(AniTorrentService.INTENT_STARTUP) } private val acquireWakeLockIntent by lazy { Intent(context, AniTorrentService.actualServiceClass).apply { @@ -57,7 +59,7 @@ class TorrentServiceConnection( } } - override suspend fun startService(): StartResult { + override suspend fun startService(): TorrentServiceConnection.StartResult { return suspendCancellableCoroutine { cont -> val receiver = object : BroadcastReceiver() { override fun onReceive(c: Context?, intent: Intent?) { @@ -69,14 +71,14 @@ class TorrentServiceConnection( context, AniTorrentService.actualServiceClass, ), - this@TorrentServiceConnection, + this@AndroidTorrentServiceConnection, Context.BIND_ABOVE_CLIENT, ) if (!bindResult) { logger.error { "Failed to bind service, context.bindService returns false." } - cont.resume(StartResult.FAILED) + cont.resume(TorrentServiceConnection.StartResult.FAILED) } else { - cont.resume(StartResult.STARTED) + cont.resume(TorrentServiceConnection.StartResult.STARTED) } } } @@ -90,7 +92,7 @@ class TorrentServiceConnection( val result = onRequiredRestartService() if (result == null) { - cont.resume(StartResult.FAILED) + cont.resume(TorrentServiceConnection.StartResult.FAILED) logger.error { "Failed to start service, context.startForegroundService returns null component info." } } else { logger.debug { "Service started, component name: $result" } @@ -106,10 +108,6 @@ class TorrentServiceConnection( onServiceConnected(binder) } - override fun onServiceDisconnected(name: ComponentName?) { - onServiceDisconnected() - } - override fun onPause(owner: LifecycleOwner) { try { // 请求 wake lock, 如果在 app 中息屏可以保证 service 正常跑 [acquireWakeLockIntent] 分钟. @@ -120,4 +118,8 @@ class TorrentServiceConnection( } super.onPause(owner) } + + override fun onServiceDisconnected(name: ComponentName?) { + onServiceDisconnected() + } } \ No newline at end of file diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/AbstractTorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt similarity index 75% rename from app/shared/app-data/src/commonMain/kotlin/domain/torrent/AbstractTorrentServiceConnection.kt rename to app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt index 4ec931a92f..36ae23f932 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/AbstractTorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt @@ -31,21 +31,66 @@ import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext /** - * 管理与服务的连接. [T] 代表与服务进行通信的c. - * - * 这个类通过 [Lifecycle] 来约束与服务的连接状态, 保证了: + * torrent 服务与 APP 通信接口. + */ +interface TorrentServiceConnection { + /** + * 当前服务是否已连接, 只有在已连接的状态才能获取通信接口. + * + * 若变为 `false`, 则服务通信接口将变得不可用, 调用任何通信接口的方法将会导致不可预测的结果. + * 此时需要再次调用 [startService] 来重新启动服务. + */ + val connected: StateFlow + + /** + * 启动服务. 调用此方法后, 服务将会启动并尽快连接. + * + * 若返回了 [StartResult.STARTED] 或 [StartResult.FAILED], + * [connected] 将在未来变为 `true`, 届时 [getBinder] 将会立刻返回服务通信接口. + */ + suspend fun startService(): StartResult + + /** + * 获取通信接口. 如果服务未连接, 则会挂起直到服务连接成功. + */ + suspend fun getBinder(): T + + /** + * Start result of [startService] + */ + enum class StartResult { + /** + * Service is started, binder should be later retrieved by [onServiceConnected] + */ + STARTED, + + /** + * Service is already running + */ + ALREADY_RUNNING, + + /** + * Service started failed. + */ + FAILED + } +} + +/** + * 通过 [Lifecycle] 来约束与服务的连接状态, 保证了: * - 在 [RESUMED][Lifecycle.State.RESUMED] 状态下, 根据[文档](https://developer.android.com/guide/components/activities/activity-lifecycle#onresume), APP 被视为在前台. * 服务未连接或终止, 则会立刻启动或重启服务保证可用性. * - 在 [CREATED][Lifecycle.State.CREATED] 和 [STARTED][Lifecycle.State.STARTED] 状态下, * 若服务终止, 不会立刻重启服务, 直到再次进入 [RESUMED][Lifecycle.State.RESUMED] 状态. * * 实现细节: - * - 实现 [startService] 方法, 用于实际的启动服务. - * - 服务启动完成后,通过具体实现的监听方式调用 [onServiceConnected] 或 [onServiceDisconnected] 方法. + * - 实现 [startService] 方法, 用于实际的启动服务, 并且要连接服务. + * - 服务连接完成,调用 [onServiceConnected] 传入服务通信接口对象. + * - 如果服务断开连接了, 调用 [onServiceDisconnected], 会自动判断是否需要重连. */ -abstract class AbstractTorrentServiceConnection( +abstract class LifecycleAwareTorrentServiceConnection( coroutineContext: CoroutineContext = EmptyCoroutineContext, -) : DefaultLifecycleObserver { +) : DefaultLifecycleObserver, TorrentServiceConnection { protected val logger = logger(this::class.simpleName ?: "TorrentServiceConnection") private val scope = coroutineContext.childScope() @@ -55,7 +100,7 @@ abstract class AbstractTorrentServiceConnection( private val isAtForeground: MutableStateFlow = MutableStateFlow(false) private val isServiceConnected: MutableStateFlow = MutableStateFlow(false) - val connected: StateFlow = isServiceConnected + override val connected: StateFlow = isServiceConnected /** * 启动服务并返回启动结果. @@ -67,7 +112,7 @@ abstract class AbstractTorrentServiceConnection( * * @return `true` if the service is started successfully, `false` otherwise. */ - abstract suspend fun startService(): StartResult + abstract override suspend fun startService(): TorrentServiceConnection.StartResult /** * 服务已连接, 服务通信对象一定可用. @@ -93,7 +138,9 @@ abstract class AbstractTorrentServiceConnection( */ protected fun onServiceDisconnected() { scope.launch(CoroutineName("TorrentServiceConnection - On Service Disconnected")) { + if (!isServiceConnected.value) return@launch lock.withLock { + if (!isServiceConnected.value) return@launch isServiceConnected.value = false binderDeferred.cancel(CancellationException("Service disconnected.")) @@ -102,7 +149,7 @@ abstract class AbstractTorrentServiceConnection( if (isAtForeground.value) { logger.info { "Service is disconnected while app is at foreground, restarting." } val startResult = startService() - if (startResult == StartResult.FAILED) { + if (startResult == TorrentServiceConnection.StartResult.FAILED) { logger.warn { "Failed to start service, all binder getter will suspended." } } } @@ -110,15 +157,6 @@ abstract class AbstractTorrentServiceConnection( } } - /** - * 获取当前 binder 对象. - * 如果服务未连接, 则会挂起直到服务连接成功. - */ - suspend fun getBinder(): T { - isServiceConnected.first { it } - return binderDeferred.await() - } - /** * APP 已进入前台, 此时需要保证服务可用. */ @@ -134,7 +172,7 @@ abstract class AbstractTorrentServiceConnection( logger.info { "Service is not started, starting." } val startResult = startService() - if (startResult == StartResult.FAILED) { + if (startResult == TorrentServiceConnection.StartResult.FAILED) { logger.warn { "Failed to start service, all binder getter will suspended." } } } @@ -151,22 +189,11 @@ abstract class AbstractTorrentServiceConnection( } /** - * Start result of [startService] + * 获取当前 binder 对象. + * 如果服务未连接, 则会挂起直到服务连接成功. */ - enum class StartResult { - /** - * Service is started, binder should be later retrieved by [onServiceConnected] - */ - STARTED, - - /** - * Service is already running - */ - ALREADY_RUNNING, - - /** - * Service started failed. - */ - FAILED + override suspend fun getBinder(): T { + isServiceConnected.first { it } + return binderDeferred.await() } } \ No newline at end of file From b0389c0f07a2414c9ecce3e088239f2753ce17ad Mon Sep 17 00:00:00 2001 From: StageGuard Date: Thu, 30 Jan 2025 18:40:53 +0800 Subject: [PATCH 03/48] restart service when app is brought to foreground --- app/android/src/main/AndroidManifest.xml | 14 +------ app/android/src/main/kotlin/AndroidModules.kt | 2 +- app/android/src/main/kotlin/AniApplication.kt | 2 +- .../AndroidTorrentServiceConnection.kt | 42 +++++++++++++++++-- .../torrent/service/AniTorrentService.kt | 35 ++++++++-------- .../torrent/service/ServiceNotification.kt | 2 +- .../LifecycleAwareTorrentServiceConnection.kt | 35 ++++++++++------ 7 files changed, 85 insertions(+), 47 deletions(-) diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index 89c64a3b40..ccf3dcc2c5 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -9,8 +9,7 @@ ~ https://github.com/open-ani/ani/blob/main/LICENSE --> - + @@ -86,16 +85,7 @@ - - (coroutineContext) { private val startupIntentFilter by lazy { IntentFilter(AniTorrentService.INTENT_STARTUP) } private val acquireWakeLockIntent by lazy { - Intent(context, AniTorrentService.actualServiceClass).apply { + Intent(context, AniTorrentService::class.java).apply { putExtra("acquireWakeLock", 1.minutes.inWholeMilliseconds) } } + /** + * Android 15 限制了 `dataSync` 和 `mediaProcessing` 的 FGS 后台运行时间限制 + */ + private var registered = false + private val timeExceedLimitIntentFilter by lazy { IntentFilter(AniTorrentService.INTENT_BACKGROUND_TIMEOUT) } + private val timeExceedLimitReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + logger.warn { "Service background time exceeded." } + onServiceDisconnected() + } + } + override suspend fun startService(): TorrentServiceConnection.StartResult { return suspendCancellableCoroutine { cont -> val receiver = object : BroadcastReceiver() { @@ -66,10 +78,15 @@ class AndroidTorrentServiceConnection( logger.debug { "Received service startup broadcast: $intent, starting bind service." } context.unregisterReceiver(this) + if (intent?.getBooleanExtra("success", false) != true) { + cont.resume(TorrentServiceConnection.StartResult.FAILED) + return + } + val bindResult = context.bindService( Intent( context, - AniTorrentService.actualServiceClass, + AniTorrentService::class.java, ), this@AndroidTorrentServiceConnection, Context.BIND_ABOVE_CLIENT, @@ -102,13 +119,23 @@ class AndroidTorrentServiceConnection( override fun onServiceConnected(name: ComponentName?, service: IBinder?) { if (service == null) { - logger.warn { "Service is connected, but got null binder!" } + logger.error { "Service is connected, but got null binder!" } } val binder = IRemoteAniTorrentEngine.Stub.asInterface(service) onServiceConnected(binder) } override fun onPause(owner: LifecycleOwner) { + // app 到后台的时候注册监听 + if (!registered) { + ContextCompat.registerReceiver( + context, + timeExceedLimitReceiver, + timeExceedLimitIntentFilter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + registered = true + } try { // 请求 wake lock, 如果在 app 中息屏可以保证 service 正常跑 [acquireWakeLockIntent] 分钟. context.startService(acquireWakeLockIntent) @@ -119,6 +146,15 @@ class AndroidTorrentServiceConnection( super.onPause(owner) } + override fun onResume(owner: LifecycleOwner) { + // app 到前台的时候取消监听 + if (registered) { + context.unregisterReceiver(timeExceedLimitReceiver) + registered = false + } + super.onResume(owner) + } + override fun onServiceDisconnected(name: ComponentName?) { onServiceDisconnected() } diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt index bd8fc13d64..9996eb44d0 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt @@ -13,12 +13,10 @@ import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.Build import android.os.IBinder import android.os.PowerManager import android.os.Process import android.os.SystemClock -import androidx.annotation.RequiresApi import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CompletableDeferred @@ -53,7 +51,7 @@ import me.him188.ani.utils.io.inSystem import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger -sealed class AniTorrentService : LifecycleService() { +class AniTorrentService : LifecycleService() { private val scope = CoroutineScope( Dispatchers.Default + CoroutineName("AniTorrentService") + SupervisorJob(lifecycleScope.coroutineContext[Job]), @@ -144,13 +142,19 @@ sealed class AniTorrentService : LifecycleService() { if (notification.createNotification(this)) { // 启动完成的广播 sendBroadcast( - Intent().apply { + Intent(INTENT_STARTUP).apply { setPackage(packageName) - setAction(INTENT_STARTUP) + putExtra("success", true) }, ) return START_STICKY } else { + sendBroadcast( + Intent(INTENT_STARTUP).apply { + setPackage(packageName) + putExtra("false", true) + }, + ) return START_NOT_STICKY } } @@ -200,6 +204,13 @@ sealed class AniTorrentService : LifecycleService() { super.onTaskRemoved(rootIntent) } + override fun onTimeout(startId: Int, fgsType: Int) { + super.onTimeout(startId, fgsType) + // 发送后台执行超时广播 + sendBroadcast(Intent(INTENT_BACKGROUND_TIMEOUT).apply { setPackage(packageName) }) + stopSelf() + } + override fun onDestroy() { logger.info { "AniTorrentService is stopping." } meteredNetworkDetector.dispose() @@ -226,16 +237,6 @@ sealed class AniTorrentService : LifecycleService() { companion object { const val INTENT_STARTUP = "me.him188.ani.android.ANI_TORRENT_SERVICE_STARTUP" - - val actualServiceClass = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - AniTorrentServiceApi34::class.java - } else { - AniTorrentServiceApiDefault::class.java - } + const val INTENT_BACKGROUND_TIMEOUT = "me.him188.ani.android.ANI_TORRENT_SERVICE_BACKGROUND_TIMEOUT" } -} - -@RequiresApi(34) -class AniTorrentServiceApi34 : AniTorrentService() - -class AniTorrentServiceApiDefault : AniTorrentService() +} \ No newline at end of file diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt index 6eb0ad54fe..e0a29a287e 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt @@ -39,7 +39,7 @@ class ServiceNotification( private val stopServiceIntent by lazy { PendingIntent.getService( context, 0, - Intent(context, AniTorrentService.actualServiceClass).apply { putExtra("stopService", true) }, + Intent(context, AniTorrentService::class.java).apply { putExtra("stopService", true) }, PendingIntent.FLAG_IMMUTABLE, ) } diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt index 36ae23f932..2dc3fb5e21 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt @@ -17,6 +17,7 @@ import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first @@ -24,7 +25,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import me.him188.ani.utils.coroutines.childScope -import me.him188.ani.utils.logging.info +import me.him188.ani.utils.logging.debug +import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.logger import me.him188.ani.utils.logging.warn import kotlin.coroutines.CoroutineContext @@ -121,7 +123,7 @@ abstract class LifecycleAwareTorrentServiceConnection( protected fun onServiceConnected(binder: T) { scope.launch(CoroutineName("TorrentServiceConnection - On Service Connected")) { lock.withLock { - logger.info { "Service is connected, got binder $binder" } + logger.debug { "Service is connected, got binder $binder" } if (binderDeferred.isCompleted) { binderDeferred = CompletableDeferred(binder) } else { @@ -147,11 +149,8 @@ abstract class LifecycleAwareTorrentServiceConnection( binderDeferred = CompletableDeferred() if (isAtForeground.value) { - logger.info { "Service is disconnected while app is at foreground, restarting." } - val startResult = startService() - if (startResult == TorrentServiceConnection.StartResult.FAILED) { - logger.warn { "Failed to start service, all binder getter will suspended." } - } + logger.debug { "Service is disconnected while app is at foreground, restarting." } + startServiceWithRetry() } } } @@ -170,11 +169,8 @@ abstract class LifecycleAwareTorrentServiceConnection( lock.withLock { if (isServiceConnected.value) return@launch - logger.info { "Service is not started, starting." } - val startResult = startService() - if (startResult == TorrentServiceConnection.StartResult.FAILED) { - logger.warn { "Failed to start service, all binder getter will suspended." } - } + logger.debug { "Service is not started, starting." } + startServiceWithRetry() } } } @@ -188,6 +184,21 @@ abstract class LifecycleAwareTorrentServiceConnection( } } + private suspend fun startServiceWithRetry(maxAttempts: Int = 15) { + var retries = 0 + while (retries <= maxAttempts) { + val startResult = startService() + if (startResult == TorrentServiceConnection.StartResult.FAILED) { + logger.warn { "[#$retries] Failed to start service." } + retries++ + delay(7500) + } else { + return + } + } + logger.error { "Failed to start service after $maxAttempts retries." } + } + /** * 获取当前 binder 对象. * 如果服务未连接, 则会挂起直到服务连接成功. From f80bc747d36e4b97151919eb35424628cfdbd928 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Thu, 30 Jan 2025 20:28:23 +0800 Subject: [PATCH 04/48] close scope --- .../torrent/LifecycleAwareTorrentServiceConnection.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt index 2dc3fb5e21..40940e709e 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt @@ -17,6 +17,7 @@ import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -199,6 +200,12 @@ abstract class LifecycleAwareTorrentServiceConnection( logger.error { "Failed to start service after $maxAttempts retries." } } + fun close() { + scope.cancel() + isServiceConnected.value = false + binderDeferred.cancel(CancellationException("TorrentServiceConnection closed.")) + } + /** * 获取当前 binder 对象. * 如果服务未连接, 则会挂起直到服务连接成功. From fd1afabc0d58890aa3b14ff1e7b7f56e762b19b3 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Fri, 31 Jan 2025 14:42:40 +0800 Subject: [PATCH 05/48] test state 1 --- .../AndroidTorrentServiceConnection.kt | 4 +- ...nection.kt => TorrentServiceConnection.kt} | 4 +- .../AbstractTorrentServiceConnectionTest.kt | 108 ++++++++++ ...ecycleAwareTorrentServiceConnectionTest.kt | 188 ++++++++++++++++++ 4 files changed, 300 insertions(+), 4 deletions(-) rename app/shared/app-data/src/commonMain/kotlin/domain/torrent/{LifecycleAwareTorrentServiceConnection.kt => TorrentServiceConnection.kt} (98%) create mode 100644 app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt create mode 100644 app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt index 779146d69b..8323a09de2 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt @@ -49,9 +49,9 @@ import kotlin.time.Duration.Companion.minutes class AndroidTorrentServiceConnection( private val context: Context, private val onRequiredRestartService: () -> ComponentName?, - coroutineContext: CoroutineContext = Dispatchers.Default, + parentCoroutineContext: CoroutineContext = Dispatchers.Default, ) : ServiceConnection, - LifecycleAwareTorrentServiceConnection(coroutineContext) { + LifecycleAwareTorrentServiceConnection(parentCoroutineContext) { private val startupIntentFilter by lazy { IntentFilter(AniTorrentService.INTENT_STARTUP) } private val acquireWakeLockIntent by lazy { Intent(context, AniTorrentService::class.java).apply { diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt similarity index 98% rename from app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt rename to app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt index 40940e709e..f7d94255af 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt @@ -92,10 +92,10 @@ interface TorrentServiceConnection { * - 如果服务断开连接了, 调用 [onServiceDisconnected], 会自动判断是否需要重连. */ abstract class LifecycleAwareTorrentServiceConnection( - coroutineContext: CoroutineContext = EmptyCoroutineContext, + parentCoroutineContext: CoroutineContext = EmptyCoroutineContext, ) : DefaultLifecycleObserver, TorrentServiceConnection { protected val logger = logger(this::class.simpleName ?: "TorrentServiceConnection") - private val scope = coroutineContext.childScope() + private val scope = parentCoroutineContext.childScope() private val lock = Mutex() private var binderDeferred by atomic(CompletableDeferred()) diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt new file mode 100644 index 0000000000..93aaa1f38c --- /dev/null +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2024-2025 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.domain.torrent + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.coroutines.CoroutineContext +import kotlin.test.AfterTest +import kotlin.test.BeforeTest + +abstract class AbstractTorrentServiceConnectionTest { + @BeforeTest + fun installDispatcher() { + Dispatchers.setMain(StandardTestDispatcher()) + } + + @AfterTest + fun resetDispatcher() { + Dispatchers.resetMain() + } +} + +class TestTorrentServiceConnection( + private val shouldStartServiceSucceed: Boolean = true, + coroutineContext: CoroutineContext = Dispatchers.Default, +) : LifecycleAwareTorrentServiceConnection(coroutineContext) { + + private val fakeBinder = "FAKE_BINDER_OBJECT" + + override suspend fun startService(): TorrentServiceConnection.StartResult { + delay(100) + return if (shouldStartServiceSucceed) { + TorrentServiceConnection.StartResult.STARTED + } else { + TorrentServiceConnection.StartResult.FAILED + } + } + + fun triggerServiceConnected() { + onServiceConnected(fakeBinder) + } + + fun triggerServiceDisconnected() { + onServiceDisconnected() + } +} + +private class TestLifecycle(private val owner: LifecycleOwner) : Lifecycle() { + private val observers = MutableStateFlow(persistentListOf()) + private val _currentState = MutableStateFlow(State.INITIALIZED) + + override val currentState: State + get() = _currentState.value + + override fun addObserver(observer: LifecycleObserver) { + check(observer is DefaultLifecycleObserver) { + "$observer must implement androidx.lifecycle.DefaultLifecycleObserver." + } + observers.update { it.add(observer) } + } + + override fun removeObserver(observer: LifecycleObserver) { + check(observer is DefaultLifecycleObserver) { + "$observer must implement androidx.lifecycle.DefaultLifecycleObserver." + } + observers.update { it.remove(observer) } + } + + fun moveToState(state: State) { + observers.value.forEach { + when (state) { + State.INITIALIZED -> {} + State.CREATED -> it.onCreate(owner) + State.STARTED -> it.onStart(owner) + State.RESUMED -> it.onResume(owner) + State.DESTROYED -> it.onDestroy(owner) + } + } + } +} + +class TestLifecycleOwner : LifecycleOwner { + private val lifecycleRegistry = TestLifecycle(this) + + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + fun moveTo(state: Lifecycle.State) { + lifecycleRegistry.moveToState(state) + } +} diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt new file mode 100644 index 0000000000..984e52e9b3 --- /dev/null +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -0,0 +1,188 @@ +/* + * Copyright (C) 2024-2025 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.domain.torrent + +import androidx.lifecycle.Lifecycle +import app.cash.turbine.test +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnectionTest() { + @Test + fun `service starts on resume - success`() = runTest { + val testLifecycle = TestLifecycleOwner() + val connection = TestTorrentServiceConnection( + shouldStartServiceSucceed = true, + coroutineContext = coroutineContext, + ) + + testLifecycle.lifecycle.addObserver(connection) + + assertFalse(connection.connected.value) + val connectedFlowJob = backgroundScope.launch { + connection.connected.test { + assertFalse(awaitItem(), "Initially, connected should be false.") + // trigger on resumed + testLifecycle.moveTo(Lifecycle.State.RESUMED) + // not started immediately, async. + assertFalse(awaitItem(), "Service not connected until onServiceConnected is invoked.") + // trigger connected + connection.triggerServiceConnected() + assertTrue(awaitItem(), "After service is connected, connected should become true.") + } + } + + advanceUntilIdle() + connectedFlowJob.join() + } + + @Test + fun `service starts on resume - fails to start service`() = runTest { + val testLifecycle = TestLifecycleOwner() + val connection = TestTorrentServiceConnection( + shouldStartServiceSucceed = false, // Force a failure + coroutineContext = coroutineContext, + ) + + testLifecycle.lifecycle.addObserver(connection) + + // The .connected flow should remain false, even after we move to resumed, + // because startService() always fails, no successful onServiceConnected() is triggered. + val connectedFlowJob = backgroundScope.launch { + connection.connected.test { + val initial = awaitItem() + assertFalse(initial) + + // Move to RESUMED + testLifecycle.moveTo(Lifecycle.State.RESUMED) + + // Because startService() fails repeatedly in the retry loop, connected never becomes true + // We'll watch for a short while and confirm it does not become true + repeat(3) { + val next = expectMostRecentItem() + assertFalse(next) + advanceTimeBy(8000) // Let the internal retry happen + } + } + } + + advanceUntilIdle() + connectedFlowJob.cancel() + } + + @Test + fun `getBinder suspends until service is connected`() = runTest { + val connection = TestTorrentServiceConnection( + shouldStartServiceSucceed = true, + coroutineContext = coroutineContext, + ) + + // Start a coroutine that calls getBinder + val binderDeferred = async { + connection.getBinder() // Should suspend + } + + // The call hasn't returned yet, because we haven't simulated connect + advanceTimeBy(200) // Enough to start the service, but not connect + assertTrue(!binderDeferred.isCompleted) + + // Now simulate connected + connection.triggerServiceConnected() + + // Once connected, getBinder should complete with the fake binder + val binder = binderDeferred.await() + assertEquals("FAKE_BINDER_OBJECT", binder) + } + + @Test + fun `service disconnect triggers automatic restart if lifecycle is RESUMED`() = runTest { + val testLifecycle = TestLifecycleOwner() + val connection = TestTorrentServiceConnection( + shouldStartServiceSucceed = true, + coroutineContext = coroutineContext, + ) + + testLifecycle.lifecycle.addObserver(connection) + testLifecycle.moveTo(Lifecycle.State.RESUMED) + + // Wait for the startService invocation + advanceTimeBy(200) + // Next, simulate the service connected + connection.triggerServiceConnected() + // Now it’s connected + assertTrue(connection.connected.value) + + // Disconnect: + connection.triggerServiceDisconnected() + + // Because the lifecycle is still in RESUMED, + // it should attempt to startService again automatically + // We can wait a bit, then connect again: + advanceTimeBy(200) // let the startService happen + connection.triggerServiceConnected() + assertTrue(connection.connected.value) + } + + @Test + fun `service disconnect does not restart if lifecycle is only CREATED`() = runTest { + val testLifecycle = TestLifecycleOwner() + val connection = TestTorrentServiceConnection( + shouldStartServiceSucceed = true, + coroutineContext = coroutineContext, + ) + testLifecycle.lifecycle.addObserver(connection) + + // Move to RESUMED, wait a bit, and connect service + testLifecycle.moveTo(Lifecycle.State.RESUMED) + advanceTimeBy(200) + connection.triggerServiceConnected() + assertTrue(connection.connected.value) + + // Move lifecycle to CREATED + testLifecycle.moveTo(Lifecycle.State.CREATED) + // Now simulate a service disconnect + connection.triggerServiceDisconnected() + + // Should remain disconnected, no auto retry because we are no longer in RESUMED + advanceTimeBy(2000) + assertFalse(connection.connected.value) + } + + @Test + fun `close cancels all coroutines and flows`() = runTest { + val connection = TestTorrentServiceConnection( + shouldStartServiceSucceed = true, + coroutineContext = coroutineContext, + ) + + connection.triggerServiceConnected() + advanceUntilIdle() + assertTrue(connection.connected.value) + + connection.close() + advanceUntilIdle() + assertFalse(connection.connected.value) + + // Attempt to call getBinder() after close => should never succeed + val binderDeferred = async { + connection.getBinder() + } + + advanceTimeBy(500) + assertTrue(binderDeferred.isCancelled, "getBinder() should be cancelled because connection is closed.") + } +} \ No newline at end of file From a977446cdaacbe91c48aa67d8ceb64023d454559 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sun, 2 Feb 2025 12:19:57 +0800 Subject: [PATCH 06/48] fix Android 34 fgs with type dataSync --- app/android/src/main/AndroidManifest.xml | 14 +++++++++-- app/android/src/main/kotlin/AndroidModules.kt | 2 +- app/android/src/main/kotlin/AniApplication.kt | 2 +- .../AndroidTorrentServiceConnection.kt | 7 ++---- .../torrent/service/AniTorrentService.kt | 25 +++++++++++++++++-- .../torrent/service/ServiceNotification.kt | 2 +- .../torrent/TorrentServiceConnection.kt | 13 +++++++--- 7 files changed, 49 insertions(+), 16 deletions(-) diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index ccf3dcc2c5..89c64a3b40 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -9,7 +9,8 @@ ~ https://github.com/open-ani/ani/blob/main/LICENSE --> - + @@ -85,7 +86,16 @@ + + (parentCoroutineContext) { private val startupIntentFilter by lazy { IntentFilter(AniTorrentService.INTENT_STARTUP) } private val acquireWakeLockIntent by lazy { - Intent(context, AniTorrentService::class.java).apply { + Intent(context, AniTorrentService.actualServiceClass).apply { putExtra("acquireWakeLock", 1.minutes.inWholeMilliseconds) } } @@ -84,10 +84,7 @@ class AndroidTorrentServiceConnection( } val bindResult = context.bindService( - Intent( - context, - AniTorrentService::class.java, - ), + Intent(context, AniTorrentService.actualServiceClass), this@AndroidTorrentServiceConnection, Context.BIND_ABOVE_CLIENT, ) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt index 9996eb44d0..17dcc5412f 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt @@ -13,6 +13,7 @@ import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.os.Build import android.os.IBinder import android.os.PowerManager import android.os.Process @@ -51,7 +52,7 @@ import me.him188.ani.utils.io.inSystem import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger -class AniTorrentService : LifecycleService() { +sealed class AniTorrentService : LifecycleService() { private val scope = CoroutineScope( Dispatchers.Default + CoroutineName("AniTorrentService") + SupervisorJob(lifecycleScope.coroutineContext[Job]), @@ -238,5 +239,25 @@ class AniTorrentService : LifecycleService() { companion object { const val INTENT_STARTUP = "me.him188.ani.android.ANI_TORRENT_SERVICE_STARTUP" const val INTENT_BACKGROUND_TIMEOUT = "me.him188.ani.android.ANI_TORRENT_SERVICE_BACKGROUND_TIMEOUT" + + val actualServiceClass = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + AniTorrentServiceApi34::class.java + } else { + AniTorrentServiceApiDefault::class.java + } } -} \ No newline at end of file +} + +/** + * Android 34 或以上使用 + * + * 在 manifest 的 fgsType 是 mediaPlayback, 没被限制运行 + */ +class AniTorrentServiceApi34 : AniTorrentService() + +/** + * Android 34 以下使用 + * + * 在 manifest 的 fgsType 是 dataSync + */ +class AniTorrentServiceApiDefault : AniTorrentService() \ No newline at end of file diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt index e0a29a287e..6eb0ad54fe 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceNotification.kt @@ -39,7 +39,7 @@ class ServiceNotification( private val stopServiceIntent by lazy { PendingIntent.getService( context, 0, - Intent(context, AniTorrentService::class.java).apply { putExtra("stopService", true) }, + Intent(context, AniTorrentService.actualServiceClass).apply { putExtra("stopService", true) }, PendingIntent.FLAG_IMMUTABLE, ) } diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt index f7d94255af..1a0b05fc03 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt @@ -17,7 +17,9 @@ import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -109,7 +111,7 @@ abstract class LifecycleAwareTorrentServiceConnection( * 启动服务并返回启动结果. * * 注意:此处同步返回的结果仅代表服务是否成功启动, 不代表服务是否已连接. - * 换句话说, 此方法返回 [StartResult.STARTED] 或 [StartResult.ALREADY_RUNNING] 后, + * 换句话说, 此方法返回 [StartResult.STARTED][TorrentServiceConnection.StartResult.STARTED] 或 [StartResult.ALREADY_RUNNING][TorrentServiceConnection.StartResult.ALREADY_RUNNING] 后, * 实现类必须尽快调用 [onServiceConnected] 并传入服务通信接口对象. * * @@ -185,7 +187,7 @@ abstract class LifecycleAwareTorrentServiceConnection( } } - private suspend fun startServiceWithRetry(maxAttempts: Int = 15) { + private suspend fun startServiceWithRetry(maxAttempts: Int = Int.MAX_VALUE) { var retries = 0 while (retries <= maxAttempts) { val startResult = startService() @@ -208,10 +210,13 @@ abstract class LifecycleAwareTorrentServiceConnection( /** * 获取当前 binder 对象. - * 如果服务未连接, 则会挂起直到服务连接成功. + * 如果服务未连接, 则会挂起直到服务连接成功; 如果连接已[关闭][close], 则直接抛出 [CancellationException]. */ override suspend fun getBinder(): T { - isServiceConnected.first { it } + // track cancellation of [scope] + scope.async { + isServiceConnected.first { it } + }.await() return binderDeferred.await() } } \ No newline at end of file From d9445d1b733bb45c82d2108dd9eca9f7ea03da0a Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sun, 2 Feb 2025 12:20:13 +0800 Subject: [PATCH 07/48] Add test for LifecycleAwareTorrentServiceConnection --- .../AbstractTorrentServiceConnectionTest.kt | 49 +++-- ...ecycleAwareTorrentServiceConnectionTest.kt | 172 +++++++++--------- 2 files changed, 124 insertions(+), 97 deletions(-) diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt index 93aaa1f38c..b7eb2460d5 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt @@ -18,9 +18,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import me.him188.ani.utils.coroutines.childScope import kotlin.coroutines.CoroutineContext import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -41,22 +43,22 @@ class TestTorrentServiceConnection( private val shouldStartServiceSucceed: Boolean = true, coroutineContext: CoroutineContext = Dispatchers.Default, ) : LifecycleAwareTorrentServiceConnection(coroutineContext) { - + private val scope = coroutineContext.childScope() private val fakeBinder = "FAKE_BINDER_OBJECT" override suspend fun startService(): TorrentServiceConnection.StartResult { delay(100) return if (shouldStartServiceSucceed) { + scope.launch { // simulate automatic connection after start service succeeded. + delay(500) + onServiceConnected(fakeBinder) + } TorrentServiceConnection.StartResult.STARTED } else { TorrentServiceConnection.StartResult.FAILED } } - fun triggerServiceConnected() { - onServiceConnected(fakeBinder) - } - fun triggerServiceDisconnected() { onServiceDisconnected() } @@ -78,7 +80,7 @@ private class TestLifecycle(private val owner: LifecycleOwner) : Lifecycle() { override fun removeObserver(observer: LifecycleObserver) { check(observer is DefaultLifecycleObserver) { - "$observer must implement androidx.lifecycle.DefaultLifecycleObserver." + "$observer must implement androidx.lifecycle.DefaultLifecycleObserver" } observers.update { it.remove(observer) } } @@ -86,13 +88,38 @@ private class TestLifecycle(private val owner: LifecycleOwner) : Lifecycle() { fun moveToState(state: State) { observers.value.forEach { when (state) { - State.INITIALIZED -> {} - State.CREATED -> it.onCreate(owner) - State.STARTED -> it.onStart(owner) - State.RESUMED -> it.onResume(owner) - State.DESTROYED -> it.onDestroy(owner) + State.INITIALIZED -> error("cannot move to INITIALIZED state") + State.CREATED -> when (_currentState.value) { + State.INITIALIZED -> { it.onCreate(owner) } + State.CREATED -> { } + State.STARTED -> { it.onStop(owner) } + State.RESUMED -> { it.onPause(owner); it.onStop(owner) } + State.DESTROYED -> error("state is DESTROYED and cannot move to others") + } + State.STARTED -> when (_currentState.value) { + State.INITIALIZED -> { it.onCreate(owner); it.onStart(owner) } + State.CREATED -> { it.onStart(owner) } + State.STARTED -> { } + State.RESUMED -> { it.onPause(owner) } + State.DESTROYED -> error("state is DESTROYED and cannot move to others") + } + State.RESUMED -> when (_currentState.value) { + State.INITIALIZED -> { it.onCreate(owner); it.onStart(owner); it.onResume(owner) } + State.CREATED -> { it.onStart(owner); it.onResume(owner) } + State.STARTED -> { it.onResume(owner) } + State.RESUMED -> { } + State.DESTROYED -> error("state is DESTROYED and cannot move to others") + } + State.DESTROYED -> when (_currentState.value) { + State.INITIALIZED -> { } + State.CREATED -> { it.onDestroy(owner) } + State.STARTED -> { it.onStop(owner); it.onDestroy(owner) } + State.RESUMED -> { it.onPause(owner); it.onStop(owner); it.onDestroy(owner) } + State.DESTROYED -> error("state is DESTROYED and cannot move to others") + } } } + _currentState.value = state } } diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt index 984e52e9b3..7dc46c06b4 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -11,6 +11,8 @@ package me.him188.ani.app.domain.torrent import androidx.lifecycle.Lifecycle import app.cash.turbine.test +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy @@ -27,27 +29,21 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect val testLifecycle = TestLifecycleOwner() val connection = TestTorrentServiceConnection( shouldStartServiceSucceed = true, - coroutineContext = coroutineContext, + coroutineContext = backgroundScope.coroutineContext, ) testLifecycle.lifecycle.addObserver(connection) - assertFalse(connection.connected.value) - val connectedFlowJob = backgroundScope.launch { - connection.connected.test { - assertFalse(awaitItem(), "Initially, connected should be false.") - // trigger on resumed - testLifecycle.moveTo(Lifecycle.State.RESUMED) - // not started immediately, async. - assertFalse(awaitItem(), "Service not connected until onServiceConnected is invoked.") - // trigger connected - connection.triggerServiceConnected() - assertTrue(awaitItem(), "After service is connected, connected should become true.") - } - } + connection.connected.test { + assertFalse(awaitItem(), "Initially, connected should be false.") - advanceUntilIdle() - connectedFlowJob.join() + // trigger on resumed + testLifecycle.moveTo(Lifecycle.State.RESUMED) + assertTrue(awaitItem(), "After service is connected, connected should become true.") + + // completed + expectNoEvents() + } } @Test @@ -55,42 +51,38 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect val testLifecycle = TestLifecycleOwner() val connection = TestTorrentServiceConnection( shouldStartServiceSucceed = false, // Force a failure - coroutineContext = coroutineContext, + coroutineContext = backgroundScope.coroutineContext, ) testLifecycle.lifecycle.addObserver(connection) // The .connected flow should remain false, even after we move to resumed, // because startService() always fails, no successful onServiceConnected() is triggered. - val connectedFlowJob = backgroundScope.launch { - connection.connected.test { - val initial = awaitItem() - assertFalse(initial) - - // Move to RESUMED - testLifecycle.moveTo(Lifecycle.State.RESUMED) - - // Because startService() fails repeatedly in the retry loop, connected never becomes true - // We'll watch for a short while and confirm it does not become true - repeat(3) { - val next = expectMostRecentItem() - assertFalse(next) - advanceTimeBy(8000) // Let the internal retry happen - } + connection.connected.test { + assertFalse(awaitItem(), "Initially, connected should be false.") + + // Move to RESUMED + testLifecycle.moveTo(Lifecycle.State.RESUMED) + + // Because startService() fails repeatedly in the retry loop, connected never becomes true + // We'll watch for a short while and confirm it does not become true + repeat(3) { + expectNoEvents() + advanceTimeBy(8000) // Let the internal retry happen } } - - advanceUntilIdle() - connectedFlowJob.cancel() } @Test fun `getBinder suspends until service is connected`() = runTest { + val testLifecycle = TestLifecycleOwner() val connection = TestTorrentServiceConnection( shouldStartServiceSucceed = true, - coroutineContext = coroutineContext, + coroutineContext = backgroundScope.coroutineContext, ) - + + testLifecycle.lifecycle.addObserver(connection) + // Start a coroutine that calls getBinder val binderDeferred = async { connection.getBinder() // Should suspend @@ -99,9 +91,8 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // The call hasn't returned yet, because we haven't simulated connect advanceTimeBy(200) // Enough to start the service, but not connect assertTrue(!binderDeferred.isCompleted) - - // Now simulate connected - connection.triggerServiceConnected() + + testLifecycle.moveTo(Lifecycle.State.RESUMED) // Once connected, getBinder should complete with the fake binder val binder = binderDeferred.await() @@ -113,28 +104,31 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect val testLifecycle = TestLifecycleOwner() val connection = TestTorrentServiceConnection( shouldStartServiceSucceed = true, - coroutineContext = coroutineContext, + coroutineContext = backgroundScope.coroutineContext, ) testLifecycle.lifecycle.addObserver(connection) - testLifecycle.moveTo(Lifecycle.State.RESUMED) - - // Wait for the startService invocation - advanceTimeBy(200) - // Next, simulate the service connected - connection.triggerServiceConnected() - // Now it’s connected - assertTrue(connection.connected.value) - - // Disconnect: - connection.triggerServiceDisconnected() - - // Because the lifecycle is still in RESUMED, - // it should attempt to startService again automatically - // We can wait a bit, then connect again: - advanceTimeBy(200) // let the startService happen - connection.triggerServiceConnected() - assertTrue(connection.connected.value) + + connection.connected.test { + assertFalse(awaitItem(), "Initially, connected should be false.") + // Wait for the startService invocation + testLifecycle.moveTo(Lifecycle.State.RESUMED) + advanceTimeBy(200) + // Now it’s connected + assertTrue(awaitItem(), "Service should be connected.") + + // Disconnect: + connection.triggerServiceDisconnected() + assertFalse(awaitItem(), "Service should be disconnected since we triggered disconnection.") + + // Because the lifecycle is still in RESUMED, + // it should attempt to startService again automatically + // We can wait a bit, then connect again: + advanceTimeBy(200) // let the startService happen + assertTrue(awaitItem(), "Service should be reconnected since lifecycle state is RESUMED.") + + expectNoEvents() + } } @Test @@ -142,47 +136,53 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect val testLifecycle = TestLifecycleOwner() val connection = TestTorrentServiceConnection( shouldStartServiceSucceed = true, - coroutineContext = coroutineContext, + coroutineContext = backgroundScope.coroutineContext, ) testLifecycle.lifecycle.addObserver(connection) - - // Move to RESUMED, wait a bit, and connect service - testLifecycle.moveTo(Lifecycle.State.RESUMED) - advanceTimeBy(200) - connection.triggerServiceConnected() - assertTrue(connection.connected.value) - - // Move lifecycle to CREATED - testLifecycle.moveTo(Lifecycle.State.CREATED) - // Now simulate a service disconnect - connection.triggerServiceDisconnected() - - // Should remain disconnected, no auto retry because we are no longer in RESUMED - advanceTimeBy(2000) - assertFalse(connection.connected.value) + + connection.connected.test { + assertFalse(awaitItem(), "Initially, connected should be false.") + + // Wait for the startService invocation + testLifecycle.moveTo(Lifecycle.State.RESUMED) + advanceTimeBy(200) + // Now it’s connected + assertTrue(awaitItem(), "Service should be connected.") + + // Move lifecycle to CREATED + testLifecycle.moveTo(Lifecycle.State.CREATED) + // Now simulate a service disconnect + connection.triggerServiceDisconnected() + + // Should remain disconnected, no auto retry because we are no longer in RESUMED + advanceTimeBy(2000) + assertFalse(awaitItem(), "Service should not be connected because current lifecycle state is not RESUMED.") + } } @Test fun `close cancels all coroutines and flows`() = runTest { val connection = TestTorrentServiceConnection( shouldStartServiceSucceed = true, - coroutineContext = coroutineContext, + coroutineContext = backgroundScope.coroutineContext, ) - - connection.triggerServiceConnected() - advanceUntilIdle() - assertTrue(connection.connected.value) - - connection.close() advanceUntilIdle() assertFalse(connection.connected.value) // Attempt to call getBinder() after close => should never succeed - val binderDeferred = async { - connection.getBinder() + val cancelled = CompletableDeferred() + launch { + try { + connection.getBinder() + } catch (ex: CancellationException) { + cancelled.complete(true) + } } + + advanceUntilIdle() + connection.close() - advanceTimeBy(500) - assertTrue(binderDeferred.isCancelled, "getBinder() should be cancelled because connection is closed.") + advanceUntilIdle() + assertTrue(cancelled.await(), "getBinder() should be cancelled because connection is closed.") } } \ No newline at end of file From 6b2f1e07ee23486b5eb1b868aeeaab2f140a3a2e Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sun, 2 Feb 2025 13:07:45 +0800 Subject: [PATCH 08/48] reformat --- .../AbstractTorrentServiceConnectionTest.kt | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt index b7eb2460d5..267316903f 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt @@ -90,31 +90,55 @@ private class TestLifecycle(private val owner: LifecycleOwner) : Lifecycle() { when (state) { State.INITIALIZED -> error("cannot move to INITIALIZED state") State.CREATED -> when (_currentState.value) { - State.INITIALIZED -> { it.onCreate(owner) } - State.CREATED -> { } - State.STARTED -> { it.onStop(owner) } - State.RESUMED -> { it.onPause(owner); it.onStop(owner) } + State.INITIALIZED -> it.onCreate(owner) + State.CREATED -> {} + State.STARTED -> it.onStop(owner) + State.RESUMED -> { + it.onPause(owner) + it.onStop(owner) + } State.DESTROYED -> error("state is DESTROYED and cannot move to others") } State.STARTED -> when (_currentState.value) { - State.INITIALIZED -> { it.onCreate(owner); it.onStart(owner) } - State.CREATED -> { it.onStart(owner) } - State.STARTED -> { } - State.RESUMED -> { it.onPause(owner) } + State.INITIALIZED -> { + it.onCreate(owner) + it.onStart(owner) + } + + State.CREATED -> it.onStart(owner) + State.STARTED -> {} + State.RESUMED -> it.onPause(owner) State.DESTROYED -> error("state is DESTROYED and cannot move to others") } State.RESUMED -> when (_currentState.value) { - State.INITIALIZED -> { it.onCreate(owner); it.onStart(owner); it.onResume(owner) } - State.CREATED -> { it.onStart(owner); it.onResume(owner) } - State.STARTED -> { it.onResume(owner) } - State.RESUMED -> { } + State.INITIALIZED -> { + it.onCreate(owner) + it.onStart(owner) + it.onResume(owner) + } + + State.CREATED -> { + it.onStart(owner) + it.onResume(owner) + } + + State.STARTED -> it.onResume(owner) + State.RESUMED -> {} State.DESTROYED -> error("state is DESTROYED and cannot move to others") } State.DESTROYED -> when (_currentState.value) { State.INITIALIZED -> { } - State.CREATED -> { it.onDestroy(owner) } - State.STARTED -> { it.onStop(owner); it.onDestroy(owner) } - State.RESUMED -> { it.onPause(owner); it.onStop(owner); it.onDestroy(owner) } + State.CREATED -> it.onDestroy(owner) + State.STARTED -> { + it.onStop(owner) + it.onDestroy(owner) + } + + State.RESUMED -> { + it.onPause(owner) + it.onStop(owner) + it.onDestroy(owner) + } State.DESTROYED -> error("state is DESTROYED and cannot move to others") } } From 7b1c302b44926efc29a76cb8532701eabfbf2b55 Mon Sep 17 00:00:00 2001 From: Him188 Date: Sun, 2 Feb 2025 13:30:50 +0000 Subject: [PATCH 09/48] Add separate dispatchers to workaround runBlocking issues --- .../torrent/client/RemoteAnitorrentEngine.kt | 21 ++++++++++++------- .../torrent/TorrentServiceConnection.kt | 13 ++++++++---- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt index 122d8a4a41..6635912ed3 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt @@ -13,6 +13,7 @@ import android.os.Build import android.os.IInterface import androidx.annotation.RequiresApi import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow @@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.newFixedThreadPoolContext import kotlinx.serialization.builtins.nullable import kotlinx.serialization.json.Json import me.him188.ani.app.data.models.preference.AnitorrentConfig @@ -54,28 +56,30 @@ class RemoteAnitorrentEngine( ) : TorrentEngine { private val logger = logger() - private val scope = parentCoroutineContext.childScope() + @OptIn(DelicateCoroutinesApi::class) + private val dispatcher = newFixedThreadPoolContext(2, "RemoteAnitorrentEngine") + private val scope = parentCoroutineContext.childScope(dispatcher) private val fetchRemoteScope = parentCoroutineContext.childScope( CoroutineName("RemoteAnitorrentEngineFetchRemote") + Dispatchers.IO_, ) - + private val connectivityAware = DefaultConnectivityAware( parentCoroutineContext.childScope(), connection.connected, ) - + override val type: TorrentEngineType = TorrentEngineType.RemoteAnitorrent - override val isSupported: Flow + override val isSupported: Flow get() = flowOf(true) override val location: MediaSourceLocation = MediaSourceLocation.Local - - private val json = Json { + + private val json = Json { ignoreUnknownKeys = true encodeDefaults = true } - + init { // transfer from app to service. collectSettingsToRemote( @@ -103,7 +107,7 @@ class RemoteAnitorrentEngine( override suspend fun testConnection(): Boolean { return connection.connected.value } - + override suspend fun getDownloader(): TorrentDownloader { return RemoteTorrentDownloader( fetchRemoteScope, @@ -118,6 +122,7 @@ class RemoteAnitorrentEngine( override fun close() { scope.cancel() + dispatcher.close() fetchRemoteScope.cancel() } diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt index 1a0b05fc03..a0c1980662 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt @@ -17,14 +17,15 @@ import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.newFixedThreadPoolContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import me.him188.ani.utils.coroutines.childScope @@ -97,7 +98,10 @@ abstract class LifecycleAwareTorrentServiceConnection( parentCoroutineContext: CoroutineContext = EmptyCoroutineContext, ) : DefaultLifecycleObserver, TorrentServiceConnection { protected val logger = logger(this::class.simpleName ?: "TorrentServiceConnection") - private val scope = parentCoroutineContext.childScope() + + @OptIn(DelicateCoroutinesApi::class) + private val dispatcher = newFixedThreadPoolContext(2, "LifecycleAwareTorrentServiceConnection") + private val scope = parentCoroutineContext.childScope(dispatcher) private val lock = Mutex() private var binderDeferred by atomic(CompletableDeferred()) @@ -204,6 +208,7 @@ abstract class LifecycleAwareTorrentServiceConnection( fun close() { scope.cancel() + dispatcher.close() isServiceConnected.value = false binderDeferred.cancel(CancellationException("TorrentServiceConnection closed.")) } @@ -214,8 +219,8 @@ abstract class LifecycleAwareTorrentServiceConnection( */ override suspend fun getBinder(): T { // track cancellation of [scope] - scope.async { - isServiceConnected.first { it } + scope.async { + isServiceConnected.first { it } }.await() return binderDeferred.await() } From d0a7829801aceabc2384fb642922cf77d9350970 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Fri, 7 Feb 2025 10:30:54 +0800 Subject: [PATCH 10/48] Use androidx TestLifecycleOwner --- app/shared/app-data/build.gradle.kts | 1 + .../AbstractTorrentServiceConnectionTest.kt | 103 +----------------- ...ecycleAwareTorrentServiceConnectionTest.kt | 15 +-- 3 files changed, 10 insertions(+), 109 deletions(-) diff --git a/app/shared/app-data/build.gradle.kts b/app/shared/app-data/build.gradle.kts index aba010b6dc..8df8d64530 100644 --- a/app/shared/app-data/build.gradle.kts +++ b/app/shared/app-data/build.gradle.kts @@ -67,6 +67,7 @@ kotlin { } sourceSets.commonTest.dependencies { implementation(projects.utils.uiTesting) + implementation(projects.utils.androidxLifecycleRuntimeTesting) implementation(libs.ktor.client.mock) implementation(libs.turbine) } diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt index 267316903f..a33460568b 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt @@ -9,15 +9,8 @@ package me.him188.ani.app.domain.torrent -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.LifecycleOwner -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain @@ -62,98 +55,4 @@ class TestTorrentServiceConnection( fun triggerServiceDisconnected() { onServiceDisconnected() } -} - -private class TestLifecycle(private val owner: LifecycleOwner) : Lifecycle() { - private val observers = MutableStateFlow(persistentListOf()) - private val _currentState = MutableStateFlow(State.INITIALIZED) - - override val currentState: State - get() = _currentState.value - - override fun addObserver(observer: LifecycleObserver) { - check(observer is DefaultLifecycleObserver) { - "$observer must implement androidx.lifecycle.DefaultLifecycleObserver." - } - observers.update { it.add(observer) } - } - - override fun removeObserver(observer: LifecycleObserver) { - check(observer is DefaultLifecycleObserver) { - "$observer must implement androidx.lifecycle.DefaultLifecycleObserver" - } - observers.update { it.remove(observer) } - } - - fun moveToState(state: State) { - observers.value.forEach { - when (state) { - State.INITIALIZED -> error("cannot move to INITIALIZED state") - State.CREATED -> when (_currentState.value) { - State.INITIALIZED -> it.onCreate(owner) - State.CREATED -> {} - State.STARTED -> it.onStop(owner) - State.RESUMED -> { - it.onPause(owner) - it.onStop(owner) - } - State.DESTROYED -> error("state is DESTROYED and cannot move to others") - } - State.STARTED -> when (_currentState.value) { - State.INITIALIZED -> { - it.onCreate(owner) - it.onStart(owner) - } - - State.CREATED -> it.onStart(owner) - State.STARTED -> {} - State.RESUMED -> it.onPause(owner) - State.DESTROYED -> error("state is DESTROYED and cannot move to others") - } - State.RESUMED -> when (_currentState.value) { - State.INITIALIZED -> { - it.onCreate(owner) - it.onStart(owner) - it.onResume(owner) - } - - State.CREATED -> { - it.onStart(owner) - it.onResume(owner) - } - - State.STARTED -> it.onResume(owner) - State.RESUMED -> {} - State.DESTROYED -> error("state is DESTROYED and cannot move to others") - } - State.DESTROYED -> when (_currentState.value) { - State.INITIALIZED -> { } - State.CREATED -> it.onDestroy(owner) - State.STARTED -> { - it.onStop(owner) - it.onDestroy(owner) - } - - State.RESUMED -> { - it.onPause(owner) - it.onStop(owner) - it.onDestroy(owner) - } - State.DESTROYED -> error("state is DESTROYED and cannot move to others") - } - } - } - _currentState.value = state - } -} - -class TestLifecycleOwner : LifecycleOwner { - private val lifecycleRegistry = TestLifecycle(this) - - override val lifecycle: Lifecycle - get() = lifecycleRegistry - - fun moveTo(state: Lifecycle.State) { - lifecycleRegistry.moveToState(state) - } -} +} \ No newline at end of file diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt index 7dc46c06b4..a2e535b5be 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -10,6 +10,7 @@ package me.him188.ani.app.domain.torrent import androidx.lifecycle.Lifecycle +import androidx.lifecycle.testing.TestLifecycleOwner import app.cash.turbine.test import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -38,7 +39,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect assertFalse(awaitItem(), "Initially, connected should be false.") // trigger on resumed - testLifecycle.moveTo(Lifecycle.State.RESUMED) + testLifecycle.setCurrentState(Lifecycle.State.RESUMED) assertTrue(awaitItem(), "After service is connected, connected should become true.") // completed @@ -62,7 +63,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect assertFalse(awaitItem(), "Initially, connected should be false.") // Move to RESUMED - testLifecycle.moveTo(Lifecycle.State.RESUMED) + testLifecycle.setCurrentState(Lifecycle.State.RESUMED) // Because startService() fails repeatedly in the retry loop, connected never becomes true // We'll watch for a short while and confirm it does not become true @@ -91,8 +92,8 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // The call hasn't returned yet, because we haven't simulated connect advanceTimeBy(200) // Enough to start the service, but not connect assertTrue(!binderDeferred.isCompleted) - - testLifecycle.moveTo(Lifecycle.State.RESUMED) + + testLifecycle.setCurrentState(Lifecycle.State.RESUMED) // Once connected, getBinder should complete with the fake binder val binder = binderDeferred.await() @@ -112,7 +113,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect connection.connected.test { assertFalse(awaitItem(), "Initially, connected should be false.") // Wait for the startService invocation - testLifecycle.moveTo(Lifecycle.State.RESUMED) + testLifecycle.setCurrentState(Lifecycle.State.RESUMED) advanceTimeBy(200) // Now it’s connected assertTrue(awaitItem(), "Service should be connected.") @@ -144,13 +145,13 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect assertFalse(awaitItem(), "Initially, connected should be false.") // Wait for the startService invocation - testLifecycle.moveTo(Lifecycle.State.RESUMED) + testLifecycle.setCurrentState(Lifecycle.State.RESUMED) advanceTimeBy(200) // Now it’s connected assertTrue(awaitItem(), "Service should be connected.") // Move lifecycle to CREATED - testLifecycle.moveTo(Lifecycle.State.CREATED) + testLifecycle.setCurrentState(Lifecycle.State.CREATED) // Now simulate a service disconnect connection.triggerServiceDisconnected() From 3fcbebf134deed0bb4986cd9720c678537ff57b5 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Fri, 7 Feb 2025 10:45:00 +0800 Subject: [PATCH 11/48] add missing `RequiresApi` --- .../kotlin/domain/torrent/service/AniTorrentService.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt index 17dcc5412f..ea8f87ab98 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt @@ -18,6 +18,7 @@ import android.os.IBinder import android.os.PowerManager import android.os.Process import android.os.SystemClock +import androidx.annotation.RequiresApi import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CompletableDeferred @@ -253,6 +254,7 @@ sealed class AniTorrentService : LifecycleService() { * * 在 manifest 的 fgsType 是 mediaPlayback, 没被限制运行 */ +@RequiresApi(34) class AniTorrentServiceApi34 : AniTorrentService() /** From 196b452a27e92e2cf343f36e405ba96ebe7f4424 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Fri, 7 Feb 2025 14:16:31 +0800 Subject: [PATCH 12/48] thread safe --- .../AndroidTorrentServiceConnection.kt | 102 ++++++---- .../torrent/TorrentServiceConnection.kt | 174 +++++++----------- .../AbstractTorrentServiceConnectionTest.kt | 18 +- ...ecycleAwareTorrentServiceConnectionTest.kt | 12 +- 4 files changed, 144 insertions(+), 162 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt index 2b85c9421f..3debb7ac64 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt @@ -18,13 +18,18 @@ import android.content.ServiceConnection import android.os.IBinder import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.suspendCancellableCoroutine import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine import me.him188.ani.app.domain.torrent.LifecycleAwareTorrentServiceConnection -import me.him188.ani.app.domain.torrent.TorrentServiceConnection import me.him188.ani.utils.logging.debug import me.him188.ani.utils.logging.error +import me.him188.ani.utils.logging.logger import me.him188.ani.utils.logging.warn import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume @@ -32,7 +37,6 @@ import kotlin.time.Duration.Companion.minutes /** * 管理与 [AniTorrentService] 的连接并获取 [IRemoteAniTorrentEngine] 远程访问接口. - * 通过 [getBinder] 获取服务接口, 再启动完成并绑定之前将挂起协程. * * 服务连接控制依赖的 lifecycle 应当尽可能大, 所以应该使用 * [ProcessLifecycleOwner][androidx.lifecycle.ProcessLifecycleOwner] @@ -46,54 +50,46 @@ import kotlin.time.Duration.Companion.minutes * @see AniTorrentService.onStartCommand * @see me.him188.ani.android.AniApplication */ +@OptIn(DelicateCoroutinesApi::class) class AndroidTorrentServiceConnection( private val context: Context, private val onRequiredRestartService: () -> ComponentName?, parentCoroutineContext: CoroutineContext = Dispatchers.Default, -) : ServiceConnection, - LifecycleAwareTorrentServiceConnection(parentCoroutineContext) { - private val startupIntentFilter by lazy { IntentFilter(AniTorrentService.INTENT_STARTUP) } - private val acquireWakeLockIntent by lazy { - Intent(context, AniTorrentService.actualServiceClass).apply { - putExtra("acquireWakeLock", 1.minutes.inWholeMilliseconds) - } - } +) : ServiceConnection, LifecycleAwareTorrentServiceConnection( + parentCoroutineContext = parentCoroutineContext, + singleThreadDispatcher = newSingleThreadContext("AndroidTorrentServiceConnection"), +) { + private val logger = logger() + + private val startupIntentFilter = IntentFilter(AniTorrentService.INTENT_STARTUP) + private val binderDeferred = MutableStateFlow(CompletableDeferred()) - /** - * Android 15 限制了 `dataSync` 和 `mediaProcessing` 的 FGS 后台运行时间限制 - */ + private val acquireWakeLockIntent = Intent(context, AniTorrentService.actualServiceClass).apply { + putExtra("acquireWakeLock", 1.minutes.inWholeMilliseconds) + } private var registered = false - private val timeExceedLimitIntentFilter by lazy { IntentFilter(AniTorrentService.INTENT_BACKGROUND_TIMEOUT) } + private val timeExceedLimitIntentFilter = IntentFilter(AniTorrentService.INTENT_BACKGROUND_TIMEOUT) private val timeExceedLimitReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { logger.warn { "Service background time exceeded." } - onServiceDisconnected() + } } - override suspend fun startService(): TorrentServiceConnection.StartResult { - return suspendCancellableCoroutine { cont -> + override suspend fun startService(): IRemoteAniTorrentEngine? { + val startResult = suspendCancellableCoroutine { cont -> val receiver = object : BroadcastReceiver() { override fun onReceive(c: Context?, intent: Intent?) { logger.debug { "Received service startup broadcast: $intent, starting bind service." } context.unregisterReceiver(this) - if (intent?.getBooleanExtra("success", false) != true) { - cont.resume(TorrentServiceConnection.StartResult.FAILED) - return + val result = intent?.getBooleanExtra("success", false) == true + if (!result) { + logger.error { "Failed to start service, service responded start result with false." } } - val bindResult = context.bindService( - Intent(context, AniTorrentService.actualServiceClass), - this@AndroidTorrentServiceConnection, - Context.BIND_ABOVE_CLIENT, - ) - if (!bindResult) { - logger.error { "Failed to bind service, context.bindService returns false." } - cont.resume(TorrentServiceConnection.StartResult.FAILED) - } else { - cont.resume(TorrentServiceConnection.StartResult.STARTED) - } + cont.resume(result) + return } } @@ -106,23 +102,53 @@ class AndroidTorrentServiceConnection( val result = onRequiredRestartService() if (result == null) { - cont.resume(TorrentServiceConnection.StartResult.FAILED) logger.error { "Failed to start service, context.startForegroundService returns null component info." } + context.unregisterReceiver(receiver) + cont.resume(false) } else { logger.debug { "Service started, component name: $result" } } } + if (!startResult) { + return null + } + + val currentDeferred = binderDeferred.value + if (!currentDeferred.isCompleted) { + currentDeferred.cancel() + } + val newDeferred = CompletableDeferred() + binderDeferred.value = newDeferred + + val bindResult = context.bindService( + Intent(context, AniTorrentService.actualServiceClass), + this@AndroidTorrentServiceConnection, + Context.BIND_ABOVE_CLIENT, + ) + if (!bindResult) return null + + return try { + newDeferred.await() + } catch (ex: CancellationException) { + // onServiceDisconnected will cancel the deferred + null + } } override fun onServiceConnected(name: ComponentName?, service: IBinder?) { if (service == null) { logger.error { "Service is connected, but got null binder!" } } - val binder = IRemoteAniTorrentEngine.Stub.asInterface(service) - onServiceConnected(binder) + binderDeferred.value.complete(IRemoteAniTorrentEngine.Stub.asInterface(service)) + } + + override fun onServiceDisconnected(name: ComponentName?) { + binderDeferred.value.cancel(CancellationException("Service disconnected.")) } override fun onPause(owner: LifecycleOwner) { + super.onPause(owner) + // app 到后台的时候注册监听 if (!registered) { ContextCompat.registerReceiver( @@ -140,19 +166,15 @@ class AndroidTorrentServiceConnection( // 大概率是 ServiceStartForegroundException, 服务已经终止了, 不需要再请求 wakelock. logger.warn(ex) { "Failed to acquire wake lock. Service has already died." } } - super.onPause(owner) } override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + // app 到前台的时候取消监听 if (registered) { context.unregisterReceiver(timeExceedLimitReceiver) registered = false } - super.onResume(owner) - } - - override fun onServiceDisconnected(name: ComponentName?) { - onServiceDisconnected() } } \ No newline at end of file diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt index 1a0b05fc03..66f000a9f7 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt @@ -16,14 +16,12 @@ import androidx.lifecycle.LifecycleOwner import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.async import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -37,48 +35,23 @@ import kotlin.coroutines.EmptyCoroutineContext /** * torrent 服务与 APP 通信接口. + * + * 此接口仅负责服务与 APP 之间的通信, 不负责服务的启动和终止 */ interface TorrentServiceConnection { /** * 当前服务是否已连接, 只有在已连接的状态才能获取通信接口. * - * 若变为 `false`, 则服务通信接口将变得不可用, 调用任何通信接口的方法将会导致不可预测的结果. - * 此时需要再次调用 [startService] 来重新启动服务. + * 若变为 `false`, 则服务通信接口将变得不可用, 可能需要实现类重新启动服务. */ val connected: StateFlow - /** - * 启动服务. 调用此方法后, 服务将会启动并尽快连接. - * - * 若返回了 [StartResult.STARTED] 或 [StartResult.FAILED], - * [connected] 将在未来变为 `true`, 届时 [getBinder] 将会立刻返回服务通信接口. - */ - suspend fun startService(): StartResult - /** * 获取通信接口. 如果服务未连接, 则会挂起直到服务连接成功. + * + * 这个函数是线程安全的. */ suspend fun getBinder(): T - - /** - * Start result of [startService] - */ - enum class StartResult { - /** - * Service is started, binder should be later retrieved by [onServiceConnected] - */ - STARTED, - - /** - * Service is already running - */ - ALREADY_RUNNING, - - /** - * Service started failed. - */ - FAILED - } } /** @@ -90,71 +63,52 @@ interface TorrentServiceConnection { * * 实现细节: * - 实现 [startService] 方法, 用于实际的启动服务, 并且要连接服务. - * - 服务连接完成,调用 [onServiceConnected] 传入服务通信接口对象. - * - 如果服务断开连接了, 调用 [onServiceDisconnected], 会自动判断是否需要重连. + * + * @param singleThreadDispatcher 用于执行内部逻辑的调度器, 需要使用单线程来保证内部逻辑的线程安全. */ abstract class LifecycleAwareTorrentServiceConnection( parentCoroutineContext: CoroutineContext = EmptyCoroutineContext, + singleThreadDispatcher: CoroutineDispatcher, ) : DefaultLifecycleObserver, TorrentServiceConnection { - protected val logger = logger(this::class.simpleName ?: "TorrentServiceConnection") - private val scope = parentCoroutineContext.childScope() + private val logger = logger(this::class.simpleName ?: "TorrentServiceConnection") - private val lock = Mutex() + // we assert it is a single thread dispatcher + private val scope = parentCoroutineContext.childScope(singleThreadDispatcher) + private var binderDeferred by atomic(CompletableDeferred()) private val isAtForeground: MutableStateFlow = MutableStateFlow(false) private val isServiceConnected: MutableStateFlow = MutableStateFlow(false) + private val startServiceLock = Mutex() override val connected: StateFlow = isServiceConnected /** - * 启动服务并返回启动结果. - * - * 注意:此处同步返回的结果仅代表服务是否成功启动, 不代表服务是否已连接. - * 换句话说, 此方法返回 [StartResult.STARTED][TorrentServiceConnection.StartResult.STARTED] 或 [StartResult.ALREADY_RUNNING][TorrentServiceConnection.StartResult.ALREADY_RUNNING] 后, - * 实现类必须尽快调用 [onServiceConnected] 并传入服务通信接口对象. + * 启动服务并返回[服务通信对象][T]接口, 若返回 null 代表启动失败. * - * - * @return `true` if the service is started successfully, `false` otherwise. + * 这个方法将在 `singleThreadDispatcher` 执行, 并且同时只有一个在执行. */ - abstract override suspend fun startService(): TorrentServiceConnection.StartResult - - /** - * 服务已连接, 服务通信对象一定可用. - * 无论当前 [Lifecycle] 什么状态都要应用新的 [binder]. - */ - protected fun onServiceConnected(binder: T) { - scope.launch(CoroutineName("TorrentServiceConnection - On Service Connected")) { - lock.withLock { - logger.debug { "Service is connected, got binder $binder" } - if (binderDeferred.isCompleted) { - binderDeferred = CompletableDeferred(binder) - } else { - binderDeferred.complete(binder) - } - isServiceConnected.value = true - } - } - } - + abstract suspend fun startService(): T? + /** * 服务已断开连接, 通信对象变为不可用. * 如果目前 APP 还在前台, 就要尝试重启并重连服务. */ - protected fun onServiceDisconnected() { + fun onServiceDisconnected() { scope.launch(CoroutineName("TorrentServiceConnection - On Service Disconnected")) { - if (!isServiceConnected.value) return@launch - lock.withLock { - if (!isServiceConnected.value) return@launch - isServiceConnected.value = false - - binderDeferred.cancel(CancellationException("Service disconnected.")) - binderDeferred = CompletableDeferred() - - if (isAtForeground.value) { - logger.debug { "Service is disconnected while app is at foreground, restarting." } - startServiceWithRetry() - } + if (!isServiceConnected.value) { + // 已经是断开状态,直接忽略 + return@launch + } + logger.debug { "Service disconnected. Marking state as disconnected." } + isServiceConnected.value = false + binderDeferred.cancel(CancellationException("Service disconnected.")) + binderDeferred = CompletableDeferred() + + // 若应用仍想要连接,则重新启动 + if (isAtForeground.value) { + logger.debug { "App is in foreground, restarting service connection..." } + startServiceWithRetry() } } } @@ -166,13 +120,8 @@ abstract class LifecycleAwareTorrentServiceConnection( override fun onResume(owner: LifecycleOwner) { scope.launch(CoroutineName("TorrentServiceConnection - Lifecycle Resume")) { isAtForeground.value = true - // 服务已经连接了, 不需要再次处理 - if (isServiceConnected.value) return@launch - - lock.withLock { - if (isServiceConnected.value) return@launch - - logger.debug { "Service is not started, starting." } + if (!isServiceConnected.value) { + logger.debug { "Lifecycle resume: Service is not connected, start connecting..." } startServiceWithRetry() } } @@ -181,42 +130,59 @@ abstract class LifecycleAwareTorrentServiceConnection( @CallSuper override fun onPause(owner: LifecycleOwner) { scope.launch(CoroutineName("TorrentServiceConnection - Lifecycle Pause")) { - lock.withLock { - isAtForeground.value = false - } + isAtForeground.value = false + logger.debug { "Lifecycle pause: App moved to background." } } } - private suspend fun startServiceWithRetry(maxAttempts: Int = Int.MAX_VALUE) { - var retries = 0 - while (retries <= maxAttempts) { - val startResult = startService() - if (startResult == TorrentServiceConnection.StartResult.FAILED) { - logger.warn { "[#$retries] Failed to start service." } - retries++ - delay(7500) + private suspend fun startServiceWithRetry( + maxAttempts: Int = 3, // 可根据需求设置 + delayMillisBetweenAttempts: Long = 2500 + ) { + var attempt = 0 + while (attempt < maxAttempts && isAtForeground.value && !isServiceConnected.value) { + val binder = startServiceLock.withLock { + if (!isAtForeground.value || isServiceConnected.value) { + logger.debug { "Service is already connected or app is not at foreground." } + return + } + startService() + } + if (binder == null) { + logger.warn { "[#$attempt] startService() returned null binder, retry after $delayMillisBetweenAttempts ms" } + attempt++ + delay(delayMillisBetweenAttempts) } else { + logger.debug { "Service connected successfully: $binder" } + isServiceConnected.value = true + if (binderDeferred.isCompleted) { + binderDeferred = CompletableDeferred() + } + binderDeferred.complete(binder) return } } - logger.error { "Failed to start service after $maxAttempts retries." } + if (!isServiceConnected.value) { + logger.error { "Failed to connect service after $maxAttempts retries." } + } } fun close() { - scope.cancel() - isServiceConnected.value = false - binderDeferred.cancel(CancellationException("TorrentServiceConnection closed.")) + scope.launch { + logger.debug { "close(): Cancel scope, mark disconnected." } + isServiceConnected.value = false + binderDeferred.cancel(CancellationException("Connection closed.")) + scope.cancel() + } } /** * 获取当前 binder 对象. - * 如果服务未连接, 则会挂起直到服务连接成功; 如果连接已[关闭][close], 则直接抛出 [CancellationException]. + * + * - 如果服务还未连接, 此函数将挂起. */ override suspend fun getBinder(): T { // track cancellation of [scope] - scope.async { - isServiceConnected.first { it } - }.await() return binderDeferred.await() } } \ No newline at end of file diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt index a33460568b..607424a834 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt @@ -11,11 +11,9 @@ package me.him188.ani.app.domain.torrent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain -import me.him188.ani.utils.coroutines.childScope import kotlin.coroutines.CoroutineContext import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -32,23 +30,19 @@ abstract class AbstractTorrentServiceConnectionTest { } } -class TestTorrentServiceConnection( +class TestLifecycleTorrentServiceConnection( private val shouldStartServiceSucceed: Boolean = true, coroutineContext: CoroutineContext = Dispatchers.Default, -) : LifecycleAwareTorrentServiceConnection(coroutineContext) { - private val scope = coroutineContext.childScope() +) : LifecycleAwareTorrentServiceConnection(coroutineContext, Dispatchers.Default) { private val fakeBinder = "FAKE_BINDER_OBJECT" - override suspend fun startService(): TorrentServiceConnection.StartResult { + override suspend fun startService(): String? { delay(100) return if (shouldStartServiceSucceed) { - scope.launch { // simulate automatic connection after start service succeeded. - delay(500) - onServiceConnected(fakeBinder) - } - TorrentServiceConnection.StartResult.STARTED + delay(500) + return fakeBinder } else { - TorrentServiceConnection.StartResult.FAILED + null } } diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt index a2e535b5be..a887530e42 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -28,7 +28,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `service starts on resume - success`() = runTest { val testLifecycle = TestLifecycleOwner() - val connection = TestTorrentServiceConnection( + val connection = TestLifecycleTorrentServiceConnection( shouldStartServiceSucceed = true, coroutineContext = backgroundScope.coroutineContext, ) @@ -50,7 +50,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `service starts on resume - fails to start service`() = runTest { val testLifecycle = TestLifecycleOwner() - val connection = TestTorrentServiceConnection( + val connection = TestLifecycleTorrentServiceConnection( shouldStartServiceSucceed = false, // Force a failure coroutineContext = backgroundScope.coroutineContext, ) @@ -77,7 +77,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `getBinder suspends until service is connected`() = runTest { val testLifecycle = TestLifecycleOwner() - val connection = TestTorrentServiceConnection( + val connection = TestLifecycleTorrentServiceConnection( shouldStartServiceSucceed = true, coroutineContext = backgroundScope.coroutineContext, ) @@ -103,7 +103,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `service disconnect triggers automatic restart if lifecycle is RESUMED`() = runTest { val testLifecycle = TestLifecycleOwner() - val connection = TestTorrentServiceConnection( + val connection = TestLifecycleTorrentServiceConnection( shouldStartServiceSucceed = true, coroutineContext = backgroundScope.coroutineContext, ) @@ -135,7 +135,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `service disconnect does not restart if lifecycle is only CREATED`() = runTest { val testLifecycle = TestLifecycleOwner() - val connection = TestTorrentServiceConnection( + val connection = TestLifecycleTorrentServiceConnection( shouldStartServiceSucceed = true, coroutineContext = backgroundScope.coroutineContext, ) @@ -163,7 +163,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `close cancels all coroutines and flows`() = runTest { - val connection = TestTorrentServiceConnection( + val connection = TestLifecycleTorrentServiceConnection( shouldStartServiceSucceed = true, coroutineContext = backgroundScope.coroutineContext, ) From eb2bc7fee6b7944ef2d472934d8202aff6dafabe Mon Sep 17 00:00:00 2001 From: StageGuard Date: Fri, 7 Feb 2025 14:17:02 +0800 Subject: [PATCH 13/48] doc --- .../kotlin/domain/torrent/TorrentServiceConnection.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt index 66f000a9f7..6df161b00b 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt @@ -34,9 +34,9 @@ import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext /** - * torrent 服务与 APP 通信接口. + * torrent 服务与 APP 通信接口. T 为通信接口的类型 * - * 此接口仅负责服务与 APP 之间的通信, 不负责服务的启动和终止 + * 此接口仅负责服务与 APP 之间的通信, 不负责服务的启动和终止. */ interface TorrentServiceConnection { /** From 051360f4a966ed4fdd89b33ddb8e4208c4393235 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Fri, 7 Feb 2025 14:22:26 +0800 Subject: [PATCH 14/48] optimize --- app/android/src/main/kotlin/AniApplication.kt | 2 +- .../torrent/service/AndroidTorrentServiceConnection.kt | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/android/src/main/kotlin/AniApplication.kt b/app/android/src/main/kotlin/AniApplication.kt index f63a3c018e..d710f33c00 100644 --- a/app/android/src/main/kotlin/AniApplication.kt +++ b/app/android/src/main/kotlin/AniApplication.kt @@ -88,7 +88,7 @@ class AniApplication : Application() { val scope = createAppRootCoroutineScope() val torrentServiceConnection = AndroidTorrentServiceConnection( this, - onRequiredRestartService = { startAniTorrentService() }, + ::startAniTorrentService, scope.coroutineContext, ) if (FEATURE_USE_TORRENT_SERVICE) { diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt index 3debb7ac64..0f7e119f9e 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt @@ -9,6 +9,7 @@ package me.him188.ani.app.domain.torrent.service +import android.app.ForegroundServiceStartNotAllowedException import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context @@ -16,6 +17,7 @@ import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection import android.os.IBinder +import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.CancellationException @@ -146,6 +148,7 @@ class AndroidTorrentServiceConnection( binderDeferred.value.cancel(CancellationException("Service disconnected.")) } + @RequiresApi(31) override fun onPause(owner: LifecycleOwner) { super.onPause(owner) @@ -162,7 +165,7 @@ class AndroidTorrentServiceConnection( try { // 请求 wake lock, 如果在 app 中息屏可以保证 service 正常跑 [acquireWakeLockIntent] 分钟. context.startService(acquireWakeLockIntent) - } catch (ex: IllegalStateException) { + } catch (ex: ForegroundServiceStartNotAllowedException) { // 大概率是 ServiceStartForegroundException, 服务已经终止了, 不需要再请求 wakelock. logger.warn(ex) { "Failed to acquire wake lock. Service has already died." } } From b86d8977345d80895af4f378f0df19324b5fd5fc Mon Sep 17 00:00:00 2001 From: StageGuard Date: Fri, 7 Feb 2025 14:24:13 +0800 Subject: [PATCH 15/48] fix --- .../domain/torrent/service/AndroidTorrentServiceConnection.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt index 0f7e119f9e..c1b3562c29 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt @@ -74,7 +74,7 @@ class AndroidTorrentServiceConnection( private val timeExceedLimitReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { logger.warn { "Service background time exceeded." } - + onServiceDisconnected() } } @@ -146,6 +146,7 @@ class AndroidTorrentServiceConnection( override fun onServiceDisconnected(name: ComponentName?) { binderDeferred.value.cancel(CancellationException("Service disconnected.")) + onServiceDisconnected() } @RequiresApi(31) From dcfe8533f936b98c54d77321b12efe7d003ce232 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Fri, 7 Feb 2025 15:49:07 +0800 Subject: [PATCH 16/48] optimize --- .../kotlin/domain/torrent/TorrentServiceConnection.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt index 6df161b00b..fa981fe5b9 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt @@ -100,15 +100,17 @@ abstract class LifecycleAwareTorrentServiceConnection( // 已经是断开状态,直接忽略 return@launch } - logger.debug { "Service disconnected. Marking state as disconnected." } isServiceConnected.value = false binderDeferred.cancel(CancellationException("Service disconnected.")) binderDeferred = CompletableDeferred() // 若应用仍想要连接,则重新启动 if (isAtForeground.value) { - logger.debug { "App is in foreground, restarting service connection..." } + logger.debug { "Service is disconnected and app is in foreground, restarting service connection in 3s..." } + delay(3000) startServiceWithRetry() + } else { + logger.debug { "Service is disconnected and app is in background." } } } } @@ -136,7 +138,7 @@ abstract class LifecycleAwareTorrentServiceConnection( } private suspend fun startServiceWithRetry( - maxAttempts: Int = 3, // 可根据需求设置 + maxAttempts: Int = Int.MAX_VALUE, // 可根据需求设置 delayMillisBetweenAttempts: Long = 2500 ) { var attempt = 0 @@ -154,11 +156,11 @@ abstract class LifecycleAwareTorrentServiceConnection( delay(delayMillisBetweenAttempts) } else { logger.debug { "Service connected successfully: $binder" } - isServiceConnected.value = true if (binderDeferred.isCompleted) { binderDeferred = CompletableDeferred() } binderDeferred.complete(binder) + isServiceConnected.value = true return } } From 616bde169b3824c4cd3c7313a5bbb7ecf1364931 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Fri, 7 Feb 2025 17:49:02 +0800 Subject: [PATCH 17/48] run settings collector in single thread --- app/android/src/main/kotlin/AndroidModules.kt | 4 ++++ .../torrent/client/RemoteAnitorrentEngine.kt | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/android/src/main/kotlin/AndroidModules.kt b/app/android/src/main/kotlin/AndroidModules.kt index 5a28c640aa..198844c6cf 100644 --- a/app/android/src/main/kotlin/AndroidModules.kt +++ b/app/android/src/main/kotlin/AndroidModules.kt @@ -13,9 +13,11 @@ import android.content.Intent import android.widget.Toast import androidx.core.app.NotificationManagerCompat import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.runBlocking import kotlinx.io.files.Path import me.him188.ani.android.activity.MainActivity @@ -202,6 +204,8 @@ fun getAndroidModules( peerFilterSettings, saveDir, parentCoroutineContext, + @OptIn(DelicateCoroutinesApi::class) + newSingleThreadContext("RemoteAnitorrentEngine"), ) } } diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt index 122d8a4a41..f136465d6f 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt @@ -12,6 +12,7 @@ package me.him188.ani.app.domain.torrent.client import android.os.Build import android.os.IInterface import androidx.annotation.RequiresApi +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -43,6 +44,13 @@ import me.him188.ani.utils.io.absolutePath import me.him188.ani.utils.logging.logger import kotlin.coroutines.CoroutineContext +/** + * Create a remote torrent engine based on Android RPC services. + * + * @param singleThreadDispatcher Dispatcher for collecting client settings to remote. + * If this dispatcher has risk of being blocked, settings collector will not work. + * This may leading to endless blocking for whole app. + */ @RequiresApi(Build.VERSION_CODES.O_MR1) class RemoteAnitorrentEngine( private val connection: TorrentServiceConnection, @@ -51,10 +59,11 @@ class RemoteAnitorrentEngine( peerFilterConfig: Flow, saveDir: SystemPath, parentCoroutineContext: CoroutineContext, + singleThreadDispatcher: CoroutineDispatcher, ) : TorrentEngine { private val logger = logger() - private val scope = parentCoroutineContext.childScope() + private val scope = parentCoroutineContext.childScope(singleThreadDispatcher) private val fetchRemoteScope = parentCoroutineContext.childScope( CoroutineName("RemoteAnitorrentEngineFetchRemote") + Dispatchers.IO_, ) @@ -70,8 +79,8 @@ class RemoteAnitorrentEngine( get() = flowOf(true) override val location: MediaSourceLocation = MediaSourceLocation.Local - - private val json = Json { + + private val json = Json { ignoreUnknownKeys = true encodeDefaults = true } From 0429de240e1e4ef9b5b36cc31252eb00e825517e Mon Sep 17 00:00:00 2001 From: StageGuard Date: Fri, 7 Feb 2025 18:57:51 +0800 Subject: [PATCH 18/48] use lifecycle.repeatOnLifecycle --- app/android/src/main/kotlin/AndroidModules.kt | 3 +- app/android/src/main/kotlin/AniApplication.kt | 15 ++-- ...nection.kt => ServiceConnectionManager.kt} | 71 +++++++++++------ .../torrent/TorrentServiceConnection.kt | 62 +++++++-------- .../AbstractTorrentServiceConnectionTest.kt | 34 +++----- ...ecycleAwareTorrentServiceConnectionTest.kt | 79 ++++++++++--------- 6 files changed, 142 insertions(+), 122 deletions(-) rename app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/{AndroidTorrentServiceConnection.kt => ServiceConnectionManager.kt} (79%) diff --git a/app/android/src/main/kotlin/AndroidModules.kt b/app/android/src/main/kotlin/AndroidModules.kt index 198844c6cf..a04280276b 100644 --- a/app/android/src/main/kotlin/AndroidModules.kt +++ b/app/android/src/main/kotlin/AndroidModules.kt @@ -43,7 +43,6 @@ import me.him188.ani.app.domain.torrent.TorrentManager import me.him188.ani.app.domain.torrent.TorrentServiceConnection import me.him188.ani.app.domain.torrent.client.RemoteAnitorrentEngine import me.him188.ani.app.domain.torrent.peer.PeerFilterSettings -import me.him188.ani.app.domain.torrent.service.AndroidTorrentServiceConnection import me.him188.ani.app.domain.torrent.service.AniTorrentService import me.him188.ani.app.navigation.BrowserNavigator import me.him188.ani.app.platform.AndroidPermissionManager @@ -80,7 +79,7 @@ import kotlin.system.exitProcess fun getAndroidModules( defaultTorrentCacheDir: File, - torrentServiceConnection: AndroidTorrentServiceConnection, + torrentServiceConnection: TorrentServiceConnection, coroutineScope: CoroutineScope, ) = module { single { diff --git a/app/android/src/main/kotlin/AniApplication.kt b/app/android/src/main/kotlin/AniApplication.kt index d710f33c00..15106c8728 100644 --- a/app/android/src/main/kotlin/AniApplication.kt +++ b/app/android/src/main/kotlin/AniApplication.kt @@ -22,9 +22,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.him188.ani.android.activity.MainActivity import me.him188.ani.app.domain.media.cache.MediaCacheNotificationTask +import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine import me.him188.ani.app.domain.torrent.TorrentManager -import me.him188.ani.app.domain.torrent.service.AndroidTorrentServiceConnection import me.him188.ani.app.domain.torrent.service.AniTorrentService +import me.him188.ani.app.domain.torrent.service.ServiceConnectionManager import me.him188.ani.app.platform.AndroidLoggingConfigurator import me.him188.ani.app.platform.JvmLogHelper import me.him188.ani.app.platform.createAppRootCoroutineScope @@ -86,15 +87,18 @@ class AniApplication : Application() { val scope = createAppRootCoroutineScope() - val torrentServiceConnection = AndroidTorrentServiceConnection( + val connectionManager = ServiceConnectionManager( this, ::startAniTorrentService, + { IRemoteAniTorrentEngine.Stub.asInterface(it) }, scope.coroutineContext, + ProcessLifecycleOwner.get().lifecycle, ) + instance = Instance() + if (FEATURE_USE_TORRENT_SERVICE) { - ProcessLifecycleOwner.get().lifecycle.addObserver(torrentServiceConnection) + connectionManager.startLifecycleLoop() } - instance = Instance() scope.launch(Dispatchers.IO_) { runCatching { @@ -112,7 +116,8 @@ class AniApplication : Application() { startKoin { androidContext(this@AniApplication) modules(getCommonKoinModule({ this@AniApplication }, scope)) - modules(getAndroidModules(defaultTorrentCacheDir, torrentServiceConnection, scope)) + + modules(getAndroidModules(defaultTorrentCacheDir, connectionManager.connection, scope)) }.startCommonKoinModule(scope) val koin = getKoin() diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt similarity index 79% rename from app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt rename to app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index c1b3562c29..8fe8ff3418 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AndroidTorrentServiceConnection.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -19,6 +19,8 @@ import android.content.ServiceConnection import android.os.IBinder import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -29,6 +31,7 @@ import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.suspendCancellableCoroutine import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine import me.him188.ani.app.domain.torrent.LifecycleAwareTorrentServiceConnection +import me.him188.ani.app.domain.torrent.TorrentServiceConnection import me.him188.ani.utils.logging.debug import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.logger @@ -45,7 +48,7 @@ import kotlin.time.Duration.Companion.minutes * 或其他可以涵盖 app 全局生命周期的自定义 [LifecycleOwner] 来管理服务连接. * 不能使用 [Activity][android.app.Activity] (例如 [ComponentActivity][androidx.core.app.ComponentActivity]) * 的生命周期, 因为在屏幕旋转 (例如竖屏转全屏播放) 的时候 Activity 可能会摧毁并重新创建, - * 这会导致 [AndroidTorrentServiceConnection] 错误地重新绑定服务或重启服务. + * 这会导致 [ServiceConnectionManager] 错误地重新绑定服务或重启服务. * * @see androidx.lifecycle.ProcessLifecycleOwner * @see ServiceConnection @@ -53,32 +56,38 @@ import kotlin.time.Duration.Companion.minutes * @see me.him188.ani.android.AniApplication */ @OptIn(DelicateCoroutinesApi::class) -class AndroidTorrentServiceConnection( +class ServiceConnectionManager( private val context: Context, private val onRequiredRestartService: () -> ComponentName?, + private val mapBinder: (IBinder?) -> T?, parentCoroutineContext: CoroutineContext = Dispatchers.Default, -) : ServiceConnection, LifecycleAwareTorrentServiceConnection( - parentCoroutineContext = parentCoroutineContext, - singleThreadDispatcher = newSingleThreadContext("AndroidTorrentServiceConnection"), -) { - private val logger = logger() + private val lifecycle: Lifecycle, +) : ServiceConnection { + private val logger = logger>() private val startupIntentFilter = IntentFilter(AniTorrentService.INTENT_STARTUP) - private val binderDeferred = MutableStateFlow(CompletableDeferred()) + private val binderDeferred = MutableStateFlow(CompletableDeferred()) - private val acquireWakeLockIntent = Intent(context, AniTorrentService.actualServiceClass).apply { - putExtra("acquireWakeLock", 1.minutes.inWholeMilliseconds) + private val _connection = LifecycleAwareTorrentServiceConnection( + parentCoroutineContext = parentCoroutineContext, + singleThreadDispatcher = newSingleThreadContext("AndroidTorrentServiceConnection"), + lifecycle = lifecycle, + startService = ::startService, + ) + + val connection: TorrentServiceConnection get() = _connection + + private val serviceTimeLimitObserver = ForegroundServiceTimeLimitObserver(context) { + logger.warn { "Service background time exceeded." } + _connection.onServiceDisconnected() } - private var registered = false - private val timeExceedLimitIntentFilter = IntentFilter(AniTorrentService.INTENT_BACKGROUND_TIMEOUT) - private val timeExceedLimitReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - logger.warn { "Service background time exceeded." } - onServiceDisconnected() - } + + fun startLifecycleLoop() { + _connection.startLifecycleLoop() + lifecycle.addObserver(serviceTimeLimitObserver) } - override suspend fun startService(): IRemoteAniTorrentEngine? { + private suspend fun startService(): T? { val startResult = suspendCancellableCoroutine { cont -> val receiver = object : BroadcastReceiver() { override fun onReceive(c: Context?, intent: Intent?) { @@ -119,12 +128,12 @@ class AndroidTorrentServiceConnection( if (!currentDeferred.isCompleted) { currentDeferred.cancel() } - val newDeferred = CompletableDeferred() + val newDeferred = CompletableDeferred() binderDeferred.value = newDeferred val bindResult = context.bindService( Intent(context, AniTorrentService.actualServiceClass), - this@AndroidTorrentServiceConnection, + this@ServiceConnectionManager, Context.BIND_ABOVE_CLIENT, ) if (!bindResult) return null @@ -141,12 +150,30 @@ class AndroidTorrentServiceConnection( if (service == null) { logger.error { "Service is connected, but got null binder!" } } - binderDeferred.value.complete(IRemoteAniTorrentEngine.Stub.asInterface(service)) + binderDeferred.value.complete(mapBinder(service)) } override fun onServiceDisconnected(name: ComponentName?) { binderDeferred.value.cancel(CancellationException("Service disconnected.")) - onServiceDisconnected() + _connection.onServiceDisconnected() + } +} + +private class ForegroundServiceTimeLimitObserver( + private val context: Context, + onServiceTimeLimitExceeded: () -> Unit +) : DefaultLifecycleObserver { + private val logger = logger() + + private val acquireWakeLockIntent = Intent(context, AniTorrentService.actualServiceClass).apply { + putExtra("acquireWakeLock", 1.minutes.inWholeMilliseconds) + } + private var registered = false + private val timeExceedLimitIntentFilter = IntentFilter(AniTorrentService.INTENT_BACKGROUND_TIMEOUT) + private val timeExceedLimitReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + onServiceTimeLimitExceeded() + } } @RequiresApi(31) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt index fa981fe5b9..8becf3c86d 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt @@ -9,15 +9,14 @@ package me.him188.ani.app.domain.torrent -import androidx.annotation.CallSuper -import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -64,12 +63,16 @@ interface TorrentServiceConnection { * 实现细节: * - 实现 [startService] 方法, 用于实际的启动服务, 并且要连接服务. * + * @param startService 启动服务并返回[服务通信对象][T]接口, 若返回 null 代表启动失败. + * 这个方法将在 `singleThreadDispatcher` 执行, 并且同时只有一个在执行. * @param singleThreadDispatcher 用于执行内部逻辑的调度器, 需要使用单线程来保证内部逻辑的线程安全. */ -abstract class LifecycleAwareTorrentServiceConnection( +class LifecycleAwareTorrentServiceConnection( parentCoroutineContext: CoroutineContext = EmptyCoroutineContext, singleThreadDispatcher: CoroutineDispatcher, -) : DefaultLifecycleObserver, TorrentServiceConnection { + private val lifecycle: Lifecycle, + private val startService: suspend () -> T?, +) : TorrentServiceConnection { private val logger = logger(this::class.simpleName ?: "TorrentServiceConnection") // we assert it is a single thread dispatcher @@ -83,19 +86,31 @@ abstract class LifecycleAwareTorrentServiceConnection( override val connected: StateFlow = isServiceConnected - /** - * 启动服务并返回[服务通信对象][T]接口, 若返回 null 代表启动失败. - * - * 这个方法将在 `singleThreadDispatcher` 执行, 并且同时只有一个在执行. - */ - abstract suspend fun startService(): T? + fun startLifecycleLoop() { + scope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + isAtForeground.value = true + try { + if (!isServiceConnected.value) { + logger.debug { "Lifecycle resume: Service is not connected, start connecting..." } + startServiceWithRetry() + } + + awaitCancellation() + } catch (_: CancellationException) { + isAtForeground.value = false + logger.debug { "Lifecycle pause: App moved to background." } + } + } + } + } /** * 服务已断开连接, 通信对象变为不可用. * 如果目前 APP 还在前台, 就要尝试重启并重连服务. */ fun onServiceDisconnected() { - scope.launch(CoroutineName("TorrentServiceConnection - On Service Disconnected")) { + scope.launch(CoroutineName("TorrentServiceConnection - Lifecycle")) { if (!isServiceConnected.value) { // 已经是断开状态,直接忽略 return@launch @@ -107,7 +122,6 @@ abstract class LifecycleAwareTorrentServiceConnection( // 若应用仍想要连接,则重新启动 if (isAtForeground.value) { logger.debug { "Service is disconnected and app is in foreground, restarting service connection in 3s..." } - delay(3000) startServiceWithRetry() } else { logger.debug { "Service is disconnected and app is in background." } @@ -115,28 +129,6 @@ abstract class LifecycleAwareTorrentServiceConnection( } } - /** - * APP 已进入前台, 此时需要保证服务可用. - */ - @CallSuper - override fun onResume(owner: LifecycleOwner) { - scope.launch(CoroutineName("TorrentServiceConnection - Lifecycle Resume")) { - isAtForeground.value = true - if (!isServiceConnected.value) { - logger.debug { "Lifecycle resume: Service is not connected, start connecting..." } - startServiceWithRetry() - } - } - } - - @CallSuper - override fun onPause(owner: LifecycleOwner) { - scope.launch(CoroutineName("TorrentServiceConnection - Lifecycle Pause")) { - isAtForeground.value = false - logger.debug { "Lifecycle pause: App moved to background." } - } - } - private suspend fun startServiceWithRetry( maxAttempts: Int = Int.MAX_VALUE, // 可根据需求设置 delayMillisBetweenAttempts: Long = 2500 diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt index 607424a834..d926b6323e 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt @@ -14,11 +14,22 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain -import kotlin.coroutines.CoroutineContext import kotlin.test.AfterTest import kotlin.test.BeforeTest abstract class AbstractTorrentServiceConnectionTest { + protected val fakeBinder = "FAKE_BINDER_OBJECT" + + protected val startServiceWithSuccess: suspend () -> String? = { + delay(300) + fakeBinder + } + + protected val startServiceWithFail: suspend () -> String? = { + delay(100) + null + } + @BeforeTest fun installDispatcher() { Dispatchers.setMain(StandardTestDispatcher()) @@ -29,24 +40,3 @@ abstract class AbstractTorrentServiceConnectionTest { Dispatchers.resetMain() } } - -class TestLifecycleTorrentServiceConnection( - private val shouldStartServiceSucceed: Boolean = true, - coroutineContext: CoroutineContext = Dispatchers.Default, -) : LifecycleAwareTorrentServiceConnection(coroutineContext, Dispatchers.Default) { - private val fakeBinder = "FAKE_BINDER_OBJECT" - - override suspend fun startService(): String? { - delay(100) - return if (shouldStartServiceSucceed) { - delay(500) - return fakeBinder - } else { - null - } - } - - fun triggerServiceDisconnected() { - onServiceDisconnected() - } -} \ No newline at end of file diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt index a887530e42..9b689d9bd0 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.testing.TestLifecycleOwner import app.cash.turbine.test import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy @@ -25,15 +26,16 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnectionTest() { + @Test fun `service starts on resume - success`() = runTest { val testLifecycle = TestLifecycleOwner() - val connection = TestLifecycleTorrentServiceConnection( - shouldStartServiceSucceed = true, - coroutineContext = backgroundScope.coroutineContext, - ) - - testLifecycle.lifecycle.addObserver(connection) + val connection = LifecycleAwareTorrentServiceConnection( + parentCoroutineContext = backgroundScope.coroutineContext, + singleThreadDispatcher = Dispatchers.Default, + lifecycle = testLifecycle.lifecycle, + startService = startServiceWithSuccess, + ).also { it.startLifecycleLoop() } connection.connected.test { assertFalse(awaitItem(), "Initially, connected should be false.") @@ -50,12 +52,12 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `service starts on resume - fails to start service`() = runTest { val testLifecycle = TestLifecycleOwner() - val connection = TestLifecycleTorrentServiceConnection( - shouldStartServiceSucceed = false, // Force a failure - coroutineContext = backgroundScope.coroutineContext, - ) - - testLifecycle.lifecycle.addObserver(connection) + val connection = LifecycleAwareTorrentServiceConnection( + parentCoroutineContext = backgroundScope.coroutineContext, + singleThreadDispatcher = Dispatchers.Default, + lifecycle = testLifecycle.lifecycle, + startService = startServiceWithFail, + ).also { it.startLifecycleLoop() } // The .connected flow should remain false, even after we move to resumed, // because startService() always fails, no successful onServiceConnected() is triggered. @@ -77,12 +79,12 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `getBinder suspends until service is connected`() = runTest { val testLifecycle = TestLifecycleOwner() - val connection = TestLifecycleTorrentServiceConnection( - shouldStartServiceSucceed = true, - coroutineContext = backgroundScope.coroutineContext, - ) - - testLifecycle.lifecycle.addObserver(connection) + val connection = LifecycleAwareTorrentServiceConnection( + parentCoroutineContext = backgroundScope.coroutineContext, + singleThreadDispatcher = Dispatchers.Default, + lifecycle = testLifecycle.lifecycle, + startService = startServiceWithSuccess, + ).also { it.startLifecycleLoop() } // Start a coroutine that calls getBinder val binderDeferred = async { @@ -97,18 +99,18 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // Once connected, getBinder should complete with the fake binder val binder = binderDeferred.await() - assertEquals("FAKE_BINDER_OBJECT", binder) + assertEquals(fakeBinder, binder) } @Test fun `service disconnect triggers automatic restart if lifecycle is RESUMED`() = runTest { val testLifecycle = TestLifecycleOwner() - val connection = TestLifecycleTorrentServiceConnection( - shouldStartServiceSucceed = true, - coroutineContext = backgroundScope.coroutineContext, - ) - - testLifecycle.lifecycle.addObserver(connection) + val connection = LifecycleAwareTorrentServiceConnection( + parentCoroutineContext = backgroundScope.coroutineContext, + singleThreadDispatcher = Dispatchers.Default, + lifecycle = testLifecycle.lifecycle, + startService = startServiceWithSuccess, + ).also { it.startLifecycleLoop() } connection.connected.test { assertFalse(awaitItem(), "Initially, connected should be false.") @@ -119,7 +121,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect assertTrue(awaitItem(), "Service should be connected.") // Disconnect: - connection.triggerServiceDisconnected() + connection.onServiceDisconnected() assertFalse(awaitItem(), "Service should be disconnected since we triggered disconnection.") // Because the lifecycle is still in RESUMED, @@ -135,11 +137,12 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `service disconnect does not restart if lifecycle is only CREATED`() = runTest { val testLifecycle = TestLifecycleOwner() - val connection = TestLifecycleTorrentServiceConnection( - shouldStartServiceSucceed = true, - coroutineContext = backgroundScope.coroutineContext, - ) - testLifecycle.lifecycle.addObserver(connection) + val connection = LifecycleAwareTorrentServiceConnection( + parentCoroutineContext = backgroundScope.coroutineContext, + singleThreadDispatcher = Dispatchers.Default, + lifecycle = testLifecycle.lifecycle, + startService = startServiceWithSuccess, + ).also { it.startLifecycleLoop() } connection.connected.test { assertFalse(awaitItem(), "Initially, connected should be false.") @@ -153,7 +156,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // Move lifecycle to CREATED testLifecycle.setCurrentState(Lifecycle.State.CREATED) // Now simulate a service disconnect - connection.triggerServiceDisconnected() + connection.onServiceDisconnected() // Should remain disconnected, no auto retry because we are no longer in RESUMED advanceTimeBy(2000) @@ -163,10 +166,14 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `close cancels all coroutines and flows`() = runTest { - val connection = TestLifecycleTorrentServiceConnection( - shouldStartServiceSucceed = true, - coroutineContext = backgroundScope.coroutineContext, - ) + val testLifecycle = TestLifecycleOwner() + val connection = LifecycleAwareTorrentServiceConnection( + parentCoroutineContext = backgroundScope.coroutineContext, + singleThreadDispatcher = Dispatchers.Default, + lifecycle = testLifecycle.lifecycle, + startService = startServiceWithSuccess, + ).also { it.startLifecycleLoop() } + advanceUntilIdle() assertFalse(connection.connected.value) From 3ec33a8cb75c994209668175e18a88139f91a1aa Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 00:23:33 +0800 Subject: [PATCH 19/48] add comment on INTENT_STARTUP --- .../torrent/service/AniTorrentService.kt | 35 +++++++++---------- .../service/ServiceConnectionManager.kt | 2 +- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt index ea8f87ab98..7c274da98a 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt @@ -141,24 +141,17 @@ sealed class AniTorrentService : LifecycleService() { } notification.parseNotificationStrategyFromIntent(intent) - if (notification.createNotification(this)) { - // 启动完成的广播 - sendBroadcast( - Intent(INTENT_STARTUP).apply { - setPackage(packageName) - putExtra("success", true) - }, - ) - return START_STICKY - } else { - sendBroadcast( - Intent(INTENT_STARTUP).apply { - setPackage(packageName) - putExtra("false", true) - }, - ) - return START_NOT_STICKY - } + val notificationResult = notification.createNotification(this) + + // 启动完成的广播 + sendBroadcast( + Intent(INTENT_STARTUP).apply { + setPackage(packageName) + putExtra(INTENT_STARTUP_EXTRA, notificationResult) + }, + ) + + return if (notificationResult) START_STICKY else START_NOT_STICKY } @@ -238,7 +231,13 @@ sealed class AniTorrentService : LifecycleService() { } companion object { + /** + * Broadcast intent for result of starting service. + * Push extra [INTENT_STARTUP_EXTRA] with boolean value, or app will assume failure on start. + */ const val INTENT_STARTUP = "me.him188.ani.android.ANI_TORRENT_SERVICE_STARTUP" + const val INTENT_STARTUP_EXTRA = "success" + const val INTENT_BACKGROUND_TIMEOUT = "me.him188.ani.android.ANI_TORRENT_SERVICE_BACKGROUND_TIMEOUT" val actualServiceClass = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index 8fe8ff3418..eeeb52b9bb 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -94,7 +94,7 @@ class ServiceConnectionManager( logger.debug { "Received service startup broadcast: $intent, starting bind service." } context.unregisterReceiver(this) - val result = intent?.getBooleanExtra("success", false) == true + val result = intent?.getBooleanExtra(AniTorrentService.INTENT_STARTUP_EXTRA, false) == true if (!result) { logger.error { "Failed to start service, service responded start result with false." } } From 5efdae1e3a775bce682f8f95cb8fb43e00fc35b8 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 00:32:43 +0800 Subject: [PATCH 20/48] document ForegroundServiceTimeLimitObserver --- .../domain/torrent/service/ServiceConnectionManager.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index eeeb52b9bb..15ca5be94e 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -159,6 +159,12 @@ class ServiceConnectionManager( } } +/** + * According to [documentation](https://developer.android.com/about/versions/15/behavior-changes-15#datasync-timeout): + * in Android 15, foreground service with type `dataSync` or `mediaProcessing` is limited to run in background for 6 hours. + * + * This observer will listen to the time limit exceeded broadcast and update service connection state of app. + */ private class ForegroundServiceTimeLimitObserver( private val context: Context, onServiceTimeLimitExceeded: () -> Unit From c5f855100aeb0d55f4b36527396b9b9a692457e6 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 00:34:37 +0800 Subject: [PATCH 21/48] we don't need wakelock anymore --- .../domain/torrent/service/AniTorrentService.kt | 17 ----------------- .../torrent/service/ServiceConnectionManager.kt | 14 -------------- 2 files changed, 31 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt index 7c274da98a..38b18199a2 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt @@ -15,7 +15,6 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder -import android.os.PowerManager import android.os.Process import android.os.SystemClock import androidx.annotation.RequiresApi @@ -87,10 +86,6 @@ sealed class AniTorrentService : LifecycleService() { private val notification = ServiceNotification(this) private val alarmService: AlarmManager by lazy { getSystemService(Context.ALARM_SERVICE) as AlarmManager } - private val wakeLock: PowerManager.WakeLock by lazy { - (getSystemService(Context.POWER_SERVICE) as PowerManager) - .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AniTorrentService::wake_lock") - } private val httpClientProvider = DefaultHttpClientProvider(FlowProxyProvider(proxyConfig), scope) private val httpClient = httpClientProvider.get() @@ -132,14 +127,6 @@ sealed class AniTorrentService : LifecycleService() { return super.onStartCommand(intent, flags, startId) } - // acquire wake lock when app is stopped. - val acquireWakeLock = intent?.getLongExtra("acquireWakeLock", -1L) ?: -1L - if (acquireWakeLock != -1L) { - wakeLock.acquire(acquireWakeLock) - logger.info { "client acquired wake lock with ${acquireWakeLock / 1000} seconds." } - return super.onStartCommand(intent, flags, startId) - } - notification.parseNotificationStrategyFromIntent(intent) val notificationResult = notification.createNotification(this) @@ -221,10 +208,6 @@ sealed class AniTorrentService : LifecycleService() { } // cancel background scope scope.cancel() - // release wake lock if held - if (wakeLock.isHeld) { - wakeLock.release() - } super.onDestroy() // force kill process Process.killProcess(Process.myPid()) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index 15ca5be94e..b1c36a8ce0 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -9,7 +9,6 @@ package me.him188.ani.app.domain.torrent.service -import android.app.ForegroundServiceStartNotAllowedException import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context @@ -17,7 +16,6 @@ import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection import android.os.IBinder -import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle @@ -38,7 +36,6 @@ import me.him188.ani.utils.logging.logger import me.him188.ani.utils.logging.warn import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume -import kotlin.time.Duration.Companion.minutes /** * 管理与 [AniTorrentService] 的连接并获取 [IRemoteAniTorrentEngine] 远程访问接口. @@ -171,9 +168,6 @@ private class ForegroundServiceTimeLimitObserver( ) : DefaultLifecycleObserver { private val logger = logger() - private val acquireWakeLockIntent = Intent(context, AniTorrentService.actualServiceClass).apply { - putExtra("acquireWakeLock", 1.minutes.inWholeMilliseconds) - } private var registered = false private val timeExceedLimitIntentFilter = IntentFilter(AniTorrentService.INTENT_BACKGROUND_TIMEOUT) private val timeExceedLimitReceiver = object : BroadcastReceiver() { @@ -182,7 +176,6 @@ private class ForegroundServiceTimeLimitObserver( } } - @RequiresApi(31) override fun onPause(owner: LifecycleOwner) { super.onPause(owner) @@ -196,13 +189,6 @@ private class ForegroundServiceTimeLimitObserver( ) registered = true } - try { - // 请求 wake lock, 如果在 app 中息屏可以保证 service 正常跑 [acquireWakeLockIntent] 分钟. - context.startService(acquireWakeLockIntent) - } catch (ex: ForegroundServiceStartNotAllowedException) { - // 大概率是 ServiceStartForegroundException, 服务已经终止了, 不需要再请求 wakelock. - logger.warn(ex) { "Failed to acquire wake lock. Service has already died." } - } } override fun onResume(owner: LifecycleOwner) { From 7de763d113b5a99a67d30f47e767b2791c334511 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 00:37:20 +0800 Subject: [PATCH 22/48] document startService --- .../kotlin/domain/torrent/service/ServiceConnectionManager.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index b1c36a8ce0..e9fc484a9a 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -84,6 +84,8 @@ class ServiceConnectionManager( lifecycle.addObserver(serviceTimeLimitObserver) } + // This method is only called at TorrentServiceConnection which ensures thread-safe. + // It is not necessary to enforce thread-safe here. private suspend fun startService(): T? { val startResult = suspendCancellableCoroutine { cont -> val receiver = object : BroadcastReceiver() { From 02c386114909b6a139ae8c44ac3945f7de16e392 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 00:47:28 +0800 Subject: [PATCH 23/48] refactor TorrentServiceConnection --- app/android/src/main/kotlin/AndroidModules.kt | 2 +- .../kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt | 2 +- .../kotlin/domain/torrent/service/ServiceConnectionManager.kt | 2 -- .../domain/torrent/{ => service}/TorrentServiceConnection.kt | 2 +- .../torrent/LifecycleAwareTorrentServiceConnectionTest.kt | 1 + 5 files changed, 4 insertions(+), 5 deletions(-) rename app/shared/app-data/src/commonMain/kotlin/domain/torrent/{ => service}/TorrentServiceConnection.kt (99%) diff --git a/app/android/src/main/kotlin/AndroidModules.kt b/app/android/src/main/kotlin/AndroidModules.kt index a04280276b..d69666fe4f 100644 --- a/app/android/src/main/kotlin/AndroidModules.kt +++ b/app/android/src/main/kotlin/AndroidModules.kt @@ -40,10 +40,10 @@ import me.him188.ani.app.domain.torrent.LocalAnitorrentEngineFactory import me.him188.ani.app.domain.torrent.TorrentEngine import me.him188.ani.app.domain.torrent.TorrentEngineFactory import me.him188.ani.app.domain.torrent.TorrentManager -import me.him188.ani.app.domain.torrent.TorrentServiceConnection import me.him188.ani.app.domain.torrent.client.RemoteAnitorrentEngine import me.him188.ani.app.domain.torrent.peer.PeerFilterSettings import me.him188.ani.app.domain.torrent.service.AniTorrentService +import me.him188.ani.app.domain.torrent.service.TorrentServiceConnection import me.him188.ani.app.navigation.BrowserNavigator import me.him188.ani.app.platform.AndroidPermissionManager import me.him188.ani.app.platform.AppTerminator diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt index f136465d6f..1beca1ee9d 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteAnitorrentEngine.kt @@ -30,11 +30,11 @@ import me.him188.ani.app.data.models.preference.ProxyConfig import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine import me.him188.ani.app.domain.torrent.TorrentEngine import me.him188.ani.app.domain.torrent.TorrentEngineType -import me.him188.ani.app.domain.torrent.TorrentServiceConnection import me.him188.ani.app.domain.torrent.parcel.PAnitorrentConfig import me.him188.ani.app.domain.torrent.parcel.PProxyConfig import me.him188.ani.app.domain.torrent.parcel.toParceled import me.him188.ani.app.domain.torrent.peer.PeerFilterSettings +import me.him188.ani.app.domain.torrent.service.TorrentServiceConnection import me.him188.ani.app.torrent.api.TorrentDownloader import me.him188.ani.datasources.api.source.MediaSourceLocation import me.him188.ani.utils.coroutines.IO_ diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index e9fc484a9a..9c47e816b2 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -28,8 +28,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.suspendCancellableCoroutine import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine -import me.him188.ani.app.domain.torrent.LifecycleAwareTorrentServiceConnection -import me.him188.ani.app.domain.torrent.TorrentServiceConnection import me.him188.ani.utils.logging.debug import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.logger diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt similarity index 99% rename from app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt rename to app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index 8becf3c86d..5e3b4f70e5 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -7,7 +7,7 @@ * https://github.com/open-ani/ani/blob/main/LICENSE */ -package me.him188.ani.app.domain.torrent +package me.him188.ani.app.domain.torrent.service import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt index 9b689d9bd0..bbe1ad6f4a 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import me.him188.ani.app.domain.torrent.service.LifecycleAwareTorrentServiceConnection import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse From 416b918adfb067f923c82f61e643d15f22f729a3 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 01:53:04 +0800 Subject: [PATCH 24/48] extract start service logic and throw checked exceptions. --- app/android/src/main/kotlin/AniApplication.kt | 2 - .../torrent/service/AniTorrentService.kt | 4 +- .../service/AniTorrentServiceStarter.kt | 110 ++++++++++++++++++ .../service/ServiceConnectionManager.kt | 102 +++------------- .../service/TorrentServiceConnection.kt | 42 +++---- .../torrent/service/TorrentServiceStarter.kt | 51 ++++++++ .../AbstractTorrentServiceConnectionTest.kt | 18 ++- ...ecycleAwareTorrentServiceConnectionTest.kt | 12 +- 8 files changed, 216 insertions(+), 125 deletions(-) create mode 100644 app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt create mode 100644 app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt diff --git a/app/android/src/main/kotlin/AniApplication.kt b/app/android/src/main/kotlin/AniApplication.kt index 15106c8728..eb51a2a9a7 100644 --- a/app/android/src/main/kotlin/AniApplication.kt +++ b/app/android/src/main/kotlin/AniApplication.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.him188.ani.android.activity.MainActivity import me.him188.ani.app.domain.media.cache.MediaCacheNotificationTask -import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine import me.him188.ani.app.domain.torrent.TorrentManager import me.him188.ani.app.domain.torrent.service.AniTorrentService import me.him188.ani.app.domain.torrent.service.ServiceConnectionManager @@ -90,7 +89,6 @@ class AniApplication : Application() { val connectionManager = ServiceConnectionManager( this, ::startAniTorrentService, - { IRemoteAniTorrentEngine.Stub.asInterface(it) }, scope.coroutineContext, ProcessLifecycleOwner.get().lifecycle, ) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt index 38b18199a2..f0d9229dff 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentService.kt @@ -215,8 +215,8 @@ sealed class AniTorrentService : LifecycleService() { companion object { /** - * Broadcast intent for result of starting service. - * Push extra [INTENT_STARTUP_EXTRA] with boolean value, or app will assume failure on start. + * 启动服务后的广播 Intent 动作, 通知启动结果. 服务必须在 `onStartCommand` 报告每一次启动结果. + * Intent 必须传递 [INTENT_STARTUP_EXTRA] boolean 值作为启动结果, 如果没传 app 会认为启动失败. */ const val INTENT_STARTUP = "me.him188.ani.android.ANI_TORRENT_SERVICE_STARTUP" const val INTENT_STARTUP_EXTRA = "success" diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt new file mode 100644 index 0000000000..f96ce456ed --- /dev/null +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2024-2025 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.domain.torrent.service + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.os.IBinder +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine +import me.him188.ani.utils.logging.debug +import me.him188.ani.utils.logging.error +import me.him188.ani.utils.logging.logger +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * @param onServiceDisconnected optional callback when service disconnected. + */ +class AniTorrentServiceStarter( + private val context: Context, + private val onRequiredRestartService: () -> ComponentName?, + private val onServiceDisconnected: () -> Unit = { }, +) : ServiceConnection, TorrentServiceStarter { + private val logger = logger() + + private val startupIntentFilter = IntentFilter(AniTorrentService.INTENT_STARTUP) + private val binderDeferred = MutableStateFlow(CompletableDeferred()) + + override suspend fun start(): IRemoteAniTorrentEngine { + suspendCancellableCoroutine { cont -> + val receiver = object : BroadcastReceiver() { + override fun onReceive(c: Context?, intent: Intent?) { + logger.debug { "Received service startup broadcast: $intent, starting bind service." } + context.unregisterReceiver(this) + + val result = intent?.getBooleanExtra(AniTorrentService.INTENT_STARTUP_EXTRA, false) == true + + if (!result) { + cont.resumeWithException(ServiceStartException.StartRespondFailure) + } else { + cont.resume(Unit) + } + } + } + + ContextCompat.registerReceiver( + context, + receiver, + startupIntentFilter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + + cont.invokeOnCancellation { + context.unregisterReceiver(receiver) + } + + val result = onRequiredRestartService() + if (result == null) { + context.unregisterReceiver(receiver) + cont.resumeWithException(ServiceStartException.ServiceNotExisted) + } else { + logger.debug { "Service started, component name: $result" } + } + } + + val currentDeferred = binderDeferred.value + if (!currentDeferred.isCompleted) { + currentDeferred.cancel() + } + val newDeferred = CompletableDeferred() + binderDeferred.value = newDeferred + + val bindResult = context.bindService( + Intent(context, AniTorrentService.actualServiceClass), + this, + Context.BIND_ABOVE_CLIENT, + ) + if (!bindResult) throw ServiceStartException.BindServiceFailed + + return newDeferred.await() + } + + + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + if (service == null) { + logger.error { "Service is connected, but got null binder!" } + } + val result = IRemoteAniTorrentEngine.Stub.asInterface(service) + binderDeferred.value.complete(result) + } + + override fun onServiceDisconnected(name: ComponentName?) { + binderDeferred.value.completeExceptionally(ServiceStartException.DisconnectedUnexpectedly) + onServiceDisconnected() + } +} \ No newline at end of file diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index 9c47e816b2..45c1d511c7 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -15,25 +15,17 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection -import android.os.IBinder import androidx.core.content.ContextCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.newSingleThreadContext -import kotlinx.coroutines.suspendCancellableCoroutine import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine -import me.him188.ani.utils.logging.debug -import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.logger import me.him188.ani.utils.logging.warn import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.resume /** * 管理与 [AniTorrentService] 的连接并获取 [IRemoteAniTorrentEngine] 远程访问接口. @@ -51,26 +43,27 @@ import kotlin.coroutines.resume * @see me.him188.ani.android.AniApplication */ @OptIn(DelicateCoroutinesApi::class) -class ServiceConnectionManager( - private val context: Context, - private val onRequiredRestartService: () -> ComponentName?, - private val mapBinder: (IBinder?) -> T?, +class ServiceConnectionManager( + context: Context, + onRequiredRestartService: () -> ComponentName?, parentCoroutineContext: CoroutineContext = Dispatchers.Default, private val lifecycle: Lifecycle, -) : ServiceConnection { - private val logger = logger>() - - private val startupIntentFilter = IntentFilter(AniTorrentService.INTENT_STARTUP) - private val binderDeferred = MutableStateFlow(CompletableDeferred()) +) { + private val logger = logger() + private val serviceStarter = AniTorrentServiceStarter( + context = context, + onRequiredRestartService = onRequiredRestartService, + onServiceDisconnected = ::onServiceDisconnected, + ) private val _connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = parentCoroutineContext, singleThreadDispatcher = newSingleThreadContext("AndroidTorrentServiceConnection"), lifecycle = lifecycle, - startService = ::startService, + serviceStarter, ) - val connection: TorrentServiceConnection get() = _connection + val connection: TorrentServiceConnection get() = _connection private val serviceTimeLimitObserver = ForegroundServiceTimeLimitObserver(context) { logger.warn { "Service background time exceeded." } @@ -82,76 +75,7 @@ class ServiceConnectionManager( lifecycle.addObserver(serviceTimeLimitObserver) } - // This method is only called at TorrentServiceConnection which ensures thread-safe. - // It is not necessary to enforce thread-safe here. - private suspend fun startService(): T? { - val startResult = suspendCancellableCoroutine { cont -> - val receiver = object : BroadcastReceiver() { - override fun onReceive(c: Context?, intent: Intent?) { - logger.debug { "Received service startup broadcast: $intent, starting bind service." } - context.unregisterReceiver(this) - - val result = intent?.getBooleanExtra(AniTorrentService.INTENT_STARTUP_EXTRA, false) == true - if (!result) { - logger.error { "Failed to start service, service responded start result with false." } - } - - cont.resume(result) - return - } - } - - ContextCompat.registerReceiver( - context, - receiver, - startupIntentFilter, - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - - val result = onRequiredRestartService() - if (result == null) { - logger.error { "Failed to start service, context.startForegroundService returns null component info." } - context.unregisterReceiver(receiver) - cont.resume(false) - } else { - logger.debug { "Service started, component name: $result" } - } - } - if (!startResult) { - return null - } - - val currentDeferred = binderDeferred.value - if (!currentDeferred.isCompleted) { - currentDeferred.cancel() - } - val newDeferred = CompletableDeferred() - binderDeferred.value = newDeferred - - val bindResult = context.bindService( - Intent(context, AniTorrentService.actualServiceClass), - this@ServiceConnectionManager, - Context.BIND_ABOVE_CLIENT, - ) - if (!bindResult) return null - - return try { - newDeferred.await() - } catch (ex: CancellationException) { - // onServiceDisconnected will cancel the deferred - null - } - } - - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - if (service == null) { - logger.error { "Service is connected, but got null binder!" } - } - binderDeferred.value.complete(mapBinder(service)) - } - - override fun onServiceDisconnected(name: ComponentName?) { - binderDeferred.value.cancel(CancellationException("Service disconnected.")) + private fun onServiceDisconnected() { _connection.onServiceDisconnected() } } diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index 5e3b4f70e5..d67045bc03 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -28,7 +28,6 @@ import me.him188.ani.utils.coroutines.childScope import me.him188.ani.utils.logging.debug import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.logger -import me.him188.ani.utils.logging.warn import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -61,9 +60,8 @@ interface TorrentServiceConnection { * 若服务终止, 不会立刻重启服务, 直到再次进入 [RESUMED][Lifecycle.State.RESUMED] 状态. * * 实现细节: - * - 实现 [startService] 方法, 用于实际的启动服务, 并且要连接服务. * - * @param startService 启动服务并返回[服务通信对象][T]接口, 若返回 null 代表启动失败. + * @param serviceStarter 启动服务并返回[服务通信对象][T]接口, 若返回 null 代表启动失败. * 这个方法将在 `singleThreadDispatcher` 执行, 并且同时只有一个在执行. * @param singleThreadDispatcher 用于执行内部逻辑的调度器, 需要使用单线程来保证内部逻辑的线程安全. */ @@ -71,7 +69,7 @@ class LifecycleAwareTorrentServiceConnection( parentCoroutineContext: CoroutineContext = EmptyCoroutineContext, singleThreadDispatcher: CoroutineDispatcher, private val lifecycle: Lifecycle, - private val startService: suspend () -> T?, + private val starter: TorrentServiceStarter, ) : TorrentServiceConnection { private val logger = logger(this::class.simpleName ?: "TorrentServiceConnection") @@ -135,26 +133,30 @@ class LifecycleAwareTorrentServiceConnection( ) { var attempt = 0 while (attempt < maxAttempts && isAtForeground.value && !isServiceConnected.value) { - val binder = startServiceLock.withLock { - if (!isAtForeground.value || isServiceConnected.value) { - logger.debug { "Service is already connected or app is not at foreground." } - return + val binder = try { + startServiceLock.withLock { + if (!isAtForeground.value || isServiceConnected.value) { + logger.debug { "Service is already connected or app is not at foreground." } + return + } + starter.start() } - startService() - } - if (binder == null) { - logger.warn { "[#$attempt] startService() returned null binder, retry after $delayMillisBetweenAttempts ms" } + } catch (ex: ServiceStartException) { + logger.error(ex) { "[#$attempt] Failed to start service, retry after $delayMillisBetweenAttempts ms" } + attempt++ delay(delayMillisBetweenAttempts) - } else { - logger.debug { "Service connected successfully: $binder" } - if (binderDeferred.isCompleted) { - binderDeferred = CompletableDeferred() - } - binderDeferred.complete(binder) - isServiceConnected.value = true - return + + continue + } + + logger.debug { "Service connected successfully: $binder" } + if (binderDeferred.isCompleted) { + binderDeferred = CompletableDeferred() } + binderDeferred.complete(binder) + isServiceConnected.value = true + return } if (!isServiceConnected.value) { logger.error { "Failed to connect service after $maxAttempts retries." } diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt new file mode 100644 index 0000000000..b9010f3a3e --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2024-2025 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.domain.torrent.service + +/** + * 启动 torrent 服务的接口, 并获取[服务通信对象][T]. + */ +interface TorrentServiceStarter { + /** + * 启动服务. + * + * 这个方法会抛出 [ServiceStartException] 异常, 并且可能会在任何时机取消. + * + * @return 服务通信对象 + */ + suspend fun start(): T +} + +sealed class ServiceStartException : Exception() { + /** + * 详情查看 `Context.startForegroundService` 的返回值. + */ + data object ServiceNotExisted : ServiceStartException() + + /** + * 服务启动了, 但服务回应了启动失败, 详情查看 [AniTorrentService][me.him188.ani.app.domain.torrent.service.AniTorrentService] 中的 `INTENT_STARTUP`. + */ + data object StartRespondFailure : ServiceStartException() + + /** + * 绑定服务失败, 详情查看 `Context.bindService`. + */ + data object BindServiceFailed : ServiceStartException() + + /** + * 绑定成功, 但是获取了空服务通信对象. + */ + data object NullBinder : ServiceStartException() + + /** + * 服务在等待通信对象的时候意外断开了连接. 详情查看 `android.content.ServiceConnection` 中的 `onServiceDisconnected`. + */ + data object DisconnectedUnexpectedly : ServiceStartException() +} \ No newline at end of file diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt index d926b6323e..2fbe3b8dda 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt @@ -14,20 +14,26 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import me.him188.ani.app.domain.torrent.service.ServiceStartException +import me.him188.ani.app.domain.torrent.service.TorrentServiceStarter import kotlin.test.AfterTest import kotlin.test.BeforeTest abstract class AbstractTorrentServiceConnectionTest { protected val fakeBinder = "FAKE_BINDER_OBJECT" - protected val startServiceWithSuccess: suspend () -> String? = { - delay(300) - fakeBinder + protected val startServiceWithSuccess = object : TorrentServiceStarter { + override suspend fun start(): String { + delay(300) + return fakeBinder + } } - protected val startServiceWithFail: suspend () -> String? = { - delay(100) - null + protected val startServiceWithFail = object : TorrentServiceStarter { + override suspend fun start(): String { + delay(100) + throw ServiceStartException.NullBinder + } } @BeforeTest diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt index bbe1ad6f4a..81b1e36354 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -35,7 +35,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect parentCoroutineContext = backgroundScope.coroutineContext, singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, - startService = startServiceWithSuccess, + starter = startServiceWithSuccess, ).also { it.startLifecycleLoop() } connection.connected.test { @@ -57,7 +57,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect parentCoroutineContext = backgroundScope.coroutineContext, singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, - startService = startServiceWithFail, + starter = startServiceWithFail, ).also { it.startLifecycleLoop() } // The .connected flow should remain false, even after we move to resumed, @@ -84,7 +84,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect parentCoroutineContext = backgroundScope.coroutineContext, singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, - startService = startServiceWithSuccess, + starter = startServiceWithSuccess, ).also { it.startLifecycleLoop() } // Start a coroutine that calls getBinder @@ -110,7 +110,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect parentCoroutineContext = backgroundScope.coroutineContext, singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, - startService = startServiceWithSuccess, + starter = startServiceWithSuccess, ).also { it.startLifecycleLoop() } connection.connected.test { @@ -142,7 +142,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect parentCoroutineContext = backgroundScope.coroutineContext, singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, - startService = startServiceWithSuccess, + starter = startServiceWithSuccess, ).also { it.startLifecycleLoop() } connection.connected.test { @@ -172,7 +172,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect parentCoroutineContext = backgroundScope.coroutineContext, singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, - startService = startServiceWithSuccess, + starter = startServiceWithSuccess, ).also { it.startLifecycleLoop() } advanceUntilIdle() From f8e3f6a2669832421b28f19a09b3fb76be00232f Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 01:54:41 +0800 Subject: [PATCH 25/48] null deferred initially. --- .../domain/torrent/service/AniTorrentServiceStarter.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt index f96ce456ed..a614bd4273 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt @@ -38,7 +38,7 @@ class AniTorrentServiceStarter( private val logger = logger() private val startupIntentFilter = IntentFilter(AniTorrentService.INTENT_STARTUP) - private val binderDeferred = MutableStateFlow(CompletableDeferred()) + private val binderDeferred = MutableStateFlow?>(null) override suspend fun start(): IRemoteAniTorrentEngine { suspendCancellableCoroutine { cont -> @@ -78,7 +78,7 @@ class AniTorrentServiceStarter( } val currentDeferred = binderDeferred.value - if (!currentDeferred.isCompleted) { + if (currentDeferred != null && currentDeferred.isCompleted) { currentDeferred.cancel() } val newDeferred = CompletableDeferred() @@ -100,11 +100,11 @@ class AniTorrentServiceStarter( logger.error { "Service is connected, but got null binder!" } } val result = IRemoteAniTorrentEngine.Stub.asInterface(service) - binderDeferred.value.complete(result) + binderDeferred.value?.complete(result) } override fun onServiceDisconnected(name: ComponentName?) { - binderDeferred.value.completeExceptionally(ServiceStartException.DisconnectedUnexpectedly) + binderDeferred.value?.completeExceptionally(ServiceStartException.DisconnectedUnexpectedly) onServiceDisconnected() } } \ No newline at end of file From bcb76fdf608a0dc81cebb5ef1d8d9f827f76a489 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 02:03:37 +0800 Subject: [PATCH 26/48] add step log for connection --- .../domain/torrent/service/AniTorrentServiceStarter.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt index a614bd4273..8d762ba01c 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt @@ -44,7 +44,7 @@ class AniTorrentServiceStarter( suspendCancellableCoroutine { cont -> val receiver = object : BroadcastReceiver() { override fun onReceive(c: Context?, intent: Intent?) { - logger.debug { "Received service startup broadcast: $intent, starting bind service." } + logger.debug { "[2/4] Received service startup result broadcast: $intent" } context.unregisterReceiver(this) val result = intent?.getBooleanExtra(AniTorrentService.INTENT_STARTUP_EXTRA, false) == true @@ -73,7 +73,7 @@ class AniTorrentServiceStarter( context.unregisterReceiver(receiver) cont.resumeWithException(ServiceStartException.ServiceNotExisted) } else { - logger.debug { "Service started, component name: $result" } + logger.debug { "[1/4] Started service, component name: $result" } } } @@ -90,7 +90,10 @@ class AniTorrentServiceStarter( Context.BIND_ABOVE_CLIENT, ) if (!bindResult) throw ServiceStartException.BindServiceFailed + logger.debug { "[3/4] Bound service successfully." } + val result = newDeferred.await() + logger.debug { "[4/4] Got service binder: $result" } return newDeferred.await() } From 80c1097a303592dff8ef9d3ae2537e8b8db9d585 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 02:05:00 +0800 Subject: [PATCH 27/48] fix got null binder --- .../domain/torrent/service/AniTorrentServiceStarter.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt index 8d762ba01c..680e9923bb 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.suspendCancellableCoroutine import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine import me.him188.ani.utils.logging.debug -import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.logger import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -94,13 +93,13 @@ class AniTorrentServiceStarter( val result = newDeferred.await() logger.debug { "[4/4] Got service binder: $result" } - return newDeferred.await() + return result } override fun onServiceConnected(name: ComponentName?, service: IBinder?) { if (service == null) { - logger.error { "Service is connected, but got null binder!" } + binderDeferred.value?.completeExceptionally(ServiceStartException.NullBinder) } val result = IRemoteAniTorrentEngine.Stub.asInterface(service) binderDeferred.value?.complete(result) From b853d815a1ba9a158a8f303886f4dd9cc6cf9358 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 02:17:04 +0800 Subject: [PATCH 28/48] catch exception when start service --- .../torrent/service/AniTorrentServiceStarter.kt | 16 ++++++++++++---- .../torrent/service/TorrentServiceStarter.kt | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt index 680e9923bb..27768b0306 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt @@ -67,12 +67,20 @@ class AniTorrentServiceStarter( context.unregisterReceiver(receiver) } - val result = onRequiredRestartService() - if (result == null) { + val startResult = try { + onRequiredRestartService() + } catch (e: Exception) { context.unregisterReceiver(receiver) - cont.resumeWithException(ServiceStartException.ServiceNotExisted) + + cont.resumeWithException(ServiceStartException.StartFailed(e)) + return@suspendCancellableCoroutine + } + + if (startResult == null) { + context.unregisterReceiver(receiver) + cont.resumeWithException(ServiceStartException.StartFailed()) } else { - logger.debug { "[1/4] Started service, component name: $result" } + logger.debug { "[1/4] Started service, result: $startResult" } } } diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt index b9010f3a3e..91d5fcc7d4 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt @@ -25,9 +25,9 @@ interface TorrentServiceStarter { sealed class ServiceStartException : Exception() { /** - * 详情查看 `Context.startForegroundService` 的返回值. + * 详情查看 `Context.startForegroundService`. */ - data object ServiceNotExisted : ServiceStartException() + data class StartFailed(override val cause: Throwable? = null) : ServiceStartException() /** * 服务启动了, 但服务回应了启动失败, 详情查看 [AniTorrentService][me.him188.ani.app.domain.torrent.service.AniTorrentService] 中的 `INTENT_STARTUP`. From aafacfe7e1e4b75b044dfe096cd6555230cd8f24 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 02:18:18 +0800 Subject: [PATCH 29/48] remove unnecessary deferred cancellation --- .../domain/torrent/service/AniTorrentServiceStarter.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt index 27768b0306..735967c69b 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt @@ -83,11 +83,7 @@ class AniTorrentServiceStarter( logger.debug { "[1/4] Started service, result: $startResult" } } } - - val currentDeferred = binderDeferred.value - if (currentDeferred != null && currentDeferred.isCompleted) { - currentDeferred.cancel() - } + val newDeferred = CompletableDeferred() binderDeferred.value = newDeferred From e1dd284c2b4b1984dc83f21824e549833cd122d3 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 02:23:09 +0800 Subject: [PATCH 30/48] contract annotation --- .../domain/torrent/service/ServiceConnectionManager.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index 45c1d511c7..68715be9bd 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -42,7 +42,6 @@ import kotlin.coroutines.CoroutineContext * @see AniTorrentService.onStartCommand * @see me.him188.ani.android.AniApplication */ -@OptIn(DelicateCoroutinesApi::class) class ServiceConnectionManager( context: Context, onRequiredRestartService: () -> ComponentName?, @@ -56,20 +55,21 @@ class ServiceConnectionManager( onRequiredRestartService = onRequiredRestartService, onServiceDisconnected = ::onServiceDisconnected, ) + + @OptIn(DelicateCoroutinesApi::class) private val _connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = parentCoroutineContext, singleThreadDispatcher = newSingleThreadContext("AndroidTorrentServiceConnection"), lifecycle = lifecycle, serviceStarter, ) - - val connection: TorrentServiceConnection get() = _connection - private val serviceTimeLimitObserver = ForegroundServiceTimeLimitObserver(context) { logger.warn { "Service background time exceeded." } _connection.onServiceDisconnected() } + val connection: TorrentServiceConnection get() = _connection + fun startLifecycleLoop() { _connection.startLifecycleLoop() lifecycle.addObserver(serviceTimeLimitObserver) From 36289d2d4f6354f09523f18ee7016c63c8c8b320 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 02:23:43 +0800 Subject: [PATCH 31/48] rename startServiceImpl --- .../kotlin/domain/torrent/service/AniTorrentServiceStarter.kt | 4 ++-- .../kotlin/domain/torrent/service/ServiceConnectionManager.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt index 735967c69b..41eb6be82f 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt @@ -31,7 +31,7 @@ import kotlin.coroutines.resumeWithException */ class AniTorrentServiceStarter( private val context: Context, - private val onRequiredRestartService: () -> ComponentName?, + private val startServiceImpl: () -> ComponentName?, private val onServiceDisconnected: () -> Unit = { }, ) : ServiceConnection, TorrentServiceStarter { private val logger = logger() @@ -68,7 +68,7 @@ class AniTorrentServiceStarter( } val startResult = try { - onRequiredRestartService() + startServiceImpl() } catch (e: Exception) { context.unregisterReceiver(receiver) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index 68715be9bd..7d833c19b2 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -44,7 +44,7 @@ import kotlin.coroutines.CoroutineContext */ class ServiceConnectionManager( context: Context, - onRequiredRestartService: () -> ComponentName?, + startServiceImpl: () -> ComponentName?, parentCoroutineContext: CoroutineContext = Dispatchers.Default, private val lifecycle: Lifecycle, ) { @@ -52,7 +52,7 @@ class ServiceConnectionManager( private val serviceStarter = AniTorrentServiceStarter( context = context, - onRequiredRestartService = onRequiredRestartService, + startServiceImpl = startServiceImpl, onServiceDisconnected = ::onServiceDisconnected, ) From 8bae8109c4f0b3e673a6aed9681692e238f2d9cd Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 02:27:04 +0800 Subject: [PATCH 32/48] avoid multiple start --- .../torrent/service/ServiceConnectionManager.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index 7d833c19b2..135134518d 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -68,11 +68,21 @@ class ServiceConnectionManager( _connection.onServiceDisconnected() } + private var started = false + val connection: TorrentServiceConnection get() = _connection fun startLifecycleLoop() { - _connection.startLifecycleLoop() - lifecycle.addObserver(serviceTimeLimitObserver) + if (started) return + + synchronized(this) { + if (started) return + + _connection.startLifecycleLoop() + lifecycle.addObserver(serviceTimeLimitObserver) + + started = true + } } private fun onServiceDisconnected() { From e219269f59784a9c2e30ea69c57361fb04cee0f6 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 02:29:29 +0800 Subject: [PATCH 33/48] optimize doc --- .../domain/torrent/service/TorrentServiceConnection.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index d67045bc03..fa020c28cd 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -32,15 +32,15 @@ import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext /** - * torrent 服务与 APP 通信接口. T 为通信接口的类型 + * Torrent 服务与 APP 通信接口. [T] 为通信接口的类型 * * 此接口仅负责服务与 APP 之间的通信, 不负责服务的启动和终止. */ interface TorrentServiceConnection { /** - * 当前服务是否已连接, 只有在已连接的状态才能获取通信接口. + * 当前服务是否已连接. * - * 若变为 `false`, 则服务通信接口将变得不可用, 可能需要实现类重新启动服务. + * 若变为 `false`, 则服务通信接口将变得不可用, 接口的实现类 可能需要重启服务, 例如 [LifecycleAwareTorrentServiceConnection]. */ val connected: StateFlow @@ -61,7 +61,7 @@ interface TorrentServiceConnection { * * 实现细节: * - * @param serviceStarter 启动服务并返回[服务通信对象][T]接口, 若返回 null 代表启动失败. + * @param starter 启动服务并返回[服务通信对象][T]接口, 若返回 null 代表启动失败. * 这个方法将在 `singleThreadDispatcher` 执行, 并且同时只有一个在执行. * @param singleThreadDispatcher 用于执行内部逻辑的调度器, 需要使用单线程来保证内部逻辑的线程安全. */ From 8531e415aa6242e124ebe0468c83e09b2cf5e6f3 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 02:36:27 +0800 Subject: [PATCH 34/48] non cancellable --- .../kotlin/domain/torrent/service/TorrentServiceConnection.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index fa020c28cd..1cd6adee7b 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel import kotlinx.coroutines.delay @@ -164,7 +165,7 @@ class LifecycleAwareTorrentServiceConnection( } fun close() { - scope.launch { + scope.launch(NonCancellable) { logger.debug { "close(): Cancel scope, mark disconnected." } isServiceConnected.value = false binderDeferred.cancel(CancellationException("Connection closed.")) From 6054a999275500a17f0c0d25d1593308db09d590 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 02:48:37 +0800 Subject: [PATCH 35/48] avoid multiple start --- .../service/TorrentServiceConnection.kt | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index 1cd6adee7b..ae1d4dfe7e 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -12,6 +12,8 @@ package me.him188.ani.app.domain.torrent.service import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher @@ -83,24 +85,35 @@ class LifecycleAwareTorrentServiceConnection( private val isServiceConnected: MutableStateFlow = MutableStateFlow(false) private val startServiceLock = Mutex() + private var lifecycleLoopLock = SynchronizedObject() + private var started = false + override val connected: StateFlow = isServiceConnected fun startLifecycleLoop() { - scope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - isAtForeground.value = true - try { - if (!isServiceConnected.value) { - logger.debug { "Lifecycle resume: Service is not connected, start connecting..." } - startServiceWithRetry() + if (started) return + + synchronized(lifecycleLoopLock) { + if (started) return + + scope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + isAtForeground.value = true + try { + if (!isServiceConnected.value) { + logger.debug { "Lifecycle resume: Service is not connected, start connecting..." } + startServiceWithRetry() + } + + awaitCancellation() + } catch (_: CancellationException) { + isAtForeground.value = false + logger.debug { "Lifecycle pause: App moved to background." } } - - awaitCancellation() - } catch (_: CancellationException) { - isAtForeground.value = false - logger.debug { "Lifecycle pause: App moved to background." } } } + + started = true } } @@ -157,6 +170,7 @@ class LifecycleAwareTorrentServiceConnection( } binderDeferred.complete(binder) isServiceConnected.value = true + return } if (!isServiceConnected.value) { From 5adda671942ec5f6d90aed0c03303814c7ed193f Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 10:33:22 +0800 Subject: [PATCH 36/48] remove singleThreadDispatcher parameter --- .../domain/torrent/service/ServiceConnectionManager.kt | 8 ++++---- .../domain/torrent/service/TorrentServiceConnection.kt | 7 ++----- .../torrent/LifecycleAwareTorrentServiceConnectionTest.kt | 7 ------- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index 135134518d..0a27c82131 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -20,7 +20,6 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.newSingleThreadContext import me.him188.ani.app.domain.torrent.IRemoteAniTorrentEngine import me.him188.ani.utils.logging.logger @@ -45,7 +44,7 @@ import kotlin.coroutines.CoroutineContext class ServiceConnectionManager( context: Context, startServiceImpl: () -> ComponentName?, - parentCoroutineContext: CoroutineContext = Dispatchers.Default, + parentCoroutineContext: CoroutineContext, private val lifecycle: Lifecycle, ) { private val logger = logger() @@ -56,10 +55,11 @@ class ServiceConnectionManager( onServiceDisconnected = ::onServiceDisconnected, ) + // TorrentServiceConnection 无论如何都不能被阻塞, 单独为它的逻辑创建一个线程. @OptIn(DelicateCoroutinesApi::class) private val _connection = LifecycleAwareTorrentServiceConnection( - parentCoroutineContext = parentCoroutineContext, - singleThreadDispatcher = newSingleThreadContext("AndroidTorrentServiceConnection"), + parentCoroutineContext = parentCoroutineContext + + newSingleThreadContext("AndroidTorrentServiceConnection"), lifecycle = lifecycle, serviceStarter, ) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index ae1d4dfe7e..e6302da54e 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -16,7 +16,6 @@ import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation @@ -65,19 +64,17 @@ interface TorrentServiceConnection { * 实现细节: * * @param starter 启动服务并返回[服务通信对象][T]接口, 若返回 null 代表启动失败. - * 这个方法将在 `singleThreadDispatcher` 执行, 并且同时只有一个在执行. - * @param singleThreadDispatcher 用于执行内部逻辑的调度器, 需要使用单线程来保证内部逻辑的线程安全. + * @param parentCoroutineContext 执行内部逻辑的协程上下文. */ class LifecycleAwareTorrentServiceConnection( parentCoroutineContext: CoroutineContext = EmptyCoroutineContext, - singleThreadDispatcher: CoroutineDispatcher, private val lifecycle: Lifecycle, private val starter: TorrentServiceStarter, ) : TorrentServiceConnection { private val logger = logger(this::class.simpleName ?: "TorrentServiceConnection") // we assert it is a single thread dispatcher - private val scope = parentCoroutineContext.childScope(singleThreadDispatcher) + private val scope = parentCoroutineContext.childScope() private var binderDeferred by atomic(CompletableDeferred()) diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt index 81b1e36354..c34606a8c9 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -14,7 +14,6 @@ import androidx.lifecycle.testing.TestLifecycleOwner import app.cash.turbine.test import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy @@ -33,7 +32,6 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect val testLifecycle = TestLifecycleOwner() val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, - singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, starter = startServiceWithSuccess, ).also { it.startLifecycleLoop() } @@ -55,7 +53,6 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect val testLifecycle = TestLifecycleOwner() val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, - singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, starter = startServiceWithFail, ).also { it.startLifecycleLoop() } @@ -82,7 +79,6 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect val testLifecycle = TestLifecycleOwner() val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, - singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, starter = startServiceWithSuccess, ).also { it.startLifecycleLoop() } @@ -108,7 +104,6 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect val testLifecycle = TestLifecycleOwner() val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, - singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, starter = startServiceWithSuccess, ).also { it.startLifecycleLoop() } @@ -140,7 +135,6 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect val testLifecycle = TestLifecycleOwner() val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, - singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, starter = startServiceWithSuccess, ).also { it.startLifecycleLoop() } @@ -170,7 +164,6 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect val testLifecycle = TestLifecycleOwner() val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, - singleThreadDispatcher = Dispatchers.Default, lifecycle = testLifecycle.lifecycle, starter = startServiceWithSuccess, ).also { it.startLifecycleLoop() } From ab8f7419b9baa71b9f9d4391bca6ac742fdc3766 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 10:38:04 +0800 Subject: [PATCH 37/48] optimize doc5 --- .../kotlin/domain/torrent/service/TorrentServiceConnection.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index e6302da54e..17aabc0a49 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -36,13 +36,13 @@ import kotlin.coroutines.EmptyCoroutineContext /** * Torrent 服务与 APP 通信接口. [T] 为通信接口的类型 * - * 此接口仅负责服务与 APP 之间的通信, 不负责服务的启动和终止. + * 此接口仅负责与 Torrent 服务的通信, 启动与终止服务的逻辑可能需要在 接口的实现类(implementations) 或其他外部实现. */ interface TorrentServiceConnection { /** * 当前服务是否已连接. * - * 若变为 `false`, 则服务通信接口将变得不可用, 接口的实现类 可能需要重启服务, 例如 [LifecycleAwareTorrentServiceConnection]. + * 若变为 `false`, 则服务通信接口将变得不可用, 接口的实现类(implementations) 可能需要重启服务, 例如 [LifecycleAwareTorrentServiceConnection]. */ val connected: StateFlow From 2b4b31defefd8a1486064a91941cb5d780a44338 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 14:11:46 +0800 Subject: [PATCH 38/48] make more thread-safe --- .../service/TorrentServiceConnection.kt | 71 +++++++++++-------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index 17aabc0a49..f76e5ecefe 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -17,6 +17,7 @@ import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel @@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.yield import me.him188.ani.utils.coroutines.childScope import me.him188.ani.utils.logging.debug import me.him188.ani.utils.logging.error @@ -71,9 +73,8 @@ class LifecycleAwareTorrentServiceConnection( private val lifecycle: Lifecycle, private val starter: TorrentServiceStarter, ) : TorrentServiceConnection { - private val logger = logger(this::class.simpleName ?: "TorrentServiceConnection") + private val logger = logger(this::class.simpleName ?: "LifecycleAwareTorrentServiceConnection") - // we assert it is a single thread dispatcher private val scope = parentCoroutineContext.childScope() private var binderDeferred by atomic(CompletableDeferred()) @@ -93,19 +94,19 @@ class LifecycleAwareTorrentServiceConnection( synchronized(lifecycleLoopLock) { if (started) return - scope.launch { + scope.launch(CoroutineName("TorrentServiceConnection - RepeatOnResume")) { lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { isAtForeground.value = true try { if (!isServiceConnected.value) { - logger.debug { "Lifecycle resume: Service is not connected, start connecting..." } + logger.debug { "Service is disconnected while app is at foreground, reconnecting..." } startServiceWithRetry() } awaitCancellation() } catch (_: CancellationException) { isAtForeground.value = false - logger.debug { "Lifecycle pause: App moved to background." } + logger.debug { "App moved to background." } } } } @@ -119,18 +120,26 @@ class LifecycleAwareTorrentServiceConnection( * 如果目前 APP 还在前台, 就要尝试重启并重连服务. */ fun onServiceDisconnected() { - scope.launch(CoroutineName("TorrentServiceConnection - Lifecycle")) { - if (!isServiceConnected.value) { - // 已经是断开状态,直接忽略 - return@launch + scope.launch( + CoroutineName("TorrentServiceConnection - ServiceDisconnected"), + start = CoroutineStart.UNDISPATCHED, + ) { + startServiceLock.withLock { + yield() + + if (!isServiceConnected.value) { + // 已经是断开状态,直接忽略 + return@launch + } + isServiceConnected.value = false + binderDeferred.cancel(CancellationException("Service disconnected.")) + binderDeferred = CompletableDeferred() } - isServiceConnected.value = false - binderDeferred.cancel(CancellationException("Service disconnected.")) - binderDeferred = CompletableDeferred() // 若应用仍想要连接,则重新启动 if (isAtForeground.value) { - logger.debug { "Service is disconnected and app is in foreground, restarting service connection in 3s..." } + logger.debug { "Service is disconnected and app is in foreground, restarting service connection in 2500 ms..." } + delay(2500) startServiceWithRetry() } else { logger.debug { "Service is disconnected and app is in background." } @@ -144,14 +153,24 @@ class LifecycleAwareTorrentServiceConnection( ) { var attempt = 0 while (attempt < maxAttempts && isAtForeground.value && !isServiceConnected.value) { - val binder = try { + try { startServiceLock.withLock { if (!isAtForeground.value || isServiceConnected.value) { logger.debug { "Service is already connected or app is not at foreground." } return } - starter.start() + + val startResult = starter.start() + + logger.debug { "Service connected successfully: $startResult" } + if (binderDeferred.isCompleted) { + binderDeferred = CompletableDeferred() + } + binderDeferred.complete(startResult) + isServiceConnected.value = true } + + return } catch (ex: ServiceStartException) { logger.error(ex) { "[#$attempt] Failed to start service, retry after $delayMillisBetweenAttempts ms" } @@ -160,24 +179,19 @@ class LifecycleAwareTorrentServiceConnection( continue } - - logger.debug { "Service connected successfully: $binder" } - if (binderDeferred.isCompleted) { - binderDeferred = CompletableDeferred() - } - binderDeferred.complete(binder) - isServiceConnected.value = true - - return } if (!isServiceConnected.value) { - logger.error { "Failed to connect service after $maxAttempts retries." } + logger.error { "Failed to connect service after $attempt retries." } } } fun close() { - scope.launch(NonCancellable) { - logger.debug { "close(): Cancel scope, mark disconnected." } + // close 工作不应该被取消并且需要立刻执行 + scope.launch( + NonCancellable + CoroutineName("TorrentServiceConnection - Close"), + start = CoroutineStart.UNDISPATCHED, + ) { + logger.debug { "Closing scope of TorrentServiceConnection." } isServiceConnected.value = false binderDeferred.cancel(CancellationException("Connection closed.")) scope.cancel() @@ -190,7 +204,8 @@ class LifecycleAwareTorrentServiceConnection( * - 如果服务还未连接, 此函数将挂起. */ override suspend fun getBinder(): T { - // track cancellation of [scope] + // 如果 isServiceDisconnected 为 false, 那 binderDeferred 一定是未完成的, 见 onServiceDisconnected + // 如果 isServiceDisconnected 为 true, 那 binderDeferred 一定是已完成的, 见 startServiceWithRetry return binderDeferred.await() } } \ No newline at end of file From e5c7c7064a79a6e4fedbf944d175aed1c77764a0 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 14:25:41 +0800 Subject: [PATCH 39/48] get binder after lock --- .../kotlin/domain/torrent/service/TorrentServiceConnection.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index f76e5ecefe..0c79d2fd55 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -206,6 +206,6 @@ class LifecycleAwareTorrentServiceConnection( override suspend fun getBinder(): T { // 如果 isServiceDisconnected 为 false, 那 binderDeferred 一定是未完成的, 见 onServiceDisconnected // 如果 isServiceDisconnected 为 true, 那 binderDeferred 一定是已完成的, 见 startServiceWithRetry - return binderDeferred.await() + return startServiceLock.withLock { binderDeferred }.await() } } \ No newline at end of file From 1733aa8a6e7ba6da60a6b27fa5604ec3c624ed08 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sat, 8 Feb 2025 14:25:48 +0800 Subject: [PATCH 40/48] don't print stacktrace --- .../androidMain/kotlin/domain/torrent/client/RemoteObject.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteObject.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteObject.kt index a57127f2c7..7083145ed7 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteObject.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/client/RemoteObject.kt @@ -75,7 +75,7 @@ class RetryRemoteObject( if (retryCount > 2) throw doe retryCount += 1 - logger.warn(Exception("Show stacktrace")) { + logger.warn { "Remote interface $currentRemote is dead, attempt to fetch new remote. retryCount = $retryCount" } From 03bc4c450743f7c77423a7f1c46b904f2dbe380d Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sun, 9 Feb 2025 13:54:17 +0800 Subject: [PATCH 41/48] optimize --- .../service/AniTorrentServiceStarter.kt | 37 ++-- .../service/ServiceConnectionManager.kt | 2 - .../service/TorrentServiceConnection.kt | 166 +++++++++++------- .../torrent/service/TorrentServiceStarter.kt | 10 +- .../AbstractTorrentServiceConnectionTest.kt | 4 +- ...ecycleAwareTorrentServiceConnectionTest.kt | 109 +++++++++--- 6 files changed, 222 insertions(+), 106 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt index 41eb6be82f..8b2744750d 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/AniTorrentServiceStarter.kt @@ -33,12 +33,27 @@ class AniTorrentServiceStarter( private val context: Context, private val startServiceImpl: () -> ComponentName?, private val onServiceDisconnected: () -> Unit = { }, -) : ServiceConnection, TorrentServiceStarter { +) : TorrentServiceStarter { private val logger = logger() private val startupIntentFilter = IntentFilter(AniTorrentService.INTENT_STARTUP) private val binderDeferred = MutableStateFlow?>(null) + private val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + if (service == null) { + binderDeferred.value?.completeExceptionally(ServiceStartException.NullBinder()) + } + val result = IRemoteAniTorrentEngine.Stub.asInterface(service) + binderDeferred.value?.complete(result) + } + + override fun onServiceDisconnected(name: ComponentName?) { + binderDeferred.value?.completeExceptionally(ServiceStartException.DisconnectedUnexpectedly()) + onServiceDisconnected() + } + } + override suspend fun start(): IRemoteAniTorrentEngine { suspendCancellableCoroutine { cont -> val receiver = object : BroadcastReceiver() { @@ -49,7 +64,7 @@ class AniTorrentServiceStarter( val result = intent?.getBooleanExtra(AniTorrentService.INTENT_STARTUP_EXTRA, false) == true if (!result) { - cont.resumeWithException(ServiceStartException.StartRespondFailure) + cont.resumeWithException(ServiceStartException.StartRespondFailure()) } else { cont.resume(Unit) } @@ -89,28 +104,14 @@ class AniTorrentServiceStarter( val bindResult = context.bindService( Intent(context, AniTorrentService.actualServiceClass), - this, + conn, Context.BIND_ABOVE_CLIENT, ) - if (!bindResult) throw ServiceStartException.BindServiceFailed + if (!bindResult) throw ServiceStartException.BindServiceFailed() logger.debug { "[3/4] Bound service successfully." } val result = newDeferred.await() logger.debug { "[4/4] Got service binder: $result" } return result } - - - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - if (service == null) { - binderDeferred.value?.completeExceptionally(ServiceStartException.NullBinder) - } - val result = IRemoteAniTorrentEngine.Stub.asInterface(service) - binderDeferred.value?.complete(result) - } - - override fun onServiceDisconnected(name: ComponentName?) { - binderDeferred.value?.completeExceptionally(ServiceStartException.DisconnectedUnexpectedly) - onServiceDisconnected() - } } \ No newline at end of file diff --git a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt index 0a27c82131..29803aa0e7 100644 --- a/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt +++ b/app/shared/app-data/src/androidMain/kotlin/domain/torrent/service/ServiceConnectionManager.kt @@ -100,8 +100,6 @@ private class ForegroundServiceTimeLimitObserver( private val context: Context, onServiceTimeLimitExceeded: () -> Unit ) : DefaultLifecycleObserver { - private val logger = logger() - private var registered = false private val timeExceedLimitIntentFilter = IntentFilter(AniTorrentService.INTENT_BACKGROUND_TIMEOUT) private val timeExceedLimitReceiver = object : BroadcastReceiver() { diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index 0c79d2fd55..c5c5d46d43 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -11,19 +11,19 @@ package me.him188.ani.app.domain.torrent.service import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle -import kotlinx.atomicfu.atomic import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -76,14 +76,16 @@ class LifecycleAwareTorrentServiceConnection( private val logger = logger(this::class.simpleName ?: "LifecycleAwareTorrentServiceConnection") private val scope = parentCoroutineContext.childScope() - - private var binderDeferred by atomic(CompletableDeferred()) + private val binderDeferred = MutableStateFlow(CompletableDeferred()) + + // read at `onServiceDisconnected`, set by `startLifecycleLoop` private val isAtForeground: MutableStateFlow = MutableStateFlow(false) private val isServiceConnected: MutableStateFlow = MutableStateFlow(false) - private val startServiceLock = Mutex() + private val lock = Mutex() + private val startServiceFlow = MutableStateFlow(Any()) - private var lifecycleLoopLock = SynchronizedObject() + private val lifecycleLoopLock = SynchronizedObject() private var started = false override val connected: StateFlow = isServiceConnected @@ -98,12 +100,39 @@ class LifecycleAwareTorrentServiceConnection( lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { isAtForeground.value = true try { - if (!isServiceConnected.value) { - logger.debug { "Service is disconnected while app is at foreground, reconnecting..." } - startServiceWithRetry() - } + startServiceFlow.collectLatest { + // 如果 collectLatest 执行后 onServiceDisconnected 也立刻 launch, 最终状态应该是断开了连接 + // 此处让出调度权, 以便 onServiceDisconnected 可以立刻执行 + // 让出调度后, onServiceDisconnected 会立刻 set new deferred, 然后 yield 还回调度权 + val beforeYieldedBinderDeferred = binderDeferred.value + + yield() + val result = startServiceAndGetBinder() + + val afterYieldedBinderDeferred = binderDeferred.value - awaitCancellation() + // 如果在 yield 后, binderDeferred 已经被重新设置, 则说明 onServiceDisconnected 已经执行了 + // fast path for disconnected. + if (beforeYieldedBinderDeferred != afterYieldedBinderDeferred) { + return@collectLatest + } + + if (result is ServiceStartResult.AlreadyStarted) return@collectLatest + if (result is ServiceStartResult.Failure) { + logger.error { "Failed to start service after ${result.attempt} attempts." } + return@collectLatest + } + if (result !is ServiceStartResult.Success) error("unreachable condition") + + logger.debug { "Service is connected: ${result.binder}" } + + lock.withLock { + binderDeferred.value = CompletableDeferred() + binderDeferred.value.complete(result.binder) + + isServiceConnected.value = true + } + } } catch (_: CancellationException) { isAtForeground.value = false logger.debug { "App moved to background." } @@ -124,88 +153,107 @@ class LifecycleAwareTorrentServiceConnection( CoroutineName("TorrentServiceConnection - ServiceDisconnected"), start = CoroutineStart.UNDISPATCHED, ) { - startServiceLock.withLock { - yield() + if (!isServiceConnected.value) { + // 已经是断开状态,直接忽略 + return@launch + } + + if (lock.tryLock()) { // 如果 lock 成功了, 说明 startServiceFlow collector 没在 lock 阶段 + try { + isServiceConnected.value = false + binderDeferred.value = CompletableDeferred() - if (!isServiceConnected.value) { - // 已经是断开状态,直接忽略 - return@launch + yield() // 配合 startServiceFlow collector 中的 yield, 可以检测到 binderDeferred 的变化 + } finally { + lock.unlock() + } + } else { + lock.withLock { + if (!isServiceConnected.value) { + // 已经是断开状态,直接忽略 + return@launch + } + + isServiceConnected.value = false + binderDeferred.value = CompletableDeferred() } - isServiceConnected.value = false - binderDeferred.cancel(CancellationException("Service disconnected.")) - binderDeferred = CompletableDeferred() } // 若应用仍想要连接,则重新启动 if (isAtForeground.value) { - logger.debug { "Service is disconnected and app is in foreground, restarting service connection in 2500 ms..." } - delay(2500) - startServiceWithRetry() + logger.debug { "Service is disconnected and app is in foreground, restarting service connection in 1000 ms..." } + delay(1000) + startServiceFlow.value = Any() } else { logger.debug { "Service is disconnected and app is in background." } } } } - private suspend fun startServiceWithRetry( + /** + * 启动服务并获取服务通信对象. + * + * This function has no side effect and can be safely cancelled. + * + * @return 如果服务已启动或超过最大尝试次数, 返回 `null`. + */ + private suspend fun startServiceAndGetBinder( maxAttempts: Int = Int.MAX_VALUE, // 可根据需求设置 - delayMillisBetweenAttempts: Long = 2500 - ) { + intervalMillisBetweenAttempts: Long = 2500 + ): ServiceStartResult { + if (isServiceConnected.value) return ServiceStartResult.AlreadyStarted() var attempt = 0 - while (attempt < maxAttempts && isAtForeground.value && !isServiceConnected.value) { + + while (attempt < maxAttempts && !isServiceConnected.value) { try { - startServiceLock.withLock { - if (!isAtForeground.value || isServiceConnected.value) { - logger.debug { "Service is already connected or app is not at foreground." } - return - } + if (isServiceConnected.value) return ServiceStartResult.AlreadyStarted() - val startResult = starter.start() + val result = starter.start() - logger.debug { "Service connected successfully: $startResult" } - if (binderDeferred.isCompleted) { - binderDeferred = CompletableDeferred() - } - binderDeferred.complete(startResult) - isServiceConnected.value = true - } - - return + return ServiceStartResult.Success(result) } catch (ex: ServiceStartException) { - logger.error(ex) { "[#$attempt] Failed to start service, retry after $delayMillisBetweenAttempts ms" } + logger.error(ex) { "[#$attempt] Failed to start service, retry after $intervalMillisBetweenAttempts ms" } attempt++ - delay(delayMillisBetweenAttempts) + delay(intervalMillisBetweenAttempts) continue } } - if (!isServiceConnected.value) { - logger.error { "Failed to connect service after $attempt retries." } - } + + if (isServiceConnected.value) return ServiceStartResult.AlreadyStarted() + return ServiceStartResult.Failure(attempt) } fun close() { - // close 工作不应该被取消并且需要立刻执行 - scope.launch( - NonCancellable + CoroutineName("TorrentServiceConnection - Close"), - start = CoroutineStart.UNDISPATCHED, - ) { - logger.debug { "Closing scope of TorrentServiceConnection." } - isServiceConnected.value = false - binderDeferred.cancel(CancellationException("Connection closed.")) - scope.cancel() - } + logger.debug { "Closing scope of TorrentServiceConnection." } + isServiceConnected.value = false + binderDeferred.value.cancel(CancellationException("Connection closed.")) + scope.cancel() } /** * 获取当前 binder 对象. * - * - 如果服务还未连接, 此函数将挂起. + * - 如果服务还未连接, 此函数将一直挂起. */ override suspend fun getBinder(): T { // 如果 isServiceDisconnected 为 false, 那 binderDeferred 一定是未完成的, 见 onServiceDisconnected // 如果 isServiceDisconnected 为 true, 那 binderDeferred 一定是已完成的, 见 startServiceWithRetry - return startServiceLock.withLock { binderDeferred }.await() + return binderDeferred.transformLatest { + it.join() + + try { + emit(it.await()) + } catch (_: CancellationException) { + + } + }.first() + } + + private sealed class ServiceStartResult { + class Success(val binder: B) : ServiceStartResult() + class AlreadyStarted : ServiceStartResult() + class Failure(val attempt: Int) : ServiceStartResult() } } \ No newline at end of file diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt index 91d5fcc7d4..c588b44523 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt @@ -27,25 +27,25 @@ sealed class ServiceStartException : Exception() { /** * 详情查看 `Context.startForegroundService`. */ - data class StartFailed(override val cause: Throwable? = null) : ServiceStartException() + class StartFailed(override val cause: Throwable? = null) : ServiceStartException() /** * 服务启动了, 但服务回应了启动失败, 详情查看 [AniTorrentService][me.him188.ani.app.domain.torrent.service.AniTorrentService] 中的 `INTENT_STARTUP`. */ - data object StartRespondFailure : ServiceStartException() + class StartRespondFailure : ServiceStartException() /** * 绑定服务失败, 详情查看 `Context.bindService`. */ - data object BindServiceFailed : ServiceStartException() + class BindServiceFailed : ServiceStartException() /** * 绑定成功, 但是获取了空服务通信对象. */ - data object NullBinder : ServiceStartException() + class NullBinder : ServiceStartException() /** * 服务在等待通信对象的时候意外断开了连接. 详情查看 `android.content.ServiceConnection` 中的 `onServiceDisconnected`. */ - data object DisconnectedUnexpectedly : ServiceStartException() + class DisconnectedUnexpectedly : ServiceStartException() } \ No newline at end of file diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt index 2fbe3b8dda..16993c1517 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt @@ -24,7 +24,7 @@ abstract class AbstractTorrentServiceConnectionTest { protected val startServiceWithSuccess = object : TorrentServiceStarter { override suspend fun start(): String { - delay(300) + delay(200) return fakeBinder } } @@ -32,7 +32,7 @@ abstract class AbstractTorrentServiceConnectionTest { protected val startServiceWithFail = object : TorrentServiceStarter { override suspend fun start(): String { delay(100) - throw ServiceStartException.NullBinder + throw ServiceStartException.NullBinder() } } diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt index c34606a8c9..41db6eb19d 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -13,8 +13,9 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.testing.TestLifecycleOwner import app.cash.turbine.test import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -46,6 +47,8 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // completed expectNoEvents() } + + connection.close() } @Test @@ -69,9 +72,11 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // We'll watch for a short while and confirm it does not become true repeat(3) { expectNoEvents() - advanceTimeBy(8000) // Let the internal retry happen + advanceTimeBy(10000) // Let the internal retry happen } } + + connection.close() } @Test @@ -84,12 +89,12 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect ).also { it.startLifecycleLoop() } // Start a coroutine that calls getBinder - val binderDeferred = async { + val binderDeferred = async(start = CoroutineStart.UNDISPATCHED) { connection.getBinder() // Should suspend } // The call hasn't returned yet, because we haven't simulated connect - advanceTimeBy(200) // Enough to start the service, but not connect + advanceUntilIdle() // Enough to start the service, but not connect assertTrue(!binderDeferred.isCompleted) testLifecycle.setCurrentState(Lifecycle.State.RESUMED) @@ -97,6 +102,8 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // Once connected, getBinder should complete with the fake binder val binder = binderDeferred.await() assertEquals(fakeBinder, binder) + + connection.close() } @Test @@ -112,22 +119,25 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect assertFalse(awaitItem(), "Initially, connected should be false.") // Wait for the startService invocation testLifecycle.setCurrentState(Lifecycle.State.RESUMED) - advanceTimeBy(200) + advanceUntilIdle() // Now it’s connected assertTrue(awaitItem(), "Service should be connected.") // Disconnect: connection.onServiceDisconnected() + advanceUntilIdle() assertFalse(awaitItem(), "Service should be disconnected since we triggered disconnection.") // Because the lifecycle is still in RESUMED, // it should attempt to startService again automatically // We can wait a bit, then connect again: - advanceTimeBy(200) // let the startService happen + advanceUntilIdle() // let the startService happen assertTrue(awaitItem(), "Service should be reconnected since lifecycle state is RESUMED.") expectNoEvents() } + + connection.close() } @Test @@ -144,7 +154,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // Wait for the startService invocation testLifecycle.setCurrentState(Lifecycle.State.RESUMED) - advanceTimeBy(200) + advanceUntilIdle() // Now it’s connected assertTrue(awaitItem(), "Service should be connected.") @@ -154,13 +164,45 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect connection.onServiceDisconnected() // Should remain disconnected, no auto retry because we are no longer in RESUMED - advanceTimeBy(2000) + advanceUntilIdle() assertFalse(awaitItem(), "Service should not be connected because current lifecycle state is not RESUMED.") + + advanceUntilIdle() + expectNoEvents() } + + connection.close() } @Test - fun `close cancels all coroutines and flows`() = runTest { + fun `lifecycle move to STARTED while starting service`() = runTest { + val testLifecycle = TestLifecycleOwner() + val connection = LifecycleAwareTorrentServiceConnection( + parentCoroutineContext = backgroundScope.coroutineContext, + lifecycle = testLifecycle.lifecycle, + starter = startServiceWithSuccess, + ).also { it.startLifecycleLoop() } + + connection.connected.test { + assertFalse(awaitItem(), "Initially, connected should be false.") + + + // Wait for the startService invocation + // connect 成功需要 200ms, 100ms 后就 move to STARTED + testLifecycle.setCurrentState(Lifecycle.State.RESUMED) + advanceTimeBy(100) + testLifecycle.setCurrentState(Lifecycle.State.STARTED) + + // 启动中途切到后台 (lifecycle state => STARTED) 不会 emit true + advanceUntilIdle() + expectNoEvents() + } + + connection.close() + } + + @Test + fun `fast path for service disconnected and also does not affect binder getter`() = runTest { val testLifecycle = TestLifecycleOwner() val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, @@ -172,19 +214,46 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect assertFalse(connection.connected.value) // Attempt to call getBinder() after close => should never succeed - val cancelled = CompletableDeferred() - launch { - try { - connection.getBinder() - } catch (ex: CancellationException) { - cancelled.complete(true) + var cancelled = false + + connection.connected.test { + assertFalse(awaitItem(), "Initially, connected should be false.") + + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + try { + connection.getBinder() + } catch (ex: CancellationException) { + cancelled = true + } } + + // connect 成功需要 200ms, 100ms 后就 trigger disconnect + // 此时 connected 不会被设置为 true, 因为通过 fast path 检测到了 disconnect + testLifecycle.setCurrentState(Lifecycle.State.RESUMED) + delay(100) + connection.onServiceDisconnected() + + advanceUntilIdle() + testLifecycle.setCurrentState(Lifecycle.State.STARTED) + + // no connected = false should be emitted, and also binder getter is still active. + expectNoEvents() + assertFalse(cancelled, "getBinder() should not be cancelled.") + + advanceUntilIdle() + testLifecycle.setCurrentState(Lifecycle.State.RESUMED) + delay(500) + testLifecycle.setCurrentState(Lifecycle.State.STARTED) + connection.onServiceDisconnected() + + advanceUntilIdle() + assertTrue(awaitItem(), "Service should be connected.") + assertFalse(awaitItem(), "Service should be disconnected.") + + advanceUntilIdle() + expectNoEvents() } - - advanceUntilIdle() - connection.close() - advanceUntilIdle() - assertTrue(cancelled.await(), "getBinder() should be cancelled because connection is closed.") + connection.close() } } \ No newline at end of file From 399735590174acc06442aa67a654a42f24760586 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sun, 9 Feb 2025 20:16:16 +0800 Subject: [PATCH 42/48] checked exception --- .../kotlin/domain/torrent/service/TorrentServiceStarter.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt index c588b44523..973a443fdd 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt @@ -20,6 +20,7 @@ interface TorrentServiceStarter { * * @return 服务通信对象 */ + @Throws(ServiceStartException::class) suspend fun start(): T } From a4e7ee0da57aeb14da997e62aa9036027dc2fd10 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sun, 9 Feb 2025 21:30:02 +0800 Subject: [PATCH 43/48] optimize logic, use flow-based state observation. --- .../service/TorrentServiceConnection.kt | 113 +++++------------- 1 file changed, 27 insertions(+), 86 deletions(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index c5c5d46d43..9be118d779 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -16,18 +16,14 @@ import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.yield import me.him188.ani.utils.coroutines.childScope import me.him188.ani.utils.logging.debug import me.him188.ani.utils.logging.error @@ -73,17 +69,11 @@ class LifecycleAwareTorrentServiceConnection( private val lifecycle: Lifecycle, private val starter: TorrentServiceStarter, ) : TorrentServiceConnection { - private val logger = logger(this::class.simpleName ?: "LifecycleAwareTorrentServiceConnection") - + private val logger = logger>() private val scope = parentCoroutineContext.childScope() private val binderDeferred = MutableStateFlow(CompletableDeferred()) - - // read at `onServiceDisconnected`, set by `startLifecycleLoop` - private val isAtForeground: MutableStateFlow = MutableStateFlow(false) private val isServiceConnected: MutableStateFlow = MutableStateFlow(false) - private val lock = Mutex() - private val startServiceFlow = MutableStateFlow(Any()) private val lifecycleLoopLock = SynchronizedObject() private var started = false @@ -97,50 +87,39 @@ class LifecycleAwareTorrentServiceConnection( if (started) return scope.launch(CoroutineName("TorrentServiceConnection - RepeatOnResume")) { - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - isAtForeground.value = true - try { - startServiceFlow.collectLatest { - // 如果 collectLatest 执行后 onServiceDisconnected 也立刻 launch, 最终状态应该是断开了连接 - // 此处让出调度权, 以便 onServiceDisconnected 可以立刻执行 - // 让出调度后, onServiceDisconnected 会立刻 set new deferred, 然后 yield 还回调度权 - val beforeYieldedBinderDeferred = binderDeferred.value - - yield() - val result = startServiceAndGetBinder() + lifecycleLoop() + } - val afterYieldedBinderDeferred = binderDeferred.value + started = true + } + } - // 如果在 yield 后, binderDeferred 已经被重新设置, 则说明 onServiceDisconnected 已经执行了 - // fast path for disconnected. - if (beforeYieldedBinderDeferred != afterYieldedBinderDeferred) { - return@collectLatest - } + private suspend fun lifecycleLoop() = lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + try { + // 每当 app 在前台 (lifecycle state = RESUMED) 并且未连接服务时都会尝试连接 + isServiceConnected.filter { !it }.collect { + binderDeferred.value = CompletableDeferred() - if (result is ServiceStartResult.AlreadyStarted) return@collectLatest - if (result is ServiceStartResult.Failure) { - logger.error { "Failed to start service after ${result.attempt} attempts." } - return@collectLatest - } - if (result !is ServiceStartResult.Success) error("unreachable condition") + when (val result = startServiceAndGetBinder()) { + is ServiceStartResult.AlreadyStarted -> { + isServiceConnected.value = true + } - logger.debug { "Service is connected: ${result.binder}" } + is ServiceStartResult.Failure -> { + logger.error { "Failed to start service after ${result.attempt} attempts." } + } - lock.withLock { - binderDeferred.value = CompletableDeferred() - binderDeferred.value.complete(result.binder) + is ServiceStartResult.Success -> { + logger.debug { "Service is connected: ${result.binder}" } - isServiceConnected.value = true - } - } - } catch (_: CancellationException) { - isAtForeground.value = false - logger.debug { "App moved to background." } + binderDeferred.value.complete(result.binder) + isServiceConnected.value = true // side effect } } } - - started = true + } catch (e: CancellationException) { + logger.debug { "App moved to background." } + throw e } } @@ -149,45 +128,7 @@ class LifecycleAwareTorrentServiceConnection( * 如果目前 APP 还在前台, 就要尝试重启并重连服务. */ fun onServiceDisconnected() { - scope.launch( - CoroutineName("TorrentServiceConnection - ServiceDisconnected"), - start = CoroutineStart.UNDISPATCHED, - ) { - if (!isServiceConnected.value) { - // 已经是断开状态,直接忽略 - return@launch - } - - if (lock.tryLock()) { // 如果 lock 成功了, 说明 startServiceFlow collector 没在 lock 阶段 - try { - isServiceConnected.value = false - binderDeferred.value = CompletableDeferred() - - yield() // 配合 startServiceFlow collector 中的 yield, 可以检测到 binderDeferred 的变化 - } finally { - lock.unlock() - } - } else { - lock.withLock { - if (!isServiceConnected.value) { - // 已经是断开状态,直接忽略 - return@launch - } - - isServiceConnected.value = false - binderDeferred.value = CompletableDeferred() - } - } - - // 若应用仍想要连接,则重新启动 - if (isAtForeground.value) { - logger.debug { "Service is disconnected and app is in foreground, restarting service connection in 1000 ms..." } - delay(1000) - startServiceFlow.value = Any() - } else { - logger.debug { "Service is disconnected and app is in background." } - } - } + isServiceConnected.value = false } /** From fa7392626a82aa33a7d1d38c1c97d802de99d995 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sun, 9 Feb 2025 21:33:49 +0800 Subject: [PATCH 44/48] add doc --- .../domain/torrent/service/TorrentServiceConnection.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index 9be118d779..d98d2309db 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -98,6 +98,10 @@ class LifecycleAwareTorrentServiceConnection( try { // 每当 app 在前台 (lifecycle state = RESUMED) 并且未连接服务时都会尝试连接 isServiceConnected.filter { !it }.collect { + val currentDeferred = binderDeferred.value + if (currentDeferred.isActive) { + currentDeferred.cancel(CancellationException("Service disconnected.")) + } binderDeferred.value = CompletableDeferred() when (val result = startServiceAndGetBinder()) { @@ -182,12 +186,14 @@ class LifecycleAwareTorrentServiceConnection( // 如果 isServiceDisconnected 为 false, 那 binderDeferred 一定是未完成的, 见 onServiceDisconnected // 如果 isServiceDisconnected 为 true, 那 binderDeferred 一定是已完成的, 见 startServiceWithRetry return binderDeferred.transformLatest { + // 等 deferred 结束. 如果 deferred 被 cancel, join 不会抛出 CancellationException 异常 it.join() - + // 此时 deferred 可能是 cancelled 或 completed try { + // 如果 completed 就可以成功获取到 binder emit(it.await()) } catch (_: CancellationException) { - + // 如果是 cancelled 就忽略异常, 等待 binderDeferred flow emit 新的 } }.first() } From 40975690a29bed423057c770d93f08dd730eab79 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sun, 9 Feb 2025 21:38:59 +0800 Subject: [PATCH 45/48] optimize --- .../domain/torrent/service/TorrentServiceConnection.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt index d98d2309db..d607e829f0 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceConnection.kt @@ -30,6 +30,8 @@ import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.logger import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds /** * Torrent 服务与 APP 通信接口. [T] 为通信接口的类型 @@ -144,7 +146,7 @@ class LifecycleAwareTorrentServiceConnection( */ private suspend fun startServiceAndGetBinder( maxAttempts: Int = Int.MAX_VALUE, // 可根据需求设置 - intervalMillisBetweenAttempts: Long = 2500 + intervalBetweenAttempt: Duration = 2500.milliseconds ): ServiceStartResult { if (isServiceConnected.value) return ServiceStartResult.AlreadyStarted() var attempt = 0 @@ -157,10 +159,10 @@ class LifecycleAwareTorrentServiceConnection( return ServiceStartResult.Success(result) } catch (ex: ServiceStartException) { - logger.error(ex) { "[#$attempt] Failed to start service, retry after $intervalMillisBetweenAttempts ms" } + logger.error(ex) { "[#$attempt] Failed to start service, retry after $intervalBetweenAttempt" } attempt++ - delay(intervalMillisBetweenAttempts) + delay(intervalBetweenAttempt) continue } @@ -183,8 +185,6 @@ class LifecycleAwareTorrentServiceConnection( * - 如果服务还未连接, 此函数将一直挂起. */ override suspend fun getBinder(): T { - // 如果 isServiceDisconnected 为 false, 那 binderDeferred 一定是未完成的, 见 onServiceDisconnected - // 如果 isServiceDisconnected 为 true, 那 binderDeferred 一定是已完成的, 见 startServiceWithRetry return binderDeferred.transformLatest { // 等 deferred 结束. 如果 deferred 被 cancel, join 不会抛出 CancellationException 异常 it.join() From fa7551518e883432a0b4f57a9e92682ab97d8473 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Sun, 9 Feb 2025 22:01:44 +0800 Subject: [PATCH 46/48] use deferred as test service completion --- .../AbstractTorrentServiceConnectionTest.kt | 28 ++++++------ ...ecycleAwareTorrentServiceConnectionTest.kt | 44 ++++++++++++------- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt index 16993c1517..89194b39c5 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/AbstractTorrentServiceConnectionTest.kt @@ -9,8 +9,8 @@ package me.him188.ani.app.domain.torrent +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -22,18 +22,20 @@ import kotlin.test.BeforeTest abstract class AbstractTorrentServiceConnectionTest { protected val fakeBinder = "FAKE_BINDER_OBJECT" - protected val startServiceWithSuccess = object : TorrentServiceStarter { - override suspend fun start(): String { - delay(200) - return fakeBinder - } - } - - protected val startServiceWithFail = object : TorrentServiceStarter { - override suspend fun start(): String { - delay(100) - throw ServiceStartException.NullBinder() - } + protected fun createStarter( + expectSuccess: Boolean + ): Pair, CompletableDeferred> { + val deferred = CompletableDeferred() + return object : TorrentServiceStarter { + override suspend fun start(): String { + deferred.await() + if (expectSuccess) { + return fakeBinder + } else { + throw ServiceStartException.NullBinder() + } + } + } to deferred } @BeforeTest diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt index 41db6eb19d..224b565892 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -15,7 +15,6 @@ import app.cash.turbine.test import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -31,10 +30,11 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `service starts on resume - success`() = runTest { val testLifecycle = TestLifecycleOwner() + val (starter, deferred) = createStarter(expectSuccess = true) val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, lifecycle = testLifecycle.lifecycle, - starter = startServiceWithSuccess, + starter = starter, ).also { it.startLifecycleLoop() } connection.connected.test { @@ -42,6 +42,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // trigger on resumed testLifecycle.setCurrentState(Lifecycle.State.RESUMED) + backgroundScope.launch { deferred.complete(Unit) } assertTrue(awaitItem(), "After service is connected, connected should become true.") // completed @@ -54,10 +55,11 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `service starts on resume - fails to start service`() = runTest { val testLifecycle = TestLifecycleOwner() + val (starter, deferred) = createStarter(expectSuccess = false) val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, lifecycle = testLifecycle.lifecycle, - starter = startServiceWithFail, + starter = starter, ).also { it.startLifecycleLoop() } // The .connected flow should remain false, even after we move to resumed, @@ -67,7 +69,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // Move to RESUMED testLifecycle.setCurrentState(Lifecycle.State.RESUMED) - + backgroundScope.launch { deferred.complete(Unit) } // Because startService() fails repeatedly in the retry loop, connected never becomes true // We'll watch for a short while and confirm it does not become true repeat(3) { @@ -82,10 +84,11 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `getBinder suspends until service is connected`() = runTest { val testLifecycle = TestLifecycleOwner() + val (starter, deferred) = createStarter(expectSuccess = true) val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, lifecycle = testLifecycle.lifecycle, - starter = startServiceWithSuccess, + starter = starter, ).also { it.startLifecycleLoop() } // Start a coroutine that calls getBinder @@ -98,6 +101,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect assertTrue(!binderDeferred.isCompleted) testLifecycle.setCurrentState(Lifecycle.State.RESUMED) + backgroundScope.launch { deferred.complete(Unit) } // Once connected, getBinder should complete with the fake binder val binder = binderDeferred.await() @@ -109,16 +113,18 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `service disconnect triggers automatic restart if lifecycle is RESUMED`() = runTest { val testLifecycle = TestLifecycleOwner() + val (starter, deferred) = createStarter(expectSuccess = true) val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, lifecycle = testLifecycle.lifecycle, - starter = startServiceWithSuccess, + starter = starter, ).also { it.startLifecycleLoop() } connection.connected.test { assertFalse(awaitItem(), "Initially, connected should be false.") // Wait for the startService invocation testLifecycle.setCurrentState(Lifecycle.State.RESUMED) + backgroundScope.launch { deferred.complete(Unit) } advanceUntilIdle() // Now it’s connected assertTrue(awaitItem(), "Service should be connected.") @@ -128,6 +134,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect advanceUntilIdle() assertFalse(awaitItem(), "Service should be disconnected since we triggered disconnection.") + backgroundScope.launch { deferred.complete(Unit) } // Because the lifecycle is still in RESUMED, // it should attempt to startService again automatically // We can wait a bit, then connect again: @@ -143,10 +150,11 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `service disconnect does not restart if lifecycle is only CREATED`() = runTest { val testLifecycle = TestLifecycleOwner() + val (starter, deferred) = createStarter(expectSuccess = true) val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, lifecycle = testLifecycle.lifecycle, - starter = startServiceWithSuccess, + starter = starter, ).also { it.startLifecycleLoop() } connection.connected.test { @@ -154,6 +162,7 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // Wait for the startService invocation testLifecycle.setCurrentState(Lifecycle.State.RESUMED) + backgroundScope.launch { deferred.complete(Unit) } advanceUntilIdle() // Now it’s connected assertTrue(awaitItem(), "Service should be connected.") @@ -177,10 +186,11 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `lifecycle move to STARTED while starting service`() = runTest { val testLifecycle = TestLifecycleOwner() + val (starter, deferred) = createStarter(expectSuccess = true) val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, lifecycle = testLifecycle.lifecycle, - starter = startServiceWithSuccess, + starter = starter, ).also { it.startLifecycleLoop() } connection.connected.test { @@ -190,7 +200,6 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // Wait for the startService invocation // connect 成功需要 200ms, 100ms 后就 move to STARTED testLifecycle.setCurrentState(Lifecycle.State.RESUMED) - advanceTimeBy(100) testLifecycle.setCurrentState(Lifecycle.State.STARTED) // 启动中途切到后台 (lifecycle state => STARTED) 不会 emit true @@ -204,10 +213,11 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect @Test fun `fast path for service disconnected and also does not affect binder getter`() = runTest { val testLifecycle = TestLifecycleOwner() + val (starter, deferred) = createStarter(expectSuccess = true) val connection = LifecycleAwareTorrentServiceConnection( parentCoroutineContext = backgroundScope.coroutineContext, lifecycle = testLifecycle.lifecycle, - starter = startServiceWithSuccess, + starter = starter, ).also { it.startLifecycleLoop() } advanceUntilIdle() @@ -230,7 +240,8 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // connect 成功需要 200ms, 100ms 后就 trigger disconnect // 此时 connected 不会被设置为 true, 因为通过 fast path 检测到了 disconnect testLifecycle.setCurrentState(Lifecycle.State.RESUMED) - delay(100) + + advanceUntilIdle() connection.onServiceDisconnected() advanceUntilIdle() @@ -242,16 +253,15 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect advanceUntilIdle() testLifecycle.setCurrentState(Lifecycle.State.RESUMED) - delay(500) - testLifecycle.setCurrentState(Lifecycle.State.STARTED) - connection.onServiceDisconnected() - + backgroundScope.launch { deferred.complete(Unit) } advanceUntilIdle() + // Now it’s connected assertTrue(awaitItem(), "Service should be connected.") - assertFalse(awaitItem(), "Service should be disconnected.") + // Disconnect: + connection.onServiceDisconnected() advanceUntilIdle() - expectNoEvents() + assertFalse(awaitItem(), "Service should be disconnected since we triggered disconnection.") } connection.close() From 4c3ceebebdef6bd1d5bafa76fbeda9bc27600522 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Mon, 10 Feb 2025 00:33:20 +0800 Subject: [PATCH 47/48] checked exception --- .../kotlin/domain/torrent/service/TorrentServiceStarter.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt index 973a443fdd..dd5a5cad48 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/torrent/service/TorrentServiceStarter.kt @@ -9,6 +9,8 @@ package me.him188.ani.app.domain.torrent.service +import kotlin.coroutines.cancellation.CancellationException + /** * 启动 torrent 服务的接口, 并获取[服务通信对象][T]. */ @@ -20,7 +22,7 @@ interface TorrentServiceStarter { * * @return 服务通信对象 */ - @Throws(ServiceStartException::class) + @Throws(ServiceStartException::class, CancellationException::class) suspend fun start(): T } From 7baf5507aca6796b82ed984ab7123e06d2f25075 Mon Sep 17 00:00:00 2001 From: StageGuard Date: Mon, 10 Feb 2025 00:33:34 +0800 Subject: [PATCH 48/48] fix test --- .../torrent/LifecycleAwareTorrentServiceConnectionTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt index 224b565892..86b84ad1b8 100644 --- a/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt +++ b/app/shared/app-data/src/commonTest/kotlin/domain/torrent/LifecycleAwareTorrentServiceConnectionTest.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import me.him188.ani.app.domain.torrent.service.LifecycleAwareTorrentServiceConnection import kotlin.test.Test @@ -200,7 +201,9 @@ class LifecycleAwareTorrentServiceConnectionTest : AbstractTorrentServiceConnect // Wait for the startService invocation // connect 成功需要 200ms, 100ms 后就 move to STARTED testLifecycle.setCurrentState(Lifecycle.State.RESUMED) + runCurrent() // ensure flow collector receives the new value testLifecycle.setCurrentState(Lifecycle.State.STARTED) + runCurrent() // 启动中途切到后台 (lifecycle state => STARTED) 不会 emit true advanceUntilIdle()