Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

分离 torrent 连接管理逻辑, helps #1606 #1616

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
11 changes: 7 additions & 4 deletions app/android/src/main/kotlin/AndroidModules.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +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.TorrentServiceConnection
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
import me.him188.ani.app.platform.AppTerminator
Expand Down Expand Up @@ -75,7 +78,7 @@ import kotlin.system.exitProcess

fun getAndroidModules(
defaultTorrentCacheDir: File,
torrentServiceConnection: TorrentServiceConnection,
torrentServiceConnection: AndroidTorrentServiceConnection,
coroutineScope: CoroutineScope,
) = module {
single<PermissionManager> {
Expand All @@ -99,7 +102,7 @@ fun getAndroidModules(
}
single<BrowserNavigator> { AndroidBrowserNavigator() }

single<TorrentServiceConnection> { torrentServiceConnection }
single<TorrentServiceConnection<IRemoteAniTorrentEngine>> { torrentServiceConnection }

single<TorrentManager> {
val context = androidContext()
Expand Down Expand Up @@ -230,7 +233,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)
Expand Down
27 changes: 6 additions & 21 deletions app/android/src/main/kotlin/AniApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +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.TorrentServiceConnection
import me.him188.ani.app.domain.torrent.service.AndroidTorrentServiceConnection
import me.him188.ani.app.domain.torrent.service.AniTorrentService
import me.him188.ani.app.platform.AndroidLoggingConfigurator
import me.him188.ani.app.platform.JvmLogHelper
import me.him188.ani.app.platform.createAppRootCoroutineScope
Expand All @@ -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
Expand Down Expand Up @@ -86,29 +86,14 @@ class AniApplication : Application() {


val scope = createAppRootCoroutineScope()
val torrentServiceConnection = TorrentServiceConnection(
val torrentServiceConnection = AndroidTorrentServiceConnection(
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_) {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -22,18 +23,19 @@ 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
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_
Expand All @@ -45,7 +47,7 @@ import kotlin.coroutines.CoroutineContext

@RequiresApi(Build.VERSION_CODES.O_MR1)
class RemoteAnitorrentEngine(
private val connection: TorrentServiceConnection,
private val connection: TorrentServiceConnection<IRemoteAniTorrentEngine>,
anitorrentConfigFlow: Flow<AnitorrentConfig>,
proxyConfig: Flow<ProxyConfig?>,
peerFilterConfig: Flow<PeerFilterSettings>,
Expand All @@ -54,28 +56,30 @@ class RemoteAnitorrentEngine(
) : TorrentEngine {
private val logger = logger<RemoteAnitorrentEngine>()

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<Boolean>
override val isSupported: Flow<Boolean>
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(
Expand Down Expand Up @@ -103,7 +107,7 @@ class RemoteAnitorrentEngine(
override suspend fun testConnection(): Boolean {
return connection.connected.value
}

override suspend fun getDownloader(): TorrentDownloader {
return RemoteTorrentDownloader(
fetchRemoteScope,
Expand All @@ -113,11 +117,12 @@ class RemoteAnitorrentEngine(
}

private suspend fun getBinderOrFail(): IRemoteAniTorrentEngine {
return connection.awaitBinder()
return connection.getBinder()
}

override fun close() {
scope.cancel()
dispatcher.close()
fetchRemoteScope.cancel()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
* 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 androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.Dispatchers
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.warn
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.minutes

/**
* 管理与 [AniTorrentService] 的连接并获取 [IRemoteAniTorrentEngine] 远程访问接口.
* 通过 [getBinder] 获取服务接口, 再启动完成并绑定之前将挂起协程.
*
* 服务连接控制依赖的 lifecycle 应当尽可能大, 所以应该使用
* [ProcessLifecycleOwner][androidx.lifecycle.ProcessLifecycleOwner]
* 或其他可以涵盖 app 全局生命周期的自定义 [LifecycleOwner] 来管理服务连接.
* 不能使用 [Activity][android.app.Activity] (例如 [ComponentActivity][androidx.core.app.ComponentActivity])
* 的生命周期, 因为在屏幕旋转 (例如竖屏转全屏播放) 的时候 Activity 可能会摧毁并重新创建,
* 这会导致 [AndroidTorrentServiceConnection] 错误地重新绑定服务或重启服务.
*
* @see androidx.lifecycle.ProcessLifecycleOwner
* @see ServiceConnection
* @see AniTorrentService.onStartCommand
* @see me.him188.ani.android.AniApplication
*/
class AndroidTorrentServiceConnection(
private val context: Context,
private val onRequiredRestartService: () -> ComponentName?,
parentCoroutineContext: CoroutineContext = Dispatchers.Default,
) : ServiceConnection,
LifecycleAwareTorrentServiceConnection<IRemoteAniTorrentEngine>(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)
}
}
Comment on lines +55 to +60
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is lazy really needed here?

Lazy introduces synchornization overhead (which is on main thread).


/**
* 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() {
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 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)
}
}
}

ContextCompat.registerReceiver(
context,
receiver,
startupIntentFilter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)

val result = onRequiredRestartService()
if (result == null) {
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" }
}
}
}

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

override fun onPause(owner: LifecycleOwner) {
// app 到后台的时候注册监听
if (!registered) {
ContextCompat.registerReceiver(
context,
timeExceedLimitReceiver,
timeExceedLimitIntentFilter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)
Comment on lines +128 to +133
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个没有 unregister, 会有问题吗

registered = true
}
try {
// 请求 wake lock, 如果在 app 中息屏可以保证 service 正常跑 [acquireWakeLockIntent] 分钟.
context.startService(acquireWakeLockIntent)
} catch (ex: IllegalStateException) {
// 大概率是 ServiceStartForegroundException, 服务已经终止了, 不需要再请求 wakelock.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

catch ForegroundServiceStartNotAllowedException explicitly

logger.warn(ex) { "Failed to acquire wake lock. Service has already died." }
}
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()
}
}
Loading
Loading