Browse Source

feat: 适配Android 17的ACCESS_LOCAL_NETWORK权限

HapeLee 1 tháng trước cách đây
mục cha
commit
c0c4710c26

+ 3 - 0
app/src/main/AndroidManifest.xml

@@ -5,6 +5,9 @@
     <uses-sdk tools:overrideLibrary="top.yukonga.miuix.kmp.blur" />
 
     <uses-permission android:name="android.permission.INTERNET" />
+    <!-- 自 Android 17 (API 37) 起本地网络保护强制生效:接受局域网入站连接需要该运行时权限,
+         否则 Web 服务对局域网内其他设备不可达。targetSdk < 37 时不得声明该权限。 -->
+    <uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
     <uses-permission android:name="android.permission.WAKE_LOCK" />
     <uses-permission android:name="android.permission.READ_PHONE_STATE" />
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

+ 24 - 4
app/src/main/java/io/legado/app/service/WebService.kt

@@ -1,13 +1,17 @@
 package io.legado.app.service
 
 // ——————【新增引用】——————
+import android.Manifest
 import android.annotation.SuppressLint
 import android.content.Context
 import android.content.Intent
+import android.content.pm.PackageManager
 import android.net.wifi.WifiManager
 import android.os.Build
 import android.os.PowerManager
 import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
 import io.legado.app.R
 import io.legado.app.base.BaseService
 import io.legado.app.constant.AppConst
@@ -15,15 +19,14 @@ import io.legado.app.constant.EventBus
 import io.legado.app.constant.IntentAction
 import io.legado.app.constant.NotificationId
 import io.legado.app.constant.PreferKey
-import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.eventBus.FlowEventBus
 import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.getPrefInt
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.printOnDebug
-import io.legado.app.utils.putPrefBoolean
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.servicePendingIntent
 import io.legado.app.utils.startForegroundServiceCompat
@@ -31,22 +34,39 @@ import io.legado.app.utils.startService
 import io.legado.app.utils.stopService
 import io.legado.app.utils.toastOnUi
 import io.legado.app.web.KtorServer
-import androidx.lifecycle.lifecycleScope
 import kotlinx.coroutines.launch
 import org.koin.android.ext.android.inject
 import splitties.init.appCtx
 import splitties.systemservices.powerManager
 import splitties.systemservices.wifiManager
-import java.io.IOException
 
 class WebService : BaseService() {
 
     private val otherSettingsGateway by inject<OtherSettingsGateway>()
 
     companion object {
+        /**
+         * Android 17 (API 37) 起本地网络默认屏蔽(入站连接同样受限),
+         * SDK 尚未提供对应的 Build.VERSION_CODES 常量,这里用字面量。
+         */
+        private const val LOCAL_NETWORK_PERMISSION_SDK_INT = 37
+
         var isRun = false
         var hostAddress = ""
 
+        private val isLocalNetworkPermissionRequired: Boolean
+            get() = Build.VERSION.SDK_INT >= LOCAL_NETWORK_PERMISSION_SDK_INT
+
+        /**
+         * 未授予时系统会静默丢弃局域网入站连接,Web 服务在其它设备上不可达。
+         * 需要本地网络访问前必须先检查;低于 API 37 的设备隐式授予,无需申请。
+         */
+        fun hasLocalNetworkPermission(context: Context): Boolean =
+            !isLocalNetworkPermissionRequired || ContextCompat.checkSelfPermission(
+                context,
+                Manifest.permission.ACCESS_LOCAL_NETWORK
+            ) == PackageManager.PERMISSION_GRANTED
+
         fun start(context: Context) {
             context.startService<WebService>()
         }

+ 19 - 0
app/src/main/java/io/legado/app/service/WebTileService.kt

@@ -2,6 +2,7 @@ package io.legado.app.service
 
 import android.app.Dialog
 import android.app.ForegroundServiceStartNotAllowedException
+import android.app.PendingIntent
 import android.content.Intent
 import android.os.Build
 import android.service.quicksettings.Tile
@@ -10,6 +11,7 @@ import android.view.WindowManager.BadTokenException
 import androidx.annotation.RequiresApi
 import io.legado.app.R
 import io.legado.app.constant.IntentAction
+import io.legado.app.ui.main.MainIntent
 import io.legado.app.utils.printOnDebug
 
 
@@ -54,6 +56,9 @@ class WebTileService : TileService() {
         super.onClick()
         if (WebService.isRun) {
             WebService.stop(this)
+        } else if (!WebService.hasLocalNetworkPermission(this)) {
+            // TileService 无法发起运行时权限申请,转到宿主 Activity 申请并补启服务
+            openInApp()
         } else {
             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
                 val dialog = Dialog(this, R.style.AppTheme_Transparent)
@@ -76,4 +81,18 @@ class WebTileService : TileService() {
         }
     }
 
+    private fun openInApp() {
+        // 本地网络权限只在 Android 17 (API 37) 及以上才会缺失,此处必然满足 API 34 要求;
+        // 低版本不做处理,避免调用已废弃且对 targetSdk 34+ 抛异常的 Intent 重载。
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
+        startActivityAndCollapse(
+            PendingIntent.getActivity(
+                this,
+                0,
+                MainIntent.createWebServiceLocalNetworkIntent(this),
+                PendingIntent.FLAG_IMMUTABLE
+            )
+        )
+    }
+
 }

+ 34 - 3
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.main
 
+import android.Manifest
 import android.content.Context
 import android.content.Intent
 import android.content.res.Configuration
@@ -9,6 +10,7 @@ import android.view.InputDevice
 import android.view.KeyEvent
 import android.view.MotionEvent
 import androidx.activity.compose.BackHandler
+import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.AnimatedContentTransitionScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionLayout
@@ -67,6 +69,7 @@ import io.legado.app.ui.welcome.WelcomeActivity
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -295,6 +298,15 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
     private val readAloudSettingsRepository by inject<ReadAloudSettingsRepository>()
     private val navRouteTracker by inject<MainNavRouteTracker>()
     private val routeEvents = MutableSharedFlow<RouteEvent>(extraBufferCapacity = 1)
+    private val localNetworkPermissionLauncher = registerForActivityResult(
+        ActivityResultContracts.RequestPermission()
+    ) { granted ->
+        if (granted) {
+            WebService.startForeground(this)
+        } else {
+            toastOnUi(R.string.web_service_local_network_permission_denied)
+        }
+    }
     private var shouldApplyDefaultToRead = true
     private var restoredReadBookRoute: MainRouteReadBook? = null
     internal var activeReadBookInputHandler: ReadBookInputHandler? = null
@@ -312,9 +324,12 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
             otherSettingsGateway.currentSettings.autoCheckUpdateOnStart
         )
 
-        // 智能自启:如果上次是手动开启状态(web_service_auto 为 true),则自启
-        if (otherSettingsGateway.currentSettings.webServiceAutoStart) {
-            WebService.startForeground(this)
+        // 智能自启:如果上次是手动开启状态(web_service_auto 为 true),则自启;
+        // 本地网络权限缺失时先申请,磁贴等入口也通过该 extra 转发到这里。
+        val requestWebService = otherSettingsGateway.currentSettings.webServiceAutoStart ||
+                intent?.getBooleanExtra(MainIntent.EXTRA_WEB_SERVICE_LOCAL_NETWORK, false) == true
+        if (requestWebService) {
+            startWebServiceWithLocalNetworkPermission()
         }
 
         lifecycleScope.launch {
@@ -333,9 +348,25 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
         }
     }
 
+    /**
+     * Android 17 (API 37) 起 Web 服务需要本地网络权限才能接受局域网入站连接。
+     * 已授予直接启动;未授予先申请,授予后由 launcher 回调补启。
+     */
+    private fun startWebServiceWithLocalNetworkPermission() {
+        if (WebService.hasLocalNetworkPermission(this)) {
+            WebService.startForeground(this)
+        } else {
+            localNetworkPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
+        }
+    }
+
     override fun onNewIntent(intent: Intent) {
         super.onNewIntent(intent)
         setIntent(intent)
+        if (intent.getBooleanExtra(MainIntent.EXTRA_WEB_SERVICE_LOCAL_NETWORK, false)) {
+            startWebServiceWithLocalNetworkPermission()
+            return
+        }
         if (!intent.hasExplicitStartRoute()) return
         routeEvents.tryEmit(
             RouteEvent(

+ 10 - 0
app/src/main/java/io/legado/app/ui/main/MainIntent.kt

@@ -39,6 +39,7 @@ object MainIntent {
     const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url"
     const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url"
     const val EXTRA_RSS_KEY = "extra_rss_key"
+    const val EXTRA_WEB_SERVICE_LOCAL_NETWORK = "webServiceLocalNetwork"
 
     const val EXTRA_RSS_READ_TITLE = "extra_rss_read_title"
     const val EXTRA_RSS_READ_ORIGIN = "extra_rss_read_origin"
@@ -61,6 +62,15 @@ object MainIntent {
         }
     }
 
+    /**
+     * 磁贴等无 Activity 的入口无法自行申请本地网络权限,转到宿主 Activity 申请并补启 Web 服务。
+     */
+    fun createWebServiceLocalNetworkIntent(context: Context): Intent =
+        createLauncherIntent(context).apply {
+            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+            putExtra(EXTRA_WEB_SERVICE_LOCAL_NETWORK, true)
+        }
+
     fun createSourceLoginIntent(
         context: Context,
         type: io.legado.app.ui.login.SourceLoginType,

+ 32 - 1
app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt

@@ -1,5 +1,10 @@
 package io.legado.app.ui.main.my
 
+import android.Manifest
+import android.annotation.SuppressLint
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.expandVertically
 import androidx.compose.animation.shrinkVertically
@@ -19,7 +24,6 @@ import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ExitToApp
-import androidx.compose.material.icons.automirrored.filled.HelpOutline
 import androidx.compose.material.icons.automirrored.filled.LibraryBooks
 import androidx.compose.material.icons.automirrored.filled.Rule
 import androidx.compose.material.icons.filled.AutoAwesome
@@ -38,9 +42,11 @@ import androidx.compose.material.icons.filled.Web
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -58,10 +64,12 @@ import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import kotlinx.coroutines.flow.collectLatest
 import org.koin.androidx.compose.koinViewModel
 
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@SuppressLint("InlinedApi") // 仅 API 37+ 缺少权限时才发出该 effect,权限名常量会被内联
 @Composable
 fun MyRouteScreen(
     viewModel: MyViewModel = koinViewModel(),
@@ -70,6 +78,29 @@ fun MyRouteScreen(
     onNavigate: (PrefClickEvent) -> Unit
 ) {
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
+    val localNetworkPermissionLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.RequestPermission()
+    ) { granted ->
+        if (granted) {
+            viewModel.onIntent(MyIntent.StartWebService)
+        } else {
+            Toast.makeText(
+                context,
+                R.string.web_service_local_network_permission_denied,
+                Toast.LENGTH_LONG
+            ).show()
+        }
+    }
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                MyEffect.RequestLocalNetworkPermission -> localNetworkPermissionLauncher.launch(
+                    Manifest.permission.ACCESS_LOCAL_NETWORK
+                )
+            }
+        }
+    }
     MyScreen(
         state = uiState,
         onIntent = viewModel::onIntent,

+ 14 - 7
app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt

@@ -36,9 +36,15 @@ sealed class PrefClickEvent {
 
 sealed interface MyIntent {
     data object ToggleWebService : MyIntent
+
+    /** 本地网络权限授予后由界面触发,避免再次进入申请分支。 */
+    data object StartWebService : MyIntent
 }
 
-sealed interface MyEffect
+sealed interface MyEffect {
+    /** Android 17 起 Web 服务需要先获得本地网络权限才能被其他设备访问。 */
+    data object RequestLocalNetworkPermission : MyEffect
+}
 
 class MyViewModel(
     application: Application
@@ -71,16 +77,17 @@ class MyViewModel(
     fun onIntent(intent: MyIntent) {
         when (intent) {
             MyIntent.ToggleWebService -> {
-                val currentIsRun = _uiState.value.isWebServiceRun
-
-                if (!currentIsRun) {
-                    WebService.start(context)
-                } else {
+                if (_uiState.value.isWebServiceRun) {
                     WebService.stop(context)
                     _uiState.update { it.copy(isWebServiceRun = false, webServiceAddress = "") }
+                } else if (WebService.hasLocalNetworkPermission(context)) {
+                    WebService.start(context)
+                } else {
+                    _effects.tryEmit(MyEffect.RequestLocalNetworkPermission)
                 }
-
             }
+
+            MyIntent.StartWebService -> WebService.start(context)
         }
     }
 

+ 1 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -1399,6 +1399,7 @@
     <string name="update_book_fail">更新失败</string>
     <string name="notification_permission_rationale">阅读需要发送通知来显示朗读控制和下载进度。</string>
     <string name="permission_not_required">无需申请</string>
+    <string name="web_service_local_network_permission_denied">Web 服务需要本地网络权限,否则其他设备无法访问</string>
     <string name="webdav_after_local_restore_confirm">WebDav 书源比本地新,是否恢复</string>
     <string name="c_whitelist">白名单(contentWhitelist)</string>
     <string name="c_blacklist">黑名单(contentBlacklist)</string>

+ 1 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -2061,6 +2061,7 @@
     <string name="palette_style">調色板樣式</string>
     <string name="paragraph_typeface">段落樣式</string>
     <string name="permission_not_required">不需要權限</string>
+    <string name="web_service_local_network_permission_denied">Web 服務需要區域網絡權限,否則其他裝置無法存取</string>
     <string name="photo">相片</string>
     <string name="play_mode">播放模式</string>
     <string name="position_bottom_end">右下角</string>

+ 1 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1747,6 +1747,7 @@
     <string name="found_cloud_progress">發現雲端進度,是否復原?</string>
     <string name="progress_exceeds_cloud">目前進度超過雲端進度,是否覆蓋?</string>
     <string name="permission_not_required">不需要權限</string>
+    <string name="web_service_local_network_permission_denied">Web 服務需要區域網路權限,否則其他裝置無法存取</string>
     <string name="about_privacy_policy_title">隱私政策</string>
     <string name="about_license_title">開源許可</string>
     <string name="about_disclaimer_title">免責聲明</string>

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -1456,6 +1456,7 @@
     <string name="update_book_fail">Update failed</string>
     <string name="notification_permission_rationale">Reading requires sending notifications to show reading control and download progress</string>
     <string name="permission_not_required">Permission not required</string>
+    <string name="web_service_local_network_permission_denied">Web service requires local network permission, otherwise it cannot be accessed from other devices</string>
     <string name="webdav_after_local_restore_confirm">The WebDAV book source is newer than the local one. Do you want to restore it?</string>
     <string name="c_whitelist">Whitelist (contentWhitelist)</string>
     <string name="c_blacklist">Blacklist (contentBlacklist)</string>