diff --git a/.github/workflows/android-fast.yml b/.github/workflows/android-fast.yml
new file mode 100644
index 000000000..700b2b9af
--- /dev/null
+++ b/.github/workflows/android-fast.yml
@@ -0,0 +1,79 @@
+name: Android Fast Build
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: android-fast-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Setup JDK
+ uses: actions/setup-java@v4
+ with:
+ java-version: 17
+ distribution: zulu
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ gradle-home-cache-cleanup: true
+
+ - name: Verify fast contracts
+ run: |
+ ./gradlew \
+ :extension:api:jvmTest \
+ :extension:runtime:test \
+ :i18n:testDebugUnitTest
+
+ - name: Build debug APKs
+ run: |
+ ./gradlew \
+ :app:smartphone:assembleDebug \
+ :app:tv:assembleDebug \
+ :testing:extension-reference:assembleDebug
+
+ - name: Verify 16 KB native compatibility
+ run: |
+ testing/bin/verify-android-16kb.sh \
+ app/smartphone/build/outputs/apk/debug/*.apk \
+ app/tv/build/outputs/apk/debug/*.apk \
+ testing/extension-reference/build/outputs/apk/debug/*.apk
+
+ - name: Upload
+ uses: actions/upload-artifact@v4
+ with:
+ name: android-debug-apks
+ if-no-files-found: error
+ retention-days: 14
+ path: |
+ app/smartphone/build/outputs/apk/debug/*.apk
+ app/tv/build/outputs/apk/debug/*.apk
+ testing/extension-reference/build/outputs/apk/debug/*.apk
+
+ - name: Upload To Telegram
+ uses: xireiki/channel-post@890c1d09635ae629aeffbed4f9716a5473eac896 # v1.0.10
+ with:
+ bot_token: ${{ secrets.BOT_TOKEN }}
+ chat_id: ${{ secrets.CHAT_ID }}
+ api_id: ${{ secrets.API_ID }}
+ api_hash: ${{ secrets.API_HASH }}
+ large_file: true
+ method: sendFile
+ path: |
+ app/smartphone/build/outputs/apk/debug/*.apk
+ app/tv/build/outputs/apk/debug/*.apk
+ testing/extension-reference/build/outputs/apk/debug/*.apk
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index f98972f93..71553678d 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -6,16 +6,14 @@ on:
paths-ignore:
- '**.md'
- '**.txt'
- - '.github/**'
- '.idea/**'
- 'fastlane/**'
- - '!.github/workflows/**'
pull_request:
branches: [ "master" ]
workflow_dispatch:
concurrency:
- group: ${{ github.ref }}
+ group: android-release-${{ github.ref }}
cancel-in-progress: true
jobs:
@@ -44,23 +42,53 @@ jobs:
- name: Clean GMD
run: ./gradlew cleanManagedDevices --unused-only
- - name: Build production app
+ - name: Verify extension contracts
+ run: |
+ ./gradlew \
+ :extension:api:jvmTest \
+ :extension:runtime:test \
+ :extension:sdk-android:testDebugUnitTest \
+ :extension:transport-android:testDebugUnitTest
+
+ - name: Verify localization contracts
+ run: ./gradlew :i18n:testDebugUnitTest
+
+ - name: Validate connected UI harnesses
+ run: |
+ bash -n testing/bin/run-smartphone-provider-ui-matrix.sh
+ ./gradlew \
+ :data:assembleDebugAndroidTest \
+ :app:smartphone:assembleDebugAndroidTest \
+ :app:tv:assembleDebugAndroidTest
+
+ - name: Build smartphone release APK
run: ./gradlew :app:smartphone:assembleRelease
- - name: Build production TV app
+ - name: Build TV release APK
run: ./gradlew :app:tv:assembleRelease
+ - name: Build reference extension release APK
+ run: ./gradlew :testing:extension-reference:assembleRelease
+
+ - name: Verify 16 KB native compatibility
+ run: |
+ testing/bin/verify-android-16kb.sh \
+ app/smartphone/build/outputs/published-apk/release/*.apk \
+ app/tv/build/outputs/published-apk/release/*.apk \
+ testing/extension-reference/build/outputs/apk/release/*.apk
+
- name: Upload
uses: actions/upload-artifact@v4
with:
if-no-files-found: error
path: |
- app/smartphone/build/outputs/apk/release/*.apk
- app/tv/build/outputs/apk/release/*.apk
+ app/smartphone/build/outputs/published-apk/release/*.apk
+ app/tv/build/outputs/published-apk/release/*.apk
+ testing/extension-reference/build/outputs/apk/release/*.apk
- name: Upload To Telegram
if: github.event_name != 'pull_request'
- uses: xireiki/channel-post@v1.0.10
+ uses: xireiki/channel-post@890c1d09635ae629aeffbed4f9716a5473eac896 # v1.0.10
with:
bot_token: ${{ secrets.BOT_TOKEN }}
chat_id: ${{ secrets.CHAT_ID }}
@@ -69,5 +97,6 @@ jobs:
large_file: true
method: sendFile
path: |
- app/smartphone/build/outputs/apk/release/*.apk
- app/tv/build/outputs/apk/release/*.apk
+ app/smartphone/build/outputs/published-apk/release/*.apk
+ app/tv/build/outputs/published-apk/release/*.apk
+ testing/extension-reference/build/outputs/apk/release/*.apk
diff --git a/.github/workflows/baseline-profiles.yml b/.github/workflows/baseline-profiles.yml
index 377b38a2e..cc4054a3b 100644
--- a/.github/workflows/baseline-profiles.yml
+++ b/.github/workflows/baseline-profiles.yml
@@ -5,8 +5,12 @@ on:
push:
branches: [ "master" ]
paths:
+ - 'build.gradle.kts'
+ - 'gradle/libs.versions.toml'
+ - 'gradle/wrapper/**'
- 'app/smartphone/build.gradle.kts'
- 'app/tv/build.gradle.kts'
+ - 'baselineprofile/**'
- '.github/workflows/baseline-profiles.yml'
permissions:
@@ -40,15 +44,18 @@ jobs:
if [ "$base" = "0000000000000000000000000000000000000000" ]; then
base="$(git rev-list --max-parents=0 HEAD)"
fi
- if git diff --name-only "$base" HEAD -- .github/workflows/baseline-profiles.yml | grep -q .; then
+ if git diff --name-only "$base" HEAD -- \
+ .github/workflows/baseline-profiles.yml \
+ build.gradle.kts \
+ gradle/libs.versions.toml \
+ gradle/wrapper \
+ app/smartphone/build.gradle.kts \
+ app/tv/build.gradle.kts \
+ baselineprofile | grep -q .; then
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
- if git diff "$base" HEAD -- app/smartphone/build.gradle.kts app/tv/build.gradle.kts | grep -E '^[-+][[:space:]]*version(Code|Name)[[:space:]]*='; then
- echo "run=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- echo "No app version change detected."
+ echo "No baseline profile inputs changed."
echo "run=false" >> "$GITHUB_OUTPUT"
- name: Setup JDK
@@ -75,6 +82,15 @@ jobs:
sudo chmod 660 /dev/kvm
ls -l /dev/kvm
+ - name: Remove stale baseline profile output
+ if: steps.baseline-trigger.outputs.run == 'true'
+ run: |
+ rm -f \
+ app/smartphone/src/main/generated/baselineProfiles/baseline-prof.txt \
+ app/smartphone/src/main/generated/baselineProfiles/startup-prof.txt \
+ app/tv/src/main/generated/baselineProfiles/baseline-prof.txt \
+ app/tv/src/main/generated/baselineProfiles/startup-prof.txt
+
- name: Generate smartphone baseline profile
if: steps.baseline-trigger.outputs.run == 'true'
run: |
@@ -97,6 +113,11 @@ jobs:
if: steps.baseline-trigger.outputs.run == 'true'
run: |
set -euo pipefail
+ test -s app/smartphone/src/main/generated/baselineProfiles/baseline-prof.txt
+ test -s app/smartphone/src/main/generated/baselineProfiles/startup-prof.txt
+ test -s app/tv/src/main/generated/baselineProfiles/baseline-prof.txt
+ test -s app/tv/src/main/generated/baselineProfiles/startup-prof.txt
+ test ! -d app/smartphone/src/release/generated/baselineProfiles
test ! -d app/tv/src/release/generated/baselineProfiles
- name: Create baseline profile update pull request
diff --git a/.github/workflows/native-packs.yml b/.github/workflows/native-packs.yml
index 37c7b3894..cb49ad269 100644
--- a/.github/workflows/native-packs.yml
+++ b/.github/workflows/native-packs.yml
@@ -53,24 +53,70 @@ jobs:
exit 1
fi
- - name: Verify native load source protocol
+ - name: Verify 16 KB native compatibility
run: |
set -euo pipefail
- if git grep -n 'm3u\.native-load-instrumentation\|NATIVE_PACK_COMMIT\|nativeLoadCommit' -- \
- native-load-gradle-plugin/src \
- app \
- data/src \
- data/build.gradle.kts \
- docs \
- native-load.yml \
- .github \
- ':!.github/workflows/native-packs.yml'; then
- exit 1
- fi
- if find native-packs -name '*.json' -print0 \
- | xargs -0 grep -n 'sourceCommit\|snapshotPath\|https://raw.githubusercontent.com\|native-packs/.*/release/'; then
- exit 1
- fi
+ pack_id="$(awk '$1 == "id:" { print $2; exit }' native-load.yml)"
+ testing/bin/verify-android-16kb.sh "native-packs/$pack_id"/*.zip
+
+ - name: Verify native pack identity pinning
+ run: |
+ set -euo pipefail
+ ./gradlew :data:generateReleaseBuildConfig --no-configuration-cache
+ python3 - <<'PY'
+ import hashlib
+ import re
+ import zipfile
+ from pathlib import Path
+
+ config = Path(
+ "data/build/generated/source/buildConfig/release/com/m3u/data/BuildConfig.java"
+ ).read_text()
+
+ def constant(name):
+ match = re.search(
+ rf'public static final String {name} = "([^"]*)";',
+ config,
+ )
+ if not match:
+ raise SystemExit(f"Missing release identity constant: {name}")
+ return match.group(1)
+
+ def entries(name):
+ encoded = constant(name)
+ return dict(entry.split("=", 1) for entry in encoded.split(";") if entry)
+
+ pack_id = constant("NATIVE_PACK_ID")
+ pack_dir = Path("native-packs") / pack_id
+ manifest = pack_dir / f"m3u-codec-{pack_id}.json"
+ expected_manifest = hashlib.sha256(manifest.read_bytes()).hexdigest()
+ if constant("NATIVE_PACK_EXPECTED_MANIFEST_SHA256") != expected_manifest:
+ raise SystemExit("Release manifest identity is stale")
+
+ expected_assets = entries("NATIVE_PACK_EXPECTED_ASSET_SHA256")
+ actual_assets = {
+ archive.name: hashlib.sha256(archive.read_bytes()).hexdigest()
+ for archive in sorted(pack_dir.glob("*.zip"))
+ }
+ if expected_assets != actual_assets:
+ raise SystemExit("Release asset identities are stale")
+
+ actual_libraries = {}
+ for archive in sorted(pack_dir.glob("*.zip")):
+ with zipfile.ZipFile(archive) as zipped:
+ for name in sorted(zipped.namelist()):
+ if not name.endswith("/"):
+ actual_libraries[f"{archive.name}/{name}"] = hashlib.sha256(
+ zipped.read(name)
+ ).hexdigest()
+ if entries("NATIVE_PACK_EXPECTED_LIBRARY_SHA256") != actual_libraries:
+ raise SystemExit("Release library identities are stale")
+ PY
+ git grep -q 'expectedManifestSha256' -- data/src/main/java/com/m3u/data/codec/CodecPackRepository.kt
+ git grep -q 'expectedAssetSha256' -- data/src/main/java/com/m3u/data/codec/CodecPackRepository.kt
+ git grep -q 'expectedLibrarySha256' -- \
+ data/src/main/java/com/m3u/data/codec/CodecPackRepository.kt \
+ data/src/main/java/com/m3u/data/codec/CodecNativeLoader.kt
- name: Verify committed native packs
run: |
diff --git a/.gitignore b/.gitignore
index 47e9967b0..cb0e32470 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ jks.txt
__pycache__/
*.pyc
testing/mock-server/bin/
+testing/mock-server/build/
+testing/extension-reference/build/
+samples/*/build/
diff --git a/README.md b/README.md
index 0ea4994c0..57d44433c 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,11 @@ It is made for users who want a clean, practical, ad-free IPTV app that works we
- [Nightly Build](https://nightly.link/oxyroid/M3UAndroid/workflows/android/master/artifact.zip)
- [Telegram Channel](https://t.me/m3u_android)
+## Extensions
+
+- [Extension documentation (English)](docs/extensions/README.md)
+- [插件文档(简体中文)](docs/extensions/README.zh-CN.md)
+
## License
M3UAndroid is an open-source project licensed under the [GNU General Public License v3.0](LICENSE).
diff --git a/app/extension/.gitignore b/app/extension/.gitignore
deleted file mode 100644
index 42afabfd2..000000000
--- a/app/extension/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build
\ No newline at end of file
diff --git a/app/extension/build.gradle.kts b/app/extension/build.gradle.kts
deleted file mode 100644
index 108995a30..000000000
--- a/app/extension/build.gradle.kts
+++ /dev/null
@@ -1,65 +0,0 @@
-plugins {
- alias(libs.plugins.com.android.application)
- alias(libs.plugins.org.jetbrains.kotlin.android)
- alias(libs.plugins.compose.compiler)
- alias(libs.plugins.com.google.devtools.ksp)
-}
-
-android {
- namespace = "com.m3u.extension"
- compileSdk = 36
-
- defaultConfig {
- applicationId = "com.m3u.extension"
- minSdk = 26
- targetSdk = 35
- versionCode = 1
- versionName = "1.0"
- manifestPlaceholders += mapOf(
- "description" to "Provides the ability to parse playlists using the standard TXT IPTV protocol",
- "version" to "1",
- "mainClass" to ".MainActivity"
- )
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- }
-
- buildTypes {
- release {
- isMinifyEnabled = false
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- signingConfig = signingConfigs.getByName("debug")
- }
- }
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
- buildFeatures {
- compose = true
- }
-}
-
-dependencies {
- implementation(libs.m3u.extension.api)
- implementation(libs.m3u.extension.annotation)
- ksp(libs.m3u.extension.processor)
-
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
-
- // compose
- implementation(platform(libs.androidx.compose.bom))
- implementation(libs.androidx.compose.foundation)
- implementation(libs.androidx.compose.foundation.layout)
- implementation(libs.androidx.compose.material.icons.extended)
- implementation(libs.androidx.compose.runtime)
- implementation(libs.androidx.compose.ui.util)
- implementation(libs.androidx.navigation.compose)
-
- // compose-material3
- implementation(libs.androidx.compose.material3)
-
-}
diff --git a/app/extension/proguard-rules.pro b/app/extension/proguard-rules.pro
deleted file mode 100644
index 36b7effad..000000000
--- a/app/extension/proguard-rules.pro
+++ /dev/null
@@ -1,25 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
-
--keep class com.squareup.wire.** { *; }
--keep class com.m3u.extension.api.model.** { *; }
--keep class * extends com.squareup.wire.ProtoAdapter
\ No newline at end of file
diff --git a/app/extension/src/main/AndroidManifest.xml b/app/extension/src/main/AndroidManifest.xml
deleted file mode 100644
index 29127b4e4..000000000
--- a/app/extension/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/extension/src/main/java/com/m3u/extension/MainActivity.kt b/app/extension/src/main/java/com/m3u/extension/MainActivity.kt
deleted file mode 100644
index 65044251a..000000000
--- a/app/extension/src/main/java/com/m3u/extension/MainActivity.kt
+++ /dev/null
@@ -1,190 +0,0 @@
-package com.m3u.extension
-
-import android.content.Intent
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.animateIntAsState
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Button
-import androidx.compose.material3.LocalTextStyle
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.LineBreak
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.m3u.extension.api.CallTokenConst
-import com.m3u.extension.api.RemoteClient
-import com.m3u.extension.api.business.InfoApi
-import com.m3u.extension.api.business.SubscribeApi
-import com.m3u.extension.api.model.AddPlaylistRequest
-import com.m3u.extension.ui.theme.M3UTheme
-import kotlinx.coroutines.launch
-
-class MainActivity : ComponentActivity() {
- private val client = RemoteClient()
- private val infoApi = client.create()
- private val subscribeApi = client.create()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- var callToken by mutableStateOf(handleArguments(intent))
- val commands = mutableStateListOf()
-
- setContent {
- M3UTheme {
- val coroutineScope = rememberCoroutineScope()
- val isConnected by client.isConnectedObservable.collectAsStateWithLifecycle(false)
- DisposableEffect(Unit) {
- onDispose {
- if (isConnected) {
- client.disconnect(this@MainActivity)
- }
- }
- }
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding)
- .padding(16.dp)
- ) {
- val lazyListState = rememberLazyListState()
- LaunchedEffect(commands.size) {
- lazyListState.scrollToItem(Int.MAX_VALUE)
- }
- LazyColumn(
- state = lazyListState,
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(8.dp))
- .background(Color.Black)
- .weight(1f),
- contentPadding = PaddingValues(8.dp)
- ) {
- itemsIndexed(commands) { index, command ->
- val isFocused = index == commands.lastIndex
- val fontWeight by animateIntAsState(if (isFocused) 700 else 400)
- val color by animateColorAsState(if (isFocused) Color.Yellow else Color.White)
-
- Text(
- text = "> $command",
- fontFamily = FontFamily.Monospace,
- color = color,
- fontWeight = FontWeight(fontWeight),
- modifier = Modifier.weight(1f),
- style = LocalTextStyle.current.copy(
- lineBreak = LineBreak.Paragraph
- )
- )
- }
- }
- Button(
- enabled = callToken != null,
- onClick = {
- val callToken = callToken
- if (!isConnected && callToken != null) {
- client.connect(
- context = this@MainActivity,
- targetPackageName = callToken.packageName,
- targetClassName = callToken.className,
- accessKey = callToken.accessKey
- )
- } else {
- client.disconnect(this@MainActivity)
- }
- }
- ) {
- Text(
- text = when {
- callToken == null -> "No CallToken"
- isConnected -> "Disconnect"
- else -> "Connect"
- }
- )
- }
- Button(
- enabled = isConnected,
- onClick = {
- coroutineScope.launch {
- commands += infoApi.getAppInfo().toString()
- }
- }
- ) {
- Text(
- text = "GetAppInfo"
- )
- }
- Button(
- enabled = isConnected,
- onClick = {
- coroutineScope.launch {
- commands += infoApi.getModules().toString()
- }
- }
- ) {
- Text(
- text = "GetModulesResponse"
- )
- }
- Button(
- enabled = isConnected,
- onClick = {
- coroutineScope.launch {
- commands += subscribeApi.addPlaylist(
- AddPlaylistRequest(
- url = "https://example.com/playlist.m3u?time=${System.currentTimeMillis()}",
- title = "Test Playlist ${System.currentTimeMillis()}",
- user_agent = "Test User Agent"
- )
- ).toString()
- }
- }
- ) {
- Text(
- text = "AddPlaylist"
- )
- }
- }
- }
- }
- }
- }
-
- private fun handleArguments(intent: Intent): CallToken? {
- val packageName = intent.getStringExtra(CallTokenConst.PACKAGE_NAME) ?: return null
- val className = intent.getStringExtra(CallTokenConst.CLASS_NAME) ?: return null
- val accessKey = intent.getStringExtra(CallTokenConst.ACCESS_KEY) ?: return null
- return CallToken(packageName, className, accessKey)
- }
-}
-
-private data class CallToken(
- val packageName: String,
- val className: String,
- val accessKey: String
-)
\ No newline at end of file
diff --git a/app/extension/src/main/java/com/m3u/extension/ui/theme/Color.kt b/app/extension/src/main/java/com/m3u/extension/ui/theme/Color.kt
deleted file mode 100644
index f67fceb93..000000000
--- a/app/extension/src/main/java/com/m3u/extension/ui/theme/Color.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.m3u.extension.ui.theme
-
-import androidx.compose.ui.graphics.Color
-
-val Purple80 = Color(0xFFD0BCFF)
-val PurpleGrey80 = Color(0xFFCCC2DC)
-val Pink80 = Color(0xFFEFB8C8)
-
-val Purple40 = Color(0xFF6650a4)
-val PurpleGrey40 = Color(0xFF625b71)
-val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/app/extension/src/main/java/com/m3u/extension/ui/theme/Theme.kt b/app/extension/src/main/java/com/m3u/extension/ui/theme/Theme.kt
deleted file mode 100644
index 6608a7c6b..000000000
--- a/app/extension/src/main/java/com/m3u/extension/ui/theme/Theme.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package com.m3u.extension.ui.theme
-
-import android.app.Activity
-import android.os.Build
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
-
-private val DarkColorScheme = darkColorScheme(
- primary = Purple80,
- secondary = PurpleGrey80,
- tertiary = Pink80
-)
-
-private val LightColorScheme = lightColorScheme(
- primary = Purple40,
- secondary = PurpleGrey40,
- tertiary = Pink40
-
- /* Other default colors to override
- background = Color(0xFFFFFBFE),
- surface = Color(0xFFFFFBFE),
- onPrimary = Color.White,
- onSecondary = Color.White,
- onTertiary = Color.White,
- onBackground = Color(0xFF1C1B1F),
- onSurface = Color(0xFF1C1B1F),
- */
-)
-
-@Composable
-fun M3UTheme(
- darkTheme: Boolean = isSystemInDarkTheme(),
- // Dynamic color is available on Android 12+
- dynamicColor: Boolean = true,
- content: @Composable () -> Unit
-) {
- val colorScheme = when {
- dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- val context = LocalContext.current
- if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
- }
-
- darkTheme -> DarkColorScheme
- else -> LightColorScheme
- }
-
- MaterialTheme(
- colorScheme = colorScheme,
- typography = Typography,
- content = content
- )
-}
\ No newline at end of file
diff --git a/app/extension/src/main/java/com/m3u/extension/ui/theme/Type.kt b/app/extension/src/main/java/com/m3u/extension/ui/theme/Type.kt
deleted file mode 100644
index f384cd7c2..000000000
--- a/app/extension/src/main/java/com/m3u/extension/ui/theme/Type.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.m3u.extension.ui.theme
-
-import androidx.compose.material3.Typography
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.sp
-
-// Set of Material typography styles to start with
-val Typography = Typography(
- bodyLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 16.sp,
- lineHeight = 24.sp,
- letterSpacing = 0.5.sp
- )
- /* Other default text styles to override
- titleLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 22.sp,
- lineHeight = 28.sp,
- letterSpacing = 0.sp
- ),
- labelSmall = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Medium,
- fontSize = 11.sp,
- lineHeight = 16.sp,
- letterSpacing = 0.5.sp
- )
- */
-)
\ No newline at end of file
diff --git a/app/extension/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/extension/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index 345888d26..000000000
--- a/app/extension/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/extension/src/main/res/mipmap-hdpi/ic_launcher.png b/app/extension/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index 6f3fe86a7..000000000
Binary files a/app/extension/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/extension/src/main/res/mipmap-hdpi/ic_launcher_background.png
deleted file mode 100644
index f8103ee0a..000000000
Binary files a/app/extension/src/main/res/mipmap-hdpi/ic_launcher_background.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/extension/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
deleted file mode 100644
index cb9ceb75a..000000000
Binary files a/app/extension/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/app/extension/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
deleted file mode 100644
index 8ddd48485..000000000
Binary files a/app/extension/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-mdpi/ic_launcher.png b/app/extension/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index 98926a8b8..000000000
Binary files a/app/extension/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/extension/src/main/res/mipmap-mdpi/ic_launcher_background.png
deleted file mode 100644
index f8e4e4c6c..000000000
Binary files a/app/extension/src/main/res/mipmap-mdpi/ic_launcher_background.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/extension/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
deleted file mode 100644
index 3f52e8a9c..000000000
Binary files a/app/extension/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/app/extension/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
deleted file mode 100644
index fab321ef0..000000000
Binary files a/app/extension/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/extension/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index f31cb27ee..000000000
Binary files a/app/extension/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/extension/src/main/res/mipmap-xhdpi/ic_launcher_background.png
deleted file mode 100644
index 106f37ba7..000000000
Binary files a/app/extension/src/main/res/mipmap-xhdpi/ic_launcher_background.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/extension/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 1eee92ece..000000000
Binary files a/app/extension/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/app/extension/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
deleted file mode 100644
index 3c4bba5da..000000000
Binary files a/app/extension/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index f985fd24a..000000000
Binary files a/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
deleted file mode 100644
index 6ad6869b5..000000000
Binary files a/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 406873f5b..000000000
Binary files a/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
deleted file mode 100644
index 7e1ca83a4..000000000
Binary files a/app/extension/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index 154f89880..000000000
Binary files a/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
deleted file mode 100644
index b28b839a9..000000000
Binary files a/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 4d620ba44..000000000
Binary files a/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
deleted file mode 100644
index 2cc11f2c2..000000000
Binary files a/app/extension/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and /dev/null differ
diff --git a/app/extension/src/main/res/values/colors.xml b/app/extension/src/main/res/values/colors.xml
deleted file mode 100644
index f8c6127d3..000000000
--- a/app/extension/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
- #FF000000
- #FFFFFFFF
-
\ No newline at end of file
diff --git a/app/extension/src/main/res/values/strings.xml b/app/extension/src/main/res/values/strings.xml
deleted file mode 100644
index a33a89b12..000000000
--- a/app/extension/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
- IPTV TxT
-
\ No newline at end of file
diff --git a/app/extension/src/main/res/values/themes.xml b/app/extension/src/main/res/values/themes.xml
deleted file mode 100644
index 667b9e345..000000000
--- a/app/extension/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/smartphone/build.gradle.kts b/app/smartphone/build.gradle.kts
index 92a74ef82..a6fe46dc4 100644
--- a/app/smartphone/build.gradle.kts
+++ b/app/smartphone/build.gradle.kts
@@ -1,6 +1,7 @@
+import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
+
plugins {
alias(libs.plugins.com.android.application)
- alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.com.google.dagger.hilt.android)
alias(libs.plugins.com.google.devtools.ksp)
@@ -9,11 +10,22 @@ plugins {
id("dev.oxyroid.native-load")
}
+extensions.configure {
+ compilerOptions {
+ optIn.addAll(
+ "androidx.compose.material3.ExperimentalMaterial3Api",
+ "androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
+ "androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
+ "com.google.accompanist.permissions.ExperimentalPermissionsApi",
+ )
+ }
+}
+
val m3uMockServerUrl = providers.gradleProperty("m3uMockServerUrl").orElse("http://10.0.2.2:8080")
android {
namespace = "com.m3u.smartphone"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
applicationId = "com.m3u.smartphone"
minSdk = 26
@@ -33,6 +45,7 @@ android {
debug {
isMinifyEnabled = false
isShrinkResources = false
+ isPseudoLocalesEnabled = true
signingConfig = signingConfigs.getByName("debug")
}
all {
@@ -43,7 +56,6 @@ android {
)
}
}
- aaptOptions.cruncherEnabled = false
splits {
abi {
val benchmark = project
@@ -79,18 +91,6 @@ android {
packaging {
resources.excludes += "META-INF/**"
}
- applicationVariants.all {
- outputs
- .map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
- .forEach { output ->
- val abi = output.getFilter("ABI")
- output.outputFileName = if (abi == null) {
- "$versionName.apk"
- } else {
- "${versionName}_$abi.apk"
- }
- }
- }
}
tasks.matching { task ->
@@ -100,6 +100,11 @@ tasks.matching { task ->
finalizedBy(":testing:mock-server:stopMockServer")
}
+tasks.matching { task -> task.name == "connectedDebugAndroidTest" }.configureEach {
+ dependsOn(":testing:extension-reference:installDebug")
+ finalizedBy(":testing:extension-reference:uninstallDebug")
+}
+
hilt {
enableAggregatingTask = true
}
@@ -111,13 +116,10 @@ baselineProfile {
}
dependencies {
+ implementation(project(":extension:api"))
implementation(project(":i18n"))
implementation(project(":core:foundation"))
- implementation(project(":core:extension"))
implementation(project(":data"))
- implementation(libs.m3u.extension.api)
- implementation(libs.m3u.extension.annotation)
- ksp(libs.m3u.extension.processor)
// business
implementation(project(":business:foryou"))
implementation(project(":business:favorite"))
@@ -125,7 +127,6 @@ dependencies {
implementation(project(":business:playlist"))
implementation(project(":business:channel"))
implementation(project(":business:playlist-configuration"))
- implementation(project(":business:extension"))
// baselineprofile
implementation(libs.androidx.profileinstaller)
"baselineProfile"(project(":baselineprofile:smartphone"))
@@ -184,10 +185,18 @@ dependencies {
implementation(libs.net.mm2d.mmupnp.mmupnp)
implementation(libs.haze)
implementation(libs.haze.materials)
+ implementation(libs.backdrop)
implementation(libs.acra.notification)
implementation(libs.acra.mail)
+ testImplementation(kotlin("test-junit"))
+ androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.runner)
+ androidTestImplementation(libs.androidx.test.uiautomator.uiautomator)
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4.accessibility)
+ androidTestImplementation(project(":extension:runtime"))
+ androidTestImplementation(project(":extension:transport-android"))
}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/smartphone/ui/common/connect/RemoteControlAccessibilityTest.kt b/app/smartphone/src/androidTest/java/com/m3u/smartphone/ui/common/connect/RemoteControlAccessibilityTest.kt
new file mode 100644
index 000000000..2ccf68ed1
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/smartphone/ui/common/connect/RemoteControlAccessibilityTest.kt
@@ -0,0 +1,306 @@
+package com.m3u.smartphone.ui.common.connect
+
+import android.content.res.Configuration
+import android.view.View
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertHeightIsAtLeast
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertWidthIsAtLeast
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.dp
+import androidx.test.platform.app.InstrumentationRegistry
+import com.m3u.core.foundation.architecture.Abi
+import com.m3u.data.tv.model.RemoteDirection
+import com.m3u.data.tv.model.TvInfo
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.MainActivity
+import java.util.Collections
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+class RemoteControlAccessibilityTest {
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
+
+ @Test
+ fun pairingContentRemainsReachableAndKeepsDigitsLtr() {
+ assertCompactRtlLargeConfiguration()
+ showRemoteControlSheet(
+ value = RemoteControlSheetValue.Prepare(
+ code = PAIRING_CODE,
+ searchingOrConnecting = false,
+ ),
+ )
+
+ composeRule.onNode(
+ hasText(
+ context.getString(string.ui_remote_control_pair_title),
+ substring = false,
+ ignoreCase = false,
+ ),
+ ).assertIsDisplayed()
+ composeRule.onNode(
+ hasText(
+ context.getString(string.ui_remote_control_connect),
+ substring = false,
+ ignoreCase = false,
+ ) and hasClickAction(),
+ )
+ .performScrollTo()
+ .assertIsDisplayed()
+ .assertMinimumTouchTarget()
+ listOf(
+ string.ui_remote_control_keypad_backspace,
+ string.ui_remote_control_keypad_clear,
+ ).forEach { descriptionResource ->
+ composeRule.onNode(
+ hasContentDescription(
+ context.getString(descriptionResource),
+ substring = false,
+ ignoreCase = false,
+ ) and hasClickAction(),
+ )
+ .performScrollTo()
+ .assertIsDisplayed()
+ .assertMinimumTouchTarget()
+ }
+
+ val pairingDescription = composeRule.onNode(
+ SemanticsMatcher("pairing code keeps its logical digit order") { node ->
+ node.config
+ .getOrElse(SemanticsProperties.ContentDescription) { emptyList() }
+ .joinToString(separator = " ")
+ .stripBidiControlsForAssertion()
+ .contains(SPACED_PAIRING_CODE)
+ },
+ ).fetchSemanticsNode().config[
+ SemanticsProperties.ContentDescription
+ ].joinToString(separator = " ")
+ assertTrue(
+ "Pairing-code semantics changed the digit order: $pairingDescription",
+ pairingDescription
+ .stripBidiControlsForAssertion()
+ .contains(SPACED_PAIRING_CODE),
+ )
+
+ val firstKey = composeRule.onNode(
+ hasText("1", substring = false, ignoreCase = false) and
+ SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button),
+ ).fetchSemanticsNode().boundsInWindow
+ val secondKey = composeRule.onNode(
+ hasText("2", substring = false, ignoreCase = false) and
+ SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button),
+ ).fetchSemanticsNode().boundsInWindow
+ val thirdKey = composeRule.onNode(
+ hasText("3", substring = false, ignoreCase = false) and
+ SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button),
+ ).fetchSemanticsNode().boundsInWindow
+ assertTrue(
+ "The numeric keypad must stay 1-2-3 from physical left to right in RTL: " +
+ "1=$firstKey, 2=$secondKey, 3=$thirdKey",
+ firstKey.center.x < secondKey.center.x &&
+ secondKey.center.x < thirdKey.center.x,
+ )
+ assertTrue(
+ "The first keypad row must remain horizontally aligned: " +
+ "1=$firstKey, 2=$secondKey, 3=$thirdKey",
+ firstKey.center.y == secondKey.center.y &&
+ secondKey.center.y == thirdKey.center.y,
+ )
+ }
+
+ @Test
+ fun directionPadKeepsPhysicalDirectionsAndActionsReachable() {
+ assertCompactRtlLargeConfiguration()
+ val emittedDirections = Collections.synchronizedList(
+ mutableListOf(),
+ )
+ showRemoteControlSheet(
+ value = RemoteControlSheetValue.DPad(
+ tvInfo = TvInfo(
+ model = LONG_TV_MODEL,
+ version = 1,
+ abi = Abi.universal,
+ ),
+ ),
+ onRemoteDirection = { emittedDirections.add(it) },
+ )
+
+ val directionPad = composeRule.onNode(
+ hasContentDescription(
+ context.getString(string.ui_remote_control_direction_pad),
+ substring = false,
+ ignoreCase = false,
+ ),
+ )
+ .assertIsDisplayed()
+ .assertMinimumTouchTarget()
+ composeRule.onNode(
+ hasText(
+ context.getString(string.ui_remote_control_disconnect),
+ substring = false,
+ ignoreCase = false,
+ ) and hasClickAction(),
+ )
+ .performScrollTo()
+ .assertIsDisplayed()
+ .assertMinimumTouchTarget()
+ directionPad
+ .performScrollTo()
+ .assertIsDisplayed()
+
+ directionPad.performTouchInput {
+ down(
+ Offset(
+ x = visibleSize.width * PHYSICAL_EDGE_PRESS_FRACTION,
+ y = visibleSize.height / 2f,
+ ),
+ )
+ advanceEventTime(PRESS_DURATION_MILLIS)
+ up()
+ }
+ composeRule.mainClock.advanceTimeBy(PRESS_DURATION_MILLIS)
+ composeRule.waitForIdle()
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ emittedDirections.isNotEmpty()
+ }
+ assertEquals(
+ "Pressing the physical left edge must still send LEFT in RTL",
+ RemoteDirection.LEFT,
+ emittedDirections.removeAt(0),
+ )
+
+ directionPad.performTouchInput {
+ down(
+ Offset(
+ x = visibleSize.width * (1f - PHYSICAL_EDGE_PRESS_FRACTION),
+ y = visibleSize.height / 2f,
+ ),
+ )
+ advanceEventTime(PRESS_DURATION_MILLIS)
+ up()
+ }
+ composeRule.mainClock.advanceTimeBy(PRESS_DURATION_MILLIS)
+ composeRule.waitForIdle()
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ emittedDirections.isNotEmpty()
+ }
+ assertEquals(
+ "Pressing the physical right edge must still send RIGHT in RTL",
+ RemoteDirection.RIGHT,
+ emittedDirections.removeAt(0),
+ )
+ }
+
+ private fun showRemoteControlSheet(
+ value: RemoteControlSheetValue,
+ onRemoteDirection: (RemoteDirection) -> Unit = {},
+ ) {
+ composeRule.runOnUiThread {
+ composeRule.activity.setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ RemoteControlSheet(
+ value = value,
+ visible = true,
+ onCode = {},
+ checkTvCodeOnSmartphone = {},
+ forgetTvCodeOnSmartphone = {},
+ onRemoteDirection = onRemoteDirection,
+ onDismissRequest = {},
+ )
+ }
+ }
+ }
+ }
+ composeRule.waitForIdle()
+ }
+
+ private fun assertCompactRtlLargeConfiguration() {
+ val configuration = currentConfiguration()
+ assertEquals(
+ MATRIX_CASE_COMPACT_RTL_LARGE,
+ requestedAccessibilityMatrixCase(),
+ )
+ assertEquals(
+ "Remote Control RTL coverage must use the Arabic pseudo locale",
+ LOCALE_RTL_PSEUDO,
+ configuration.locales[0].toLanguageTag(),
+ )
+ assertEquals(
+ "Remote Control RTL coverage must use locale-driven RTL",
+ View.LAYOUT_DIRECTION_RTL,
+ configuration.layoutDirection,
+ )
+ assertTrue(
+ "Expected 200% text, actual=${configuration.fontScale}",
+ configuration.fontScale >= LARGE_TEXT_MINIMUM_SCALE,
+ )
+ assertTrue(
+ "Expected a 320dp narrow window, actual=${configuration.screenWidthDp}",
+ configuration.screenWidthDp in NARROW_WIDTH_RANGE,
+ )
+ }
+
+ private fun currentConfiguration(): Configuration = composeRule.runOnIdle {
+ Configuration(composeRule.activity.resources.configuration)
+ }
+
+ private fun requestedAccessibilityMatrixCase(): String =
+ InstrumentationRegistry.getArguments()
+ .getString(ARG_ACCESSIBILITY_MATRIX_CASE)
+ ?: error(
+ "Missing required instrumentation argument " +
+ "$ARG_ACCESSIBILITY_MATRIX_CASE. Run " +
+ "testing/bin/run-smartphone-provider-ui-matrix.sh.",
+ )
+
+ private fun SemanticsNodeInteraction.assertMinimumTouchTarget():
+ SemanticsNodeInteraction = assertWidthIsAtLeast(MINIMUM_TOUCH_TARGET_DP.dp)
+ .assertHeightIsAtLeast(MINIMUM_TOUCH_TARGET_DP.dp)
+
+ private fun String.stripBidiControlsForAssertion(): String = filterNot { character ->
+ character == '\u061C' ||
+ character == '\u200E' ||
+ character == '\u200F' ||
+ character in '\u202A'..'\u202E' ||
+ character in '\u2066'..'\u2069'
+ }
+
+ private companion object {
+ const val ARG_ACCESSIBILITY_MATRIX_CASE = "accessibilityMatrixCase"
+ const val MATRIX_CASE_COMPACT_RTL_LARGE = "compact-rtl-large"
+ const val LOCALE_RTL_PSEUDO = "ar-XB"
+ const val LARGE_TEXT_MINIMUM_SCALE = 1.95f
+ const val NARROW_WIDTH_MINIMUM_DP = 315
+ const val NARROW_WIDTH_MAXIMUM_DP = 325
+ val NARROW_WIDTH_RANGE =
+ NARROW_WIDTH_MINIMUM_DP..NARROW_WIDTH_MAXIMUM_DP
+ const val UI_TIMEOUT_MILLIS = 5_000L
+ const val MINIMUM_TOUCH_TARGET_DP = 48
+ const val PRESS_DURATION_MILLIS = 200L
+ const val PHYSICAL_EDGE_PRESS_FRACTION = 0.15f
+ const val PAIRING_CODE = "123456"
+ const val SPACED_PAIRING_CODE = "1 2 3 4 5 6"
+ const val LONG_TV_MODEL =
+ "Living Room Television With A Deliberately Long Device Name"
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/DebugDefaultLibraryBootstrapTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/DebugDefaultLibraryBootstrapTest.kt
new file mode 100644
index 000000000..c9e5522e1
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/DebugDefaultLibraryBootstrapTest.kt
@@ -0,0 +1,89 @@
+package com.m3u.testing
+
+import android.content.Context
+import android.os.SystemClock
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.m3u.data.database.model.DataSource
+import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.smartphone.DebugExtensionPlatformEntryPoint
+import dagger.hilt.android.EntryPointAccessors
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class DebugDefaultLibraryBootstrapTest {
+ private val context: Context =
+ InstrumentationRegistry.getInstrumentation().targetContext
+ private val playlistRepository: PlaylistRepository by lazy {
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ DebugExtensionPlatformEntryPoint::class.java,
+ ).playlistRepository()
+ }
+
+ @Test
+ fun freshDebugInstallImportsTheBundledPlaybackSamplesOnce() {
+ val deadline = SystemClock.uptimeMillis() + IMPORT_TIMEOUT_MILLIS
+ var importedPlaylistUrl: String? = null
+ do {
+ importedPlaylistUrl = runBlocking {
+ playlistRepository.getAll()
+ .singleOrNull { playlist ->
+ playlist.title == DEFAULT_LIBRARY_TITLE
+ }
+ ?.url
+ }
+ if (importedPlaylistUrl != null) break
+ SystemClock.sleep(IMPORT_POLL_MILLIS)
+ } while (SystemClock.uptimeMillis() < deadline)
+
+ assertNotNull(
+ "The smartphone debug build did not import its bundled playback samples",
+ importedPlaylistUrl,
+ )
+ assertEquals(
+ "The debug bootstrap must remain idempotent across process restarts",
+ 1,
+ runBlocking {
+ playlistRepository.getAll().count { playlist ->
+ playlist.title == DEFAULT_LIBRARY_TITLE
+ }
+ },
+ )
+ val imported = runBlocking {
+ playlistRepository.getPlaylistWithChannels(
+ checkNotNull(importedPlaylistUrl)
+ )
+ }
+ assertNotNull(imported)
+ assertEquals(DataSource.M3U, imported?.playlist?.source)
+ assertEquals(
+ EXPECTED_CHANNEL_IDS,
+ imported?.channels
+ ?.mapNotNullTo(mutableSetOf()) { channel -> channel.relationId },
+ )
+ assertEquals(EXPECTED_CHANNEL_IDS.size, imported?.channels?.size)
+
+ val stateFile = context.noBackupFilesDir.resolve(
+ "debug-default-library/bootstrap-state-v1"
+ )
+ assertTrue("The debug bootstrap state was not committed", stateFile.isFile)
+ assertEquals("imported", stateFile.readText().trim())
+ }
+
+ private companion object {
+ const val DEFAULT_LIBRARY_TITLE = "Debug playback samples"
+ const val IMPORT_TIMEOUT_MILLIS = 20_000L
+ const val IMPORT_POLL_MILLIS = 100L
+ val EXPECTED_CHANNEL_IDS = setOf(
+ "apple.bipbop.avc",
+ "blender.big-buck-bunny.hls",
+ "blender.sintel.trailer",
+ )
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/ExternalExtensionIpcTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/ExternalExtensionIpcTest.kt
new file mode 100644
index 000000000..0ba2d7dc6
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/ExternalExtensionIpcTest.kt
@@ -0,0 +1,221 @@
+package com.m3u.testing
+
+import android.os.ParcelFileDescriptor
+import androidx.test.platform.app.InstrumentationRegistry
+import com.m3u.extension.api.BackgroundTaskRequest
+import com.m3u.extension.api.BackgroundTaskResult
+import com.m3u.extension.api.ChannelMetadataSnapshot
+import com.m3u.extension.api.EpgRefreshRequest
+import com.m3u.extension.api.EpgRefreshResult
+import com.m3u.extension.api.ExtensionApiVersions
+import com.m3u.extension.api.ExtensionSettingsSnapshot
+import com.m3u.extension.api.HookResult
+import com.m3u.extension.api.HostHookSpecs
+import com.m3u.extension.api.MetadataEnrichmentRequest
+import com.m3u.extension.api.MetadataEnrichmentResult
+import com.m3u.extension.api.SearchProviderRequest
+import com.m3u.extension.api.SearchProviderResult
+import com.m3u.extension.api.SettingsSchemaRequest
+import com.m3u.extension.api.SettingsSchemaResult
+import com.m3u.extension.api.security.BrokerInvocation
+import com.m3u.extension.api.security.BrokerInvocationResult
+import com.m3u.extension.api.security.BrokerOperation
+import com.m3u.extension.api.security.BrokerOperationResult
+import com.m3u.extension.api.security.BrokerProtocolVersions
+import com.m3u.extension.api.security.BrokerScopeHandle
+import com.m3u.extension.api.security.BrokerValue
+import com.m3u.extension.api.security.BrokeredHttpResponse
+import com.m3u.extension.api.security.CredentialHandle
+import com.m3u.extension.api.subscription.SubscriptionHookSpecs
+import com.m3u.extension.api.subscription.SubscriptionProviderDiscoverRequest
+import com.m3u.extension.api.subscription.SubscriptionProviderDiscoverResult
+import com.m3u.extension.runtime.ExtensionRegistrationResult
+import com.m3u.extension.runtime.ExtensionRuntime
+import com.m3u.extension.runtime.ExtensionSettingsProvider
+import com.m3u.extension.runtime.ExtensionTransportHealth
+import com.m3u.extension.runtime.InvocationPolicy
+import com.m3u.extension.transport.android.AndroidBoundExtensionTransport
+import com.m3u.extension.transport.android.AndroidExtensionDiscovery
+import com.m3u.extension.transport.android.ParcelFileCodec
+import com.m3u.extension.transport.android.ipc.IExtensionHostBridge
+import com.m3u.extension.transport.android.ipc.IExtensionResultCallback
+import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonPrimitive
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ExternalExtensionIpcTest {
+ @Test
+ fun referenceApkIsDiscoveredBoundAndInvokedThroughStreamTransport() = runBlocking {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val installed = AndroidExtensionDiscovery(context).discover().firstOrNull {
+ it.packageName == REFERENCE_PACKAGE
+ } ?: error("Reference extension APK was not installed by the conformance task")
+ val transport = AndroidBoundExtensionTransport.connect(
+ context = context,
+ installed = installed,
+ hostBridgeFactory = { _, _, _ -> ConformanceHostBridge },
+ )
+ try {
+ val runtime = ExtensionRuntime(
+ ExtensionApiVersions.Current,
+ invocationPolicy = InvocationPolicy(maxPayloadBytes = 4_194_304),
+ settingsProvider = ExtensionSettingsProvider {
+ ExtensionSettingsSnapshot(
+ schemaVersions = mapOf("manifest" to 1),
+ values = mapOf("manifest/enabled" to JsonPrimitive(false)),
+ credentialHandles = mapOf(
+ "manifest/api-key" to CredentialHandle("extension-secret:test"),
+ ),
+ )
+ },
+ )
+ val registration = runtime.register(transport)
+ assertTrue(registration is ExtensionRegistrationResult.Registered)
+ val registered = registration as ExtensionRegistrationResult.Registered
+ runtime.recordTransportHealth(
+ transport.manifest.id,
+ checkNotNull(registered.registrationToken),
+ ExtensionTransportHealth.HEALTHY,
+ )
+
+ val result = runtime.invoke(
+ extensionId = transport.manifest.id,
+ spec = SubscriptionHookSpecs.Discover,
+ request = SubscriptionProviderDiscoverRequest(),
+ )
+ val payload = (result.outcome as HookResult.Success<*>).payload as
+ SubscriptionProviderDiscoverResult
+
+ assertEquals("Reference Provider", payload.provider.displayName)
+ assertEquals("Reference", payload.provider.variants.single().displayName)
+ assertEquals("reference", payload.provider.variants.single().kind.value)
+
+ val largeResult = runtime.invoke(
+ extensionId = transport.manifest.id,
+ spec = HostHookSpecs.SearchProvider,
+ request = SearchProviderRequest("large"),
+ brokerScope = BrokerScopeHandle("reference-search-conformance"),
+ )
+ val search = (largeResult.outcome as HookResult.Success<*>).payload as SearchProviderResult
+ assertEquals(25_000, search.items.size)
+
+ val metadataResult = runtime.invoke(
+ extensionId = transport.manifest.id,
+ spec = HostHookSpecs.MetadataEnrichment,
+ request = MetadataEnrichmentRequest(
+ channels = listOf(
+ ChannelMetadataSnapshot(
+ stableReference = "channel-42",
+ title = "unenriched:Reference Channel",
+ category = "Reference",
+ )
+ )
+ ),
+ )
+ val metadata = (metadataResult.outcome as HookResult.Success<*>).payload as
+ MetadataEnrichmentResult
+ assertEquals("channel-42", metadata.patches.single().stableReference)
+ assertEquals("Reference Channel", metadata.patches.single().title)
+
+ val epgResult = runtime.invoke(
+ extensionId = transport.manifest.id,
+ spec = HostHookSpecs.EpgRefresh,
+ request = EpgRefreshRequest(
+ sourceIds = listOf("channel-42"),
+ fromEpochMillis = 1_000,
+ toEpochMillis = 2_000,
+ ),
+ )
+ val epg = (epgResult.outcome as HookResult.Success<*>).payload as EpgRefreshResult
+ assertEquals("channel-42", epg.programmes.single().channelReference)
+ assertEquals("Reference programme", epg.programmes.single().title)
+
+ val settingsResult = runtime.invoke(
+ extensionId = transport.manifest.id,
+ spec = HostHookSpecs.SettingsSchema,
+ request = SettingsSchemaRequest(localeTag = "en-US", surface = "phone"),
+ )
+ val settings = (settingsResult.outcome as HookResult.Success<*>).payload as
+ SettingsSchemaResult
+ assertEquals("playback", settings.sections.single().id)
+
+ val settingsStatus = runtime.invoke(
+ extensionId = transport.manifest.id,
+ spec = HostHookSpecs.BackgroundTask,
+ request = BackgroundTaskRequest("settings-status"),
+ )
+ val settingsOutput = (settingsStatus.outcome as HookResult.Success<*>).payload as
+ BackgroundTaskResult
+ assertEquals("false", settingsOutput.output["enabled"])
+ assertEquals("true", settingsOutput.output["hasApiKey"])
+
+ val slowInvocation = async {
+ runtime.invoke(
+ extensionId = transport.manifest.id,
+ spec = HostHookSpecs.BackgroundTask,
+ request = BackgroundTaskRequest("slow"),
+ )
+ }
+ delay(200)
+ slowInvocation.cancel()
+ runCatching { slowInvocation.await() }
+ delay(200)
+ val cancelStatus = runtime.invoke(
+ extensionId = transport.manifest.id,
+ spec = HostHookSpecs.BackgroundTask,
+ request = BackgroundTaskRequest("cancel-status"),
+ )
+ val status = (cancelStatus.outcome as HookResult.Success<*>).payload as
+ BackgroundTaskResult
+ assertEquals("true", status.output["cancelled"])
+ } finally {
+ transport.close()
+ }
+ }
+
+ private object ConformanceHostBridge : IExtensionHostBridge.Stub() {
+ private val json = Json { ignoreUnknownKeys = true }
+
+ override fun executeHttp(
+ requestId: String,
+ request: ParcelFileDescriptor,
+ callback: IExtensionResultCallback,
+ ) {
+ val invocation = json.decodeFromString(
+ ParcelFileCodec.read(request, 64 * 1024)
+ )
+ assertEquals(BrokerProtocolVersions.Current, invocation.brokerProtocolVersion)
+ val operation = invocation.operation as BrokerOperation.Http
+ assertEquals(
+ BrokerValue.Literal("https://reference.invalid/probe"),
+ operation.request.url,
+ )
+ val result: BrokerInvocationResult = BrokerInvocationResult.Success(
+ BrokerOperationResult.Http(
+ BrokeredHttpResponse(
+ statusCode = 204,
+ headers = emptyMap(),
+ body = "caller-bound",
+ )
+ )
+ )
+ ParcelFileCodec.write(
+ InstrumentationRegistry.getInstrumentation().targetContext,
+ json.encodeToString(result),
+ ).use { response -> callback.onSuccess(requestId, response) }
+ }
+
+ override fun cancelHttp(requestId: String?) = Unit
+ }
+
+ private companion object {
+ const val REFERENCE_PACKAGE = "com.m3u.testing.extension.reference"
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/ExternalExtensionManagementUiTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/ExternalExtensionManagementUiTest.kt
new file mode 100644
index 000000000..a7c4fe286
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/ExternalExtensionManagementUiTest.kt
@@ -0,0 +1,928 @@
+package com.m3u.testing
+
+import android.os.SystemClock
+import android.view.View
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertHeightIsAtLeast
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.assertTextContains
+import androidx.compose.ui.test.assertWidthIsAtLeast
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.isEnabled
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.junit4.accessibility.disableAccessibilityChecks
+import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performScrollToNode
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.tryPerformAccessibilityChecks
+import androidx.compose.ui.unit.dp
+import androidx.datastore.preferences.core.edit
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.StaleObjectException
+import androidx.test.uiautomator.UiDevice
+import com.m3u.business.setting.ExtensionPluginOperation
+import com.m3u.business.setting.ExtensionPluginOperationState
+import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
+import com.m3u.core.foundation.architecture.preferences.settings
+import com.m3u.data.repository.extension.ExtensionNetworkOriginState
+import com.m3u.extension.api.ExtensionState
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.DebugExtensionPlatformEntryPoint
+import com.m3u.smartphone.MainActivity
+import com.m3u.smartphone.ui.business.setting.fragments.ExtensionPluginDetailContentState
+import com.m3u.smartphone.ui.business.setting.fragments.ExtensionPluginDetailScreen
+import com.m3u.smartphone.ui.business.setting.fragments.ExtensionPluginDiscoveryStatus
+import dagger.hilt.android.EntryPointAccessors
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class ExternalExtensionManagementUiTest {
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private val targetContext
+ get() = InstrumentationRegistry.getInstrumentation()
+ .targetContext
+ .applicationContext
+
+ private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ private var externalExtensionsPreviouslyEnabled: Boolean? = null
+
+ @Before
+ fun enableExternalExtensions() {
+ runBlocking {
+ externalExtensionsPreviouslyEnabled = targetContext.settings.data
+ .first()[PreferencesKeys.EXTERNAL_EXTENSIONS]
+ targetContext.settings.edit { preferences ->
+ preferences[PreferencesKeys.EXTERNAL_EXTENSIONS] = true
+ }
+ }
+ }
+
+ @After
+ fun removeExtensionTrustAndRestoreDeveloperMode() {
+ runBlocking {
+ val cleanupFailure = runCatching {
+ val repository = EntryPointAccessors.fromApplication(
+ targetContext,
+ DebugExtensionPlatformEntryPoint::class.java,
+ ).pluginRepository()
+ repository.installedPlugins()
+ .filter { plugin -> plugin.packageName == REFERENCE_PACKAGE }
+ .forEach { plugin ->
+ repository.revoke(
+ packageName = plugin.packageName,
+ serviceName = plugin.serviceName,
+ )
+ }
+ }.exceptionOrNull()
+ targetContext.settings.edit { preferences ->
+ externalExtensionsPreviouslyEnabled?.let { enabled ->
+ preferences[PreferencesKeys.EXTERNAL_EXTENSIONS] = enabled
+ } ?: preferences.remove(PreferencesKeys.EXTERNAL_EXTENSIONS)
+ }
+ cleanupFailure?.let { cause ->
+ throw AssertionError("Failed to remove reference extension trust", cause)
+ }
+ }
+ }
+
+ @Test
+ fun directDetailRenderingDistinguishesLookupStatesAndBusyPluginContent() {
+ assertRequestedAccessibilityConfigurationIfPresent()
+ val repository = EntryPointAccessors.fromApplication(
+ targetContext,
+ DebugExtensionPlatformEntryPoint::class.java,
+ ).pluginRepository()
+ val inspectedPlugin = runBlocking {
+ repository.installedPlugins().single { plugin ->
+ plugin.packageName == REFERENCE_PACKAGE &&
+ plugin.serviceName == REFERENCE_SERVICE
+ }
+ }
+ assertTrue(
+ "The installed reference plugin must provide an authorization token",
+ inspectedPlugin.authorizationToken != null,
+ )
+ val plugin = inspectedPlugin.copy(
+ trusted = true,
+ signatureChanged = false,
+ extensionId = REFERENCE_EXTENSION_ID,
+ enabled = true,
+ state = ExtensionState.ENABLED,
+ version = LONG_REFERENCE_VERSION,
+ grantedCapabilities = inspectedPlugin.requestedCapabilities,
+ capabilityPermissions = inspectedPlugin.capabilityPermissions.map { permission ->
+ permission.copy(granted = true)
+ },
+ inspectionError = null,
+ installed = true,
+ approvedNetworkOrigins = inspectedPlugin.networkOrigins,
+ networkAccess = inspectedPlugin.networkAccess.copy(
+ fixedOrigins = inspectedPlugin.networkAccess.fixedOrigins.map { origin ->
+ origin.copy(state = ExtensionNetworkOriginState.APPROVED)
+ }
+ ),
+ )
+ val detailState = mutableStateOf(
+ ExtensionPluginDetailContentState.Loading
+ )
+ val operationState = mutableStateOf(
+ ExtensionPluginOperationState.Idle
+ )
+ val retryRequested = AtomicBoolean(false)
+
+ composeRule.runOnUiThread {
+ composeRule.activity.setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ ExtensionPluginDetailScreen(
+ state = detailState.value,
+ operationState = operationState.value,
+ onRetryDiscovery = { retryRequested.set(true) },
+ onOpenAuthorization = {},
+ onOpenSettings = {},
+ onDisable = {},
+ onRevoke = { _, _, _ -> },
+ onClearData = { _, _, _ -> },
+ onExportDiagnostics = {},
+ )
+ }
+ }
+ }
+ }
+
+ waitUntilTagExists(PLUGIN_DETAIL_LOADING_TAG)
+ composeRule.onNodeWithTag(PLUGIN_DETAIL_LOADING_TAG).assertIsDisplayed()
+ composeRule.onAllNodesWithTag(PLUGIN_UNAVAILABLE_TAG).assertCountEquals(0)
+
+ composeRule.runOnIdle {
+ detailState.value = ExtensionPluginDetailContentState.Failure
+ }
+ waitUntilTagExists(PLUGIN_FAILURE_TAG)
+ composeRule.onNodeWithTag(PLUGIN_FAILURE_TAG).assertIsDisplayed()
+ composeRule.onAllNodesWithTag(PLUGIN_UNAVAILABLE_TAG).assertCountEquals(0)
+ composeRule.onNodeWithTag(PLUGIN_RETRY_TAG)
+ .assertIsDisplayed()
+ .assertMinimumTouchTarget()
+ .performClick()
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ retryRequested.get()
+ }
+
+ composeRule.runOnIdle {
+ detailState.value = ExtensionPluginDetailContentState.Missing
+ }
+ waitUntilTagExists(PLUGIN_UNAVAILABLE_TAG)
+ composeRule.onNodeWithTag(PLUGIN_UNAVAILABLE_TAG).assertIsDisplayed()
+ composeRule.onAllNodesWithTag(PLUGIN_DETAIL_LOADING_TAG).assertCountEquals(0)
+
+ composeRule.runOnIdle {
+ detailState.value = ExtensionPluginDetailContentState.Content(
+ plugin = plugin,
+ discoveryStatus = ExtensionPluginDiscoveryStatus.READY,
+ )
+ operationState.value = ExtensionPluginOperationState.Running(
+ ExtensionPluginOperation.Reauthorize(
+ packageName = REFERENCE_PACKAGE,
+ serviceName = REFERENCE_SERVICE,
+ )
+ )
+ }
+ waitUntilTagExists(pluginDetailTag())
+ waitUntilTagExists(OPERATION_PROGRESS_TAG)
+ composeRule.onAllNodesWithTag(PLUGIN_UNAVAILABLE_TAG).assertCountEquals(0)
+ val operationDescription = composeRule.activity.getString(
+ string.feat_setting_extension_operation_reauthorizing
+ )
+ composeRule.onNodeWithTag(OPERATION_PROGRESS_TAG)
+ .assertIsDisplayed()
+ .assertTextContains(operationDescription, substring = false)
+ .assert(
+ SemanticsMatcher("does not repeat visible text as a state description") {
+ !it.config.contains(SemanticsProperties.StateDescription)
+ }
+ )
+ .assert(
+ SemanticsMatcher.expectValue(
+ SemanticsProperties.LiveRegion,
+ LiveRegionMode.Polite,
+ )
+ )
+
+ assertBusyDetailActionsAreDisabledAndDoNotOverlap()
+
+ composeRule.runOnIdle {
+ operationState.value = ExtensionPluginOperationState.Running(
+ ExtensionPluginOperation.Reauthorize(
+ packageName = REFERENCE_PACKAGE,
+ serviceName = "$REFERENCE_SERVICE.Other",
+ )
+ )
+ }
+ val otherOperationDescription = composeRule.activity.getString(
+ string.feat_setting_extension_operation_other_in_progress
+ )
+ composeRule.onNodeWithTag(OPERATION_PROGRESS_TAG)
+ .assertTextContains(otherOperationDescription, substring = false)
+
+ scrollDetailTo(TECHNICAL_IDENTITY_DISCLOSURE_TAG)
+ composeRule.onNodeWithTag(TECHNICAL_IDENTITY_DISCLOSURE_TAG)
+ .assertMinimumTouchTarget()
+ .performClick()
+ assertTechnicalIdentityValue("version", LONG_REFERENCE_VERSION)
+ assertTechnicalIdentityValue("package", REFERENCE_PACKAGE)
+ assertTechnicalIdentityValue("service", REFERENCE_SERVICE)
+ assertTechnicalIdentityValue(
+ "certificate",
+ plugin.certificateSha256.chunked(16).joinToString(" "),
+ )
+ }
+
+ @Test
+ fun referencePluginCompletesTheVisibleManagementLifecycle() {
+ assertRequestedAccessibilityConfigurationIfPresent()
+ openExtensionPlugins()
+
+ waitUntilTagExists(PLUGIN_LIST_TAG)
+ composeRule.enableAccessibilityChecks()
+ composeRule.onRoot().tryPerformAccessibilityChecks()
+ composeRule.disableAccessibilityChecks()
+ assertAdaptiveNavigation(nestedDetailVisible = true)
+ waitUntilTagExists(pluginListItemTag())
+ composeRule.onNodeWithTag(pluginListItemTag())
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagExists(pluginDetailTag())
+ waitUntilTagGone(PLUGIN_LIST_TAG)
+ waitUntilTagExists(CAPABILITIES_DISCLOSURE_TAG)
+ waitUntilTagExists(NETWORK_ORIGINS_DISCLOSURE_TAG)
+ composeRule.onAllNodes(
+ hasText(REFERENCE_CAPABILITY_ID, substring = true, ignoreCase = false)
+ ).assertCountEquals(0)
+ val referenceCapabilityName = composeRule.activity.getString(
+ string.feat_setting_extension_capability_name_background_task
+ )
+ composeRule.onAllNodes(
+ hasText(referenceCapabilityName, substring = false, ignoreCase = false)
+ ).assertCountEquals(0)
+ composeRule.onNodeWithTag(CAPABILITIES_DISCLOSURE_TAG)
+ .assertMinimumTouchTarget()
+ .assert(
+ SemanticsMatcher.expectValue(
+ SemanticsProperties.StateDescription,
+ composeRule.activity.getString(string.ui_state_collapsed),
+ )
+ )
+ .performClick()
+ composeRule.onNodeWithTag(CAPABILITIES_DISCLOSURE_TAG)
+ .assert(
+ SemanticsMatcher.expectValue(
+ SemanticsProperties.StateDescription,
+ composeRule.activity.getString(string.ui_state_expanded),
+ )
+ )
+ waitUntilExists(hasText(referenceCapabilityName, substring = false))
+ composeRule.onAllNodes(
+ hasText(REFERENCE_CAPABILITY_ID, substring = true, ignoreCase = false)
+ ).assertCountEquals(0)
+ composeRule.onNodeWithTag(CAPABILITIES_DISCLOSURE_TAG)
+ .performClick()
+ waitUntilGone(hasText(referenceCapabilityName, substring = false))
+ scrollDetailTo(NETWORK_ORIGINS_DISCLOSURE_TAG)
+ composeRule.onNodeWithTag(NETWORK_ORIGINS_DISCLOSURE_TAG)
+ .assertMinimumTouchTarget()
+ .assertDisclosureState(string.ui_state_collapsed)
+ .performClick()
+ .assertDisclosureState(string.ui_state_expanded)
+ .performClick()
+ .assertDisclosureState(string.ui_state_collapsed)
+ scrollDetailTo(TECHNICAL_IDENTITY_DISCLOSURE_TAG)
+ composeRule.onNodeWithTag(TECHNICAL_IDENTITY_DISCLOSURE_TAG)
+ .assertMinimumTouchTarget()
+ .assertDisclosureState(string.ui_state_collapsed)
+ .performClick()
+ .assertDisclosureState(string.ui_state_expanded)
+ waitUntilExists(
+ hasText(REFERENCE_PACKAGE, substring = true, ignoreCase = false)
+ )
+ composeRule.onNodeWithTag(TECHNICAL_IDENTITY_DISCLOSURE_TAG)
+ .performClick()
+ .assertDisclosureState(string.ui_state_collapsed)
+ waitUntilGone(
+ hasText(REFERENCE_PACKAGE, substring = true, ignoreCase = false)
+ )
+ scrollDetailTo(actionTag("enable"))
+ waitUntilTagEnabled(actionTag("enable"))
+ composeRule.onNodeWithTag(actionTag("enable"))
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagExists(AUTHORIZATION_SCREEN_TAG)
+ composeRule.onAllNodes(
+ hasText(REFERENCE_PACKAGE, substring = true, ignoreCase = false)
+ ).assertCountEquals(0)
+ waitUntilExists(
+ hasText(
+ composeRule.activity.getString(
+ string.feat_setting_extension_requested_capabilities
+ ),
+ substring = false,
+ ignoreCase = true,
+ )
+ )
+ composeRule.onNodeWithTag(AUTHORIZATION_SCREEN_TAG)
+ .performScrollToNode(hasTestTag(AUTHORIZATION_IDENTITY_DISCLOSURE_TAG))
+ waitUntilTagExists(AUTHORIZATION_IDENTITY_DISCLOSURE_TAG)
+ composeRule.onNodeWithTag(AUTHORIZATION_IDENTITY_DISCLOSURE_TAG)
+ .assertMinimumTouchTarget()
+ .assertDisclosureState(string.ui_state_collapsed)
+ .performClick()
+ .assertDisclosureState(string.ui_state_expanded)
+ waitUntilExists(hasText(REFERENCE_PACKAGE, substring = true))
+ composeRule.onNodeWithTag(AUTHORIZATION_IDENTITY_DISCLOSURE_TAG)
+ .performClick()
+ .assertDisclosureState(string.ui_state_collapsed)
+ waitUntilGone(hasText(REFERENCE_PACKAGE, substring = true))
+ scrollAuthorizationActionsIntoView()
+ assertAuthorizationActionsAreUsable()
+ physicallyClickAuthorization(string.feat_setting_extension_enable)
+ waitUntilTagGone(AUTHORIZATION_SCREEN_TAG)
+ waitUntilTagExists(pluginDetailTag())
+ openSettings()
+ composeRule.onNodeWithTag(choiceTag("direct"))
+ .performScrollTo()
+ .assertMinimumTouchTarget()
+ .performClick()
+ composeRule.onNodeWithTag(choiceTag("direct")).assertIsSelected()
+ composeRule.onNodeWithTag(choiceTag("auto")).performScrollTo()
+ assertFlowRowControlsDoNotOverlap(
+ firstTag = choiceTag("direct"),
+ secondTag = choiceTag("auto"),
+ )
+ composeRule.onNodeWithTag(API_KEY_FIELD_TAG)
+ .performScrollTo()
+ .performTextInput("ui-secret")
+ composeRule.onNodeWithTag(API_KEY_SAVE_TAG)
+ .performScrollTo()
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagExists(API_KEY_CLEAR_TAG)
+ composeRule.onNodeWithTag(API_KEY_CLEAR_TAG)
+ .performScrollTo()
+ .assertMinimumTouchTarget()
+ composeRule.onNodeWithTag(API_KEY_FIELD_TAG)
+ .performScrollTo()
+ .performTextInput("ui-replacement")
+ waitUntilTagExists(API_KEY_SAVE_TAG)
+ composeRule.onNodeWithTag(API_KEY_SAVE_TAG).performScrollTo()
+ assertFlowRowControlsDoNotOverlap(
+ firstTag = API_KEY_SAVE_TAG,
+ secondTag = API_KEY_CLEAR_TAG,
+ )
+ val notConfiguredOriginState = composeRule.activity.getString(
+ string.feat_setting_extension_network_origin_state_not_configured
+ )
+ composeRule.onNodeWithTag(SETTINGS_SCREEN_TAG)
+ .performScrollToNode(hasTestTag(API_ORIGIN_FIELD_TAG))
+ waitUntilTagExists(API_ORIGIN_FIELD_TAG)
+ composeRule.onNodeWithTag(API_ORIGIN_STATE_TAG, useUnmergedTree = true)
+ .performScrollTo()
+ .assertTextContains(notConfiguredOriginState, substring = false)
+ composeRule.onNodeWithTag(API_ORIGIN_FIELD_TAG)
+ .performScrollTo()
+ .performTextInput(REFERENCE_SETTING_ORIGIN)
+ composeRule.onNodeWithTag(API_ORIGIN_SAVE_TAG)
+ .performScrollTo()
+ .assertMinimumTouchTarget()
+ .performClick()
+ val approvedOriginState = composeRule.activity.getString(
+ string.feat_setting_extension_network_origin_state_approved
+ )
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ runCatching {
+ composeRule.onNodeWithTag(
+ API_ORIGIN_STATE_TAG,
+ useUnmergedTree = true,
+ )
+ .assertTextContains(approvedOriginState, substring = false)
+ }.isSuccess
+ }
+ closeSettings()
+
+ scrollDetailTo(NETWORK_ORIGINS_DISCLOSURE_TAG)
+ composeRule.onNodeWithTag(NETWORK_ORIGINS_DISCLOSURE_TAG)
+ .performClick()
+ waitUntilTagExists(API_ORIGIN_DETAIL_TAG)
+ composeRule.onNodeWithTag(API_ORIGIN_DETAIL_TAG)
+ .performScrollTo()
+ .assertIsDisplayed()
+ waitUntilExists(
+ hasText(REFERENCE_SETTING_ORIGIN, substring = false)
+ )
+ composeRule.onNodeWithTag(API_ORIGIN_DETAIL_TAG).assert(
+ SemanticsMatcher.expectValue(
+ SemanticsProperties.StateDescription,
+ approvedOriginState,
+ )
+ )
+ composeRule.onNodeWithTag(NETWORK_ORIGINS_DISCLOSURE_TAG)
+ .performClick()
+
+ scrollDetailTo(actionTag("clear-data"))
+ waitUntilTagEnabled(actionTag("clear-data"))
+ composeRule.onNodeWithTag(actionTag("clear-data"))
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagExists(CLEAR_DATA_DIALOG_TAG)
+ composeRule.onNodeWithTag(DATA_REMOVAL_PACKAGE_TAG)
+ .assertTextContains(REFERENCE_PACKAGE, substring = true)
+ composeRule.onNodeWithTag(CLEAR_DATA_CONFIRM_TAG)
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagGone(CLEAR_DATA_DIALOG_TAG)
+ val dataClearedMessage = composeRule.activity.getString(
+ string.feat_setting_extension_data_cleared
+ )
+ waitUntilExists(hasText(dataClearedMessage, substring = false))
+ waitUntilGone(hasText(dataClearedMessage, substring = false))
+ scrollDetailTo(actionTag("settings"))
+
+ openSettings()
+ composeRule.onNodeWithTag(choiceTag("auto")).performScrollTo().assertIsSelected()
+ waitUntilTagGone(API_KEY_CLEAR_TAG)
+ composeRule.onNodeWithTag(SETTINGS_SCREEN_TAG)
+ .performScrollToNode(hasTestTag(API_ORIGIN_FIELD_TAG))
+ waitUntilTagExists(API_ORIGIN_FIELD_TAG)
+ composeRule.onNodeWithTag(API_ORIGIN_STATE_TAG, useUnmergedTree = true)
+ .performScrollTo()
+ .assertTextContains(notConfiguredOriginState, substring = false)
+ closeSettings()
+
+ scrollDetailTo(actionTag("reauthorize"))
+ waitUntilTagEnabled(actionTag("reauthorize"))
+ composeRule.onNodeWithTag(actionTag("reauthorize"))
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagExists(AUTHORIZATION_SCREEN_TAG)
+ scrollAuthorizationActionsIntoView()
+ assertAuthorizationActionsAreUsable()
+ physicallyClickAuthorization(string.feat_setting_extension_reauthorize)
+ waitUntilTagGone(AUTHORIZATION_SCREEN_TAG)
+ waitUntilTagExists(pluginDetailTag())
+ scrollDetailTo(actionTag("disable"))
+ waitUntilTagEnabled(actionTag("disable"))
+
+ composeRule.onNodeWithTag(actionTag("disable"))
+ .assertMinimumTouchTarget()
+ .performClick()
+ scrollDetailTo(actionTag("enable"))
+ waitUntilTagEnabled(actionTag("enable"))
+ composeRule.onNodeWithTag(actionTag("enable"))
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagExists(AUTHORIZATION_SCREEN_TAG)
+ scrollAuthorizationActionsIntoView()
+ assertAuthorizationActionsAreUsable()
+ physicallyClickAuthorization(string.feat_setting_extension_enable)
+ waitUntilTagGone(AUTHORIZATION_SCREEN_TAG)
+ waitUntilTagExists(pluginDetailTag())
+ scrollDetailTo(actionTag("revoke"))
+ waitUntilTagEnabled(actionTag("revoke"))
+
+ composeRule.onNodeWithTag(actionTag("revoke"))
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagExists(REVOKE_DIALOG_TAG)
+ composeRule.onNodeWithTag(DATA_REMOVAL_PACKAGE_TAG)
+ .assertTextContains(REFERENCE_PACKAGE, substring = true)
+ composeRule.onNodeWithTag(REVOKE_CONFIRM_TAG)
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagGone(REVOKE_DIALOG_TAG)
+ scrollDetailTo(actionTag("enable"))
+ waitUntilTagEnabled(actionTag("enable"))
+
+ navigateBack()
+ waitUntilTagExists(PLUGIN_LIST_TAG)
+ waitUntilTagExists(pluginListItemTag())
+ navigateBack()
+ waitUntilTagExists(EXTENSION_ENTRY_TAG)
+ assertAdaptiveNavigation(nestedDetailVisible = false)
+ }
+
+ private fun openSettings() {
+ scrollDetailTo(actionTag("settings"))
+ waitUntilTagEnabled(actionTag("settings"))
+ composeRule.onNodeWithTag(actionTag("settings"))
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagExists(SETTINGS_SCREEN_TAG)
+ }
+
+ private fun scrollAuthorizationActionsIntoView() {
+ composeRule.onNodeWithTag(AUTHORIZATION_SCREEN_TAG)
+ .performScrollToNode(hasTestTag(AUTHORIZATION_BOTTOM_SAFE_SPACE_TAG))
+ waitUntilTagExists(AUTHORIZATION_BOTTOM_SAFE_SPACE_TAG)
+ composeRule.waitForIdle()
+ }
+
+ private fun assertAuthorizationActionsAreUsable() {
+ waitUntilTagEnabled(AUTHORIZATION_CONFIRM_TAG)
+ composeRule.onNodeWithTag(AUTHORIZATION_CONFIRM_TAG)
+ .assert(hasClickAction())
+ assertFlowRowControlsDoNotOverlap(
+ firstTag = AUTHORIZATION_CANCEL_TAG,
+ secondTag = AUTHORIZATION_CONFIRM_TAG,
+ )
+ }
+
+ private fun assertFlowRowControlsDoNotOverlap(
+ firstTag: String,
+ secondTag: String,
+ ) {
+ val first = composeRule.onNodeWithTag(firstTag)
+ .performScrollTo()
+ .assertIsDisplayed()
+ .assertMinimumTouchTarget()
+ val second = composeRule.onNodeWithTag(secondTag)
+ .performScrollTo()
+ .assertIsDisplayed()
+ .assertMinimumTouchTarget()
+ val firstBounds = first.getUnclippedBoundsInRoot()
+ val secondBounds = second.getUnclippedBoundsInRoot()
+ val rootBounds = composeRule.onRoot().getUnclippedBoundsInRoot()
+ val overlapWidth =
+ minOf(firstBounds.right, secondBounds.right) -
+ maxOf(firstBounds.left, secondBounds.left)
+ val overlapHeight =
+ minOf(firstBounds.bottom, secondBounds.bottom) -
+ maxOf(firstBounds.top, secondBounds.top)
+
+ assertTrue(
+ "FlowRow controls overlap: " +
+ "$firstTag=$firstBounds, $secondTag=$secondBounds",
+ overlapWidth <= 0.dp || overlapHeight <= 0.dp,
+ )
+ listOf(firstTag to firstBounds, secondTag to secondBounds).forEach { (tag, bounds) ->
+ assertTrue(
+ "FlowRow control is clipped by the root: " +
+ "$tag=$bounds, root=$rootBounds",
+ bounds.left >= rootBounds.left &&
+ bounds.top >= rootBounds.top &&
+ bounds.right <= rootBounds.right &&
+ bounds.bottom <= rootBounds.bottom,
+ )
+ }
+ }
+
+ private fun assertBusyDetailActionsAreDisabledAndDoNotOverlap() {
+ val actionTags = listOf(
+ actionTag("settings"),
+ actionTag("reauthorize"),
+ actionTag("disable"),
+ )
+ composeRule.onNodeWithTag(pluginDetailTag())
+ .performScrollToNode(hasTestTag(actionTags.last()))
+ composeRule.waitForIdle()
+ val rootBounds = composeRule.onNodeWithTag(pluginDetailTag())
+ .getUnclippedBoundsInRoot()
+ val actionBounds = actionTags.map { tag ->
+ val node = composeRule.onNodeWithTag(tag)
+ .assertIsDisplayed()
+ .assertIsNotEnabled()
+ .assertMinimumTouchTarget()
+ tag to node.getUnclippedBoundsInRoot()
+ }
+
+ actionBounds.forEach { (tag, bounds) ->
+ assertTrue(
+ "Busy extension action is clipped: $tag=$bounds, root=$rootBounds",
+ bounds.left >= rootBounds.left &&
+ bounds.top >= rootBounds.top &&
+ bounds.right <= rootBounds.right &&
+ bounds.bottom <= rootBounds.bottom,
+ )
+ }
+ actionBounds.forEachIndexed { index, (firstTag, firstBounds) ->
+ actionBounds.drop(index + 1).forEach { (secondTag, secondBounds) ->
+ val overlapWidth =
+ minOf(firstBounds.right, secondBounds.right) -
+ maxOf(firstBounds.left, secondBounds.left)
+ val overlapHeight =
+ minOf(firstBounds.bottom, secondBounds.bottom) -
+ maxOf(firstBounds.top, secondBounds.top)
+ assertTrue(
+ "Busy extension actions overlap: " +
+ "$firstTag=$firstBounds, $secondTag=$secondBounds",
+ overlapWidth <= 0.dp || overlapHeight <= 0.dp,
+ )
+ }
+ }
+ }
+
+ private fun assertTechnicalIdentityValue(key: String, expected: String) {
+ val tag = "$TECHNICAL_IDENTITY_VALUE_TAG_PREFIX$key"
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodesWithTag(tag, useUnmergedTree = true)
+ .fetchSemanticsNodes()
+ .isNotEmpty()
+ }
+ composeRule.onNodeWithTag(tag, useUnmergedTree = true)
+ .performScrollTo()
+ .assertIsDisplayed()
+ .assertTextContains(expected, substring = true)
+ }
+
+ private fun assertRequestedAccessibilityConfigurationIfPresent() {
+ val matrixCase = InstrumentationRegistry.getArguments()
+ .getString(ARG_ACCESSIBILITY_MATRIX_CASE)
+ ?: return
+ if (matrixCase != MATRIX_CASE_COMPACT_RTL_LARGE) return
+
+ val configuration = composeRule.activity.resources.configuration
+ assertEquals(
+ "The extension lifecycle must run in a locale-driven RTL configuration",
+ LOCALE_RTL_TEST,
+ configuration.locales[0].toLanguageTag(),
+ )
+ assertEquals(
+ "The extension lifecycle must use RTL layout direction",
+ View.LAYOUT_DIRECTION_RTL,
+ configuration.layoutDirection,
+ )
+ assertTrue(
+ "The extension lifecycle must run at 200% font scale; " +
+ "actual=${configuration.fontScale}",
+ configuration.fontScale >= LARGE_TEXT_MINIMUM_SCALE,
+ )
+ assertTrue(
+ "The extension lifecycle must use a 320dp compact window; " +
+ "actual=${configuration.screenWidthDp}",
+ configuration.screenWidthDp in COMPACT_WIDTH_RANGE,
+ )
+ }
+
+ private fun assertAdaptiveNavigation(nestedDetailVisible: Boolean) {
+ val usesSideRail =
+ composeRule.activity.resources.configuration.screenWidthDp >=
+ SIDE_RAIL_MINIMUM_WIDTH_DP
+ if (usesSideRail) {
+ waitUntilTagGone(FLOATING_NAVIGATION_TAG)
+ waitUntilTagExists(SIDE_NAVIGATION_SETTING_TAG)
+ composeRule.onNodeWithTag(SIDE_NAVIGATION_SETTING_TAG)
+ .assertIsSelected()
+ } else if (nestedDetailVisible) {
+ waitUntilTagGone(FLOATING_NAVIGATION_TAG)
+ } else {
+ waitUntilTagExists(FLOATING_NAVIGATION_TAG)
+ }
+ }
+
+ private fun scrollDetailTo(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ runCatching {
+ composeRule.onNodeWithTag(pluginDetailTag())
+ .performScrollToNode(hasTestTag(tag))
+ }.isSuccess
+ }
+ waitUntilTagExists(tag)
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ runCatching {
+ composeRule.onNodeWithTag(tag).performScrollTo()
+ }.isSuccess
+ }
+ }
+
+ private fun physicallyClickAuthorization(labelResource: Int) {
+ val label = composeRule.activity.getString(labelResource)
+ val expectedBounds = composeRule.onNodeWithTag(AUTHORIZATION_CONFIRM_TAG)
+ .fetchSemanticsNode()
+ .boundsInWindow
+ // Compose exposes the control role through semantics, but UIAutomator
+ // does not consistently map that role to android.widget.Button.
+ val selector = By.text(label).enabled(true)
+ val deadline = SystemClock.uptimeMillis() + UI_TIMEOUT_MILLIS
+
+ while (SystemClock.uptimeMillis() < deadline) {
+ val clicked = device.findObjects(selector).any { control ->
+ try {
+ val bounds = control.visibleBounds
+ val centerX = bounds.exactCenterX()
+ val centerY = bounds.exactCenterY()
+ val matchesComposeControl =
+ centerX >= expectedBounds.left &&
+ centerX <= expectedBounds.right &&
+ centerY >= expectedBounds.top &&
+ centerY <= expectedBounds.bottom
+ if (matchesComposeControl) control.click()
+ matchesComposeControl
+ } catch (_: StaleObjectException) {
+ false
+ }
+ }
+ if (clicked) {
+ device.waitForIdle()
+ composeRule.waitForIdle()
+ return
+ }
+ SystemClock.sleep(UI_AUTOMATOR_RETRY_MILLIS)
+ }
+
+ throw AssertionError("Authorization action is not visible: $label")
+ }
+
+ private fun closeSettings() {
+ navigateBack()
+ waitUntilTagGone(SETTINGS_SCREEN_TAG)
+ waitUntilTagExists(pluginDetailTag())
+ }
+
+ private fun navigateBack() {
+ composeRule.runOnUiThread {
+ composeRule.activity.onBackPressedDispatcher.onBackPressed()
+ }
+ }
+
+ private fun openExtensionPlugins() {
+ val pluginSettings =
+ composeRule.activity.getString(string.feat_setting_extension_plugins)
+ waitUntilExists(
+ hasText(pluginSettings, substring = false, ignoreCase = true) or
+ hasContentDescription(
+ composeRule.activity.getString(string.ui_destination_setting),
+ substring = false,
+ ignoreCase = true,
+ )
+ )
+ if (
+ composeRule.onAllNodesWithTag(EXTENSION_ENTRY_TAG)
+ .fetchSemanticsNodes()
+ .isEmpty()
+ ) {
+ composeRule.onNode(
+ hasContentDescription(
+ composeRule.activity.getString(string.ui_destination_setting),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+ ).performClick()
+ }
+ waitUntilTagExists(EXTENSION_ENTRY_TAG)
+ composeRule.onAllNodesWithTag(EXTENSION_ENTRY_TAG).assertCountEquals(1)
+ composeRule.onNodeWithTag(EXTENSION_ENTRY_TAG)
+ .assertMinimumTouchTarget()
+ .performClick()
+ waitUntilTagExists(PLUGIN_LIST_TAG)
+ }
+
+ private fun SemanticsNodeInteraction.assertMinimumTouchTarget():
+ SemanticsNodeInteraction = assertWidthIsAtLeast(48.dp)
+ .assertHeightIsAtLeast(48.dp)
+
+ private fun SemanticsNodeInteraction.assertDisclosureState(
+ stateResource: Int,
+ ): SemanticsNodeInteraction {
+ val matcher = SemanticsMatcher.expectValue(
+ SemanticsProperties.StateDescription,
+ composeRule.activity.getString(stateResource),
+ )
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ runCatching { assert(matcher) }.isSuccess
+ }
+ return this
+ }
+
+ private fun waitUntilExists(matcher: SemanticsMatcher) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun waitUntilGone(matcher: SemanticsMatcher) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodes(matcher).fetchSemanticsNodes().isEmpty()
+ }
+ }
+
+ private fun waitUntilTagExists(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun waitUntilTagEnabled(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodes(hasTestTag(tag) and isEnabled())
+ .fetchSemanticsNodes()
+ .isNotEmpty()
+ }
+ }
+
+ private fun waitUntilTagGone(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()
+ }
+ }
+
+ private fun pluginListItemTag(): String =
+ "extension-plugin-list-item:$REFERENCE_PACKAGE/$REFERENCE_SERVICE"
+
+ private fun pluginDetailTag(): String =
+ "extension-plugin-detail:$REFERENCE_PACKAGE/$REFERENCE_SERVICE"
+
+ private fun actionTag(action: String): String =
+ "extension-plugin-action-$action:$REFERENCE_PACKAGE/$REFERENCE_SERVICE"
+
+ private fun choiceTag(value: String): String =
+ "extension-setting-choice:playback/quality:$value"
+
+ private companion object {
+ const val UI_TIMEOUT_MILLIS = 15_000L
+ const val UI_AUTOMATOR_RETRY_MILLIS = 100L
+ const val REFERENCE_PACKAGE = "com.m3u.testing.extension.reference"
+ const val REFERENCE_SERVICE =
+ "com.m3u.testing.extension.reference.ReferenceExtensionService"
+ const val REFERENCE_CAPABILITY_ID = "background.task"
+ const val EXTENSION_ENTRY_TAG = "extension-entry"
+ const val FLOATING_NAVIGATION_TAG = "floating-app-navigation"
+ const val SIDE_NAVIGATION_SETTING_TAG = "side-navigation-item:Setting"
+ const val PLUGIN_LIST_TAG = "extension-list"
+ const val PLUGIN_DETAIL_LOADING_TAG = "extension-plugin-detail-loading"
+ const val PLUGIN_FAILURE_TAG = "extension-plugin-failure"
+ const val PLUGIN_RETRY_TAG = "extension-plugin-retry"
+ const val PLUGIN_UNAVAILABLE_TAG = "extension-plugin-unavailable"
+ const val OPERATION_PROGRESS_TAG = "extension-operation-progress"
+ const val CAPABILITIES_DISCLOSURE_TAG = "extension-capabilities-disclosure"
+ const val NETWORK_ORIGINS_DISCLOSURE_TAG =
+ "extension-network-origins-disclosure"
+ const val TECHNICAL_IDENTITY_DISCLOSURE_TAG =
+ "extension-technical-identity-disclosure"
+ const val TECHNICAL_IDENTITY_VALUE_TAG_PREFIX =
+ "extension-technical-identity-value:"
+ const val AUTHORIZATION_SCREEN_TAG = "extension-authorization"
+ const val AUTHORIZATION_IDENTITY_DISCLOSURE_TAG =
+ "extension-authorization-identity-disclosure"
+ const val AUTHORIZATION_BOTTOM_SAFE_SPACE_TAG =
+ "extension-authorization-bottom-safe-space"
+ const val AUTHORIZATION_CONFIRM_TAG = "extension-authorization-confirm"
+ const val AUTHORIZATION_CANCEL_TAG = "extension-authorization-cancel"
+ const val SETTINGS_SCREEN_TAG = "extension-settings"
+ const val CLEAR_DATA_DIALOG_TAG = "extension-clear-data-dialog"
+ const val CLEAR_DATA_CONFIRM_TAG = "extension-clear-data-confirm"
+ const val REVOKE_DIALOG_TAG = "extension-revoke-dialog"
+ const val REVOKE_CONFIRM_TAG = "extension-revoke-confirm"
+ const val DATA_REMOVAL_PACKAGE_TAG = "extension-data-removal-package"
+ const val API_KEY_FIELD_TAG = "extension-setting-field:manifest/api-key"
+ const val API_KEY_SAVE_TAG = "extension-setting-save:manifest/api-key"
+ const val API_KEY_CLEAR_TAG = "extension-setting-clear:manifest/api-key"
+ const val API_ORIGIN_FIELD_TAG =
+ "extension-setting-field:playback/api-origin"
+ const val API_ORIGIN_SAVE_TAG =
+ "extension-setting-save:playback/api-origin"
+ const val API_ORIGIN_STATE_TAG =
+ "extension-setting-origin-state:playback/api-origin"
+ const val API_ORIGIN_DETAIL_TAG =
+ "extension-network-setting-origin:playback/api-origin"
+ const val REFERENCE_SETTING_ORIGIN = "https://ui.reference.test:443"
+ const val ARG_ACCESSIBILITY_MATRIX_CASE = "accessibilityMatrixCase"
+ const val MATRIX_CASE_COMPACT_RTL_LARGE = "compact-rtl-large"
+ const val LOCALE_RTL_TEST = "ar-XB"
+ const val LARGE_TEXT_MINIMUM_SCALE = 1.95f
+ const val SIDE_RAIL_MINIMUM_WIDTH_DP = 600
+ const val REFERENCE_EXTENSION_ID = "com.m3u.reference.provider"
+ const val LONG_REFERENCE_VERSION =
+ "1.0.0-reference-preview.20260729"
+ val COMPACT_WIDTH_RANGE = 315..325
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/ExternalProviderEndToEndTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/ExternalProviderEndToEndTest.kt
new file mode 100644
index 000000000..14fd772f3
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/ExternalProviderEndToEndTest.kt
@@ -0,0 +1,223 @@
+package com.m3u.testing
+
+import androidx.datastore.preferences.core.edit
+import androidx.test.platform.app.InstrumentationRegistry
+import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
+import com.m3u.core.foundation.architecture.preferences.settings
+import com.m3u.data.database.model.Channel
+import com.m3u.data.database.model.DataSource
+import com.m3u.data.repository.plugin.PluginEnableResult
+import com.m3u.data.repository.provider.ProviderOperationException
+import com.m3u.data.repository.provider.ProviderPlaybackCloseReason
+import com.m3u.data.repository.provider.ProviderPlaybackSession
+import com.m3u.data.repository.provider.ProviderSubscriptionRequest
+import com.m3u.extension.api.ExtensionId
+import com.m3u.extension.api.ExtensionState
+import com.m3u.extension.api.subscription.ProviderKind
+import com.m3u.extension.api.subscription.SubscriptionProviderSettingKeys
+import com.m3u.smartphone.DebugExtensionPlatformEntryPoint
+import dagger.hilt.android.EntryPointAccessors
+import java.net.HttpURLConnection
+import java.net.URL
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ExternalProviderEndToEndTest {
+ @Test
+ fun referenceProviderCompletesCredentialBackedPlaybackSessionAcrossBinder() = runBlocking {
+ val instrumentation = InstrumentationRegistry.getInstrumentation()
+ val context = instrumentation.targetContext.applicationContext
+ val entryPoint = EntryPointAccessors.fromApplication(
+ context,
+ DebugExtensionPlatformEntryPoint::class.java,
+ )
+ val pluginRepository = entryPoint.pluginRepository()
+ val providerRepository = entryPoint.providerRepository()
+ val playlistRepository = entryPoint.playlistRepository()
+ var pluginPackage: String? = null
+ var pluginService: String? = null
+ var providerPlaylistUrl: String? = null
+ var activeSession: ProviderPlaybackSession? = null
+
+ try {
+ context.settings.edit { preferences ->
+ preferences[PreferencesKeys.EXTERNAL_EXTENSIONS] = true
+ }
+ val plugin = pluginRepository.installedPlugins().single { installed ->
+ installed.packageName == REFERENCE_PACKAGE
+ }
+ pluginPackage = plugin.packageName
+ pluginService = plugin.serviceName
+ val enableResult = pluginRepository.enable(
+ plugin.packageName,
+ plugin.serviceName,
+ checkNotNull(plugin.authorizationToken),
+ )
+ assertTrue(
+ "Reference provider could not be enabled: $enableResult",
+ enableResult is PluginEnableResult.Enabled,
+ )
+
+ playlistRepository.getBySource(DataSource.Provider)
+ .filter { playlist -> playlist.title == PLAYLIST_TITLE }
+ .forEach { playlist -> playlistRepository.unsubscribe(playlist.url) }
+
+ val descriptor = providerRepository.discoverProviders().single { provider ->
+ provider.descriptor.providerId == REFERENCE_EXTENSION_ID
+ }.descriptor
+ assertTrue(descriptor.variants.any { variant -> variant.kind == REFERENCE_PROVIDER_KIND })
+ val serverUrl = InstrumentationRegistry.getArguments()
+ .getString("m3uMockServerUrl", DEFAULT_SERVER_URL)
+ .trimEnd('/')
+ repeat(3) {
+ val failure = runCatching {
+ providerRepository.subscribe(
+ ProviderSubscriptionRequest(
+ title = PLAYLIST_TITLE,
+ providerId = REFERENCE_EXTENSION_ID,
+ providerKind = REFERENCE_PROVIDER_KIND,
+ settingValues = mapOf(
+ SubscriptionProviderSettingKeys.BaseUrl to serverUrl,
+ SubscriptionProviderSettingKeys.Username to "m3u",
+ ),
+ credentialHandles = mapOf(
+ SubscriptionProviderSettingKeys.Password to
+ providerRepository.stageCredential("wrong-password"),
+ ),
+ )
+ )
+ }.exceptionOrNull()
+ assertTrue(failure is ProviderOperationException)
+ assertEquals(
+ "provider.authentication_failed",
+ (failure as ProviderOperationException).code,
+ )
+ }
+ val pluginAfterRejectedLogins = pluginRepository.installedPlugins().single { installed ->
+ installed.packageName == REFERENCE_PACKAGE
+ }
+ assertEquals(ExtensionState.ENABLED, pluginAfterRejectedLogins.state)
+
+ val subscription = providerRepository.subscribe(
+ ProviderSubscriptionRequest(
+ title = PLAYLIST_TITLE,
+ providerId = REFERENCE_EXTENSION_ID,
+ providerKind = REFERENCE_PROVIDER_KIND,
+ settingValues = mapOf(
+ SubscriptionProviderSettingKeys.BaseUrl to serverUrl,
+ SubscriptionProviderSettingKeys.Username to "m3u",
+ ),
+ credentialHandles = mapOf(
+ SubscriptionProviderSettingKeys.Password to
+ providerRepository.stageCredential("reference-password"),
+ ),
+ )
+ )
+ providerPlaylistUrl = subscription.playlistUrl
+ assertEquals(2, subscription.channelCount)
+ assertEquals(
+ 2,
+ requireNotNull(
+ playlistRepository.getPlaylistWithChannels(subscription.playlistUrl)
+ ).channels.size,
+ )
+
+ val refresh = providerRepository.refresh(subscription.playlistUrl)
+ assertEquals(2, refresh.channelCount)
+ val channels = requireNotNull(
+ playlistRepository.getPlaylistWithChannels(subscription.playlistUrl)
+ ).channels
+ assertEquals(2, channels.size)
+ val news = channels.single { channel -> channel.relationId == REFERENCE_NEWS_ID }
+ assertEquals(Channel.URL_DYNAMIC, news.url)
+
+ val source = requireNotNull(providerRepository.resolvePlayback(news.id))
+ assertNotEquals(Channel.URL_DYNAMIC, source.url)
+ assertEquals(
+ "$serverUrl/reference-provider/stream/$REFERENCE_NEWS_ID/index.m3u8",
+ source.url,
+ )
+ assertEquals(REFERENCE_ACCESS_TOKEN, source.headers["X-Emby-Token"])
+ assertEquals(REFERENCE_USER_ID, source.headers["X-Reference-User"])
+ val session = requireNotNull(source.session)
+ activeSession = session
+ val playSessionId = requireNotNull(session.playSessionId)
+ assertEquals("open", referenceSessionState(serverUrl, playSessionId))
+
+ assertTrue(
+ providerRepository.closePlayback(
+ session = session,
+ reason = ProviderPlaybackCloseReason.STOPPED,
+ )
+ )
+ activeSession = null
+ assertEquals("closed", referenceSessionState(serverUrl, playSessionId))
+
+ val pluginAfterPlayback = pluginRepository.installedPlugins().single { installed ->
+ installed.packageName == REFERENCE_PACKAGE
+ }
+ assertEquals(ExtensionState.ENABLED, pluginAfterPlayback.state)
+ } finally {
+ activeSession?.let { session ->
+ runCatching {
+ providerRepository.closePlayback(
+ session = session,
+ reason = ProviderPlaybackCloseReason.STOPPED,
+ )
+ }
+ }
+ providerPlaylistUrl?.let { playlistUrl ->
+ runCatching { playlistRepository.unsubscribe(playlistUrl) }
+ }
+ if (pluginPackage != null && pluginService != null) {
+ runCatching {
+ pluginRepository.revoke(
+ packageName = checkNotNull(pluginPackage),
+ serviceName = checkNotNull(pluginService),
+ )
+ }
+ }
+ context.settings.edit { preferences ->
+ preferences[PreferencesKeys.EXTERNAL_EXTENSIONS] = false
+ }
+ }
+ }
+
+ private fun referenceSessionState(
+ serverUrl: String,
+ playSessionId: String,
+ ): String {
+ val connection = URL(
+ "$serverUrl/reference-provider/sessions/$playSessionId"
+ ).openConnection() as HttpURLConnection
+ return try {
+ connection.connectTimeout = 2_000
+ connection.readTimeout = 2_000
+ connection.setRequestProperty("X-Emby-Token", REFERENCE_ACCESS_TOKEN)
+ assertEquals(200, connection.responseCode)
+ val payload = connection.inputStream.bufferedReader().use { reader ->
+ Json.parseToJsonElement(reader.readText()).jsonObject
+ }
+ payload.getValue("state").jsonPrimitive.content
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ private companion object {
+ const val REFERENCE_PACKAGE = "com.m3u.testing.extension.reference"
+ const val PLAYLIST_TITLE = "Reference Provider E2E"
+ const val DEFAULT_SERVER_URL = "http://10.0.2.2:8080"
+ const val REFERENCE_ACCESS_TOKEN = "mock-reference-access-token"
+ const val REFERENCE_USER_ID = "reference-user-id"
+ const val REFERENCE_NEWS_ID = "reference.news"
+ val REFERENCE_EXTENSION_ID = ExtensionId("com.m3u.reference.provider")
+ val REFERENCE_PROVIDER_KIND = ProviderKind("reference")
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/FloatingNavigationBehaviorTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/FloatingNavigationBehaviorTest.kt
new file mode 100644
index 000000000..14b5634b5
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/FloatingNavigationBehaviorTest.kt
@@ -0,0 +1,251 @@
+package com.m3u.testing
+
+import android.os.SystemClock
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp
+import androidx.test.core.app.ActivityScenario
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.BySelector
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.UiObject2
+import androidx.test.uiautomator.Until
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.MainActivity
+import com.m3u.smartphone.ui.common.helper.Fob
+import com.m3u.smartphone.ui.common.helper.Metadata
+import com.m3u.smartphone.ui.material.components.Destination
+import org.junit.Assume.assumeTrue
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.regex.Pattern
+
+class FloatingNavigationBehaviorTest {
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val context = instrumentation.targetContext
+ private val device = UiDevice.getInstance(instrumentation)
+
+ @Test
+ fun compactNavigationStaysForScrollActionAndHidesForDetailAndSearch() {
+ assumeTrue(
+ device.displayWidth / context.resources.displayMetrics.density < COMPACT_WIDTH_DP,
+ )
+
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ device.waitForIdle()
+
+ val forYou = navigationSelector(string.ui_destination_foryou)
+ val favorite = navigationSelector(string.ui_destination_favourite)
+ val setting = navigationSelector(string.ui_destination_setting)
+ val destinations = listOf(forYou, favorite, setting)
+
+ val selectedForYou = device.findRequiredObject(forYou)
+ assertFalse(
+ "Android should not expose the already-selected tab as clickable",
+ selectedForYou.isClickable,
+ )
+ assertTrue(
+ "The selected top-level destination did not expose selected semantics",
+ selectedForYou.isSelected,
+ )
+ assertTrue(
+ "The compact navigation unexpectedly rendered destination labels",
+ selectedForYou.text.isNullOrEmpty(),
+ )
+ device.assertSingleSelectedDestination(
+ destinations = destinations,
+ expected = forYou,
+ )
+ scenario.onActivity {
+ Metadata.fob = Fob(
+ destination = Destination.Foryou,
+ icon = Icons.Rounded.KeyboardDoubleArrowUp,
+ iconTextId = string.feat_playlist_scroll_up,
+ onClick = {},
+ )
+ }
+ device.waitForIdle()
+ SystemClock.sleep(NAVIGATION_ANIMATION_SETTLE_MILLIS)
+ device.findRequiredObject(forYou)
+ scenario.onActivity {
+ Metadata.fob = null
+ }
+
+ device.findRequiredObject(
+ By.text(
+ caseInsensitive(
+ context.getString(string.ui_search_placeholder),
+ )
+ )
+ ).clickableAncestor().click()
+ device.findRequiredObject(
+ By.desc(
+ caseInsensitive(
+ context.getString(string.ui_cd_top_bar_on_back_pressed),
+ )
+ )
+ )
+ assertTrue(
+ "Floating navigation remained visible while search was expanded",
+ device.wait(Until.gone(forYou), UI_TIMEOUT_MILLIS)
+ )
+
+ device.pressBack()
+ device.pressBack()
+ device.findRequiredObject(forYou)
+
+ val dragStart = device.findRequiredObject(forYou).visibleBounds
+ val dragEnd = device.findRequiredObject(favorite).visibleBounds
+ device.swipe(
+ dragStart.centerX(),
+ dragStart.centerY(),
+ dragEnd.centerX(),
+ dragEnd.centerY(),
+ NAVIGATION_DRAG_STEPS,
+ )
+ device.waitForIdle()
+ SystemClock.sleep(NAVIGATION_ANIMATION_SETTLE_MILLIS)
+ assertTrue(
+ "Dragging the glass indicator did not select the favorite destination",
+ device.findRequiredObject(favorite).isSelected,
+ )
+ device.assertSingleSelectedDestination(
+ destinations = destinations,
+ expected = favorite,
+ )
+ device.findRequiredObject(setting).run {
+ assertTrue(
+ "The named navigation destination did not expose a click action",
+ isClickable,
+ )
+ click()
+ }
+ assertTrue(
+ "The clicked settings destination did not expose selected semantics",
+ device.findRequiredObject(setting).isSelected,
+ )
+ device.assertSingleSelectedDestination(
+ destinations = destinations,
+ expected = setting,
+ )
+
+ val playlistManagement = By.text(
+ caseInsensitive(
+ context.getString(string.feat_setting_playlist_management),
+ )
+ )
+ device.findRequiredObject(playlistManagement).click()
+ assertTrue(
+ "Floating navigation remained visible on the settings detail pane",
+ device.wait(Until.gone(setting), UI_TIMEOUT_MILLIS)
+ )
+
+ device.pressBack()
+ device.findRequiredObject(setting)
+ }
+ }
+
+ @Test
+ fun settingsDetailSurvivesCompactAndRailLayoutChanges() {
+ device.setOrientationNatural()
+ try {
+ device.waitForIdle()
+ val density = context.resources.displayMetrics.density
+ assumeTrue(device.displayWidth / density < COMPACT_WIDTH_DP)
+ assumeTrue(device.displayHeight / density >= COMPACT_WIDTH_DP)
+
+ ActivityScenario.launch(MainActivity::class.java).use {
+ device.waitForIdle()
+ val setting = navigationSelector(string.ui_destination_setting)
+ val playlistOverview = By.text(
+ caseInsensitive(
+ context.getString(
+ string.feat_setting_playlist_content_and_guide
+ ),
+ )
+ )
+
+ device.findRequiredObject(setting).click()
+ device.findRequiredObject(
+ By.text(
+ caseInsensitive(
+ context.getString(string.feat_setting_playlist_management),
+ )
+ )
+ ).click()
+ device.findRequiredObject(playlistOverview)
+
+ device.setOrientationLeft()
+ device.waitForWindowUpdate(context.packageName, UI_TIMEOUT_MILLIS)
+ device.waitForIdle()
+ assertTrue(
+ "The rotated window did not cross into the side-rail layout",
+ device.displayWidth / density >= COMPACT_WIDTH_DP,
+ )
+ device.findRequiredObject(playlistOverview)
+ device.findRequiredObject(
+ By.text(
+ caseInsensitive(
+ context.getString(string.ui_destination_setting),
+ )
+ )
+ )
+
+ device.setOrientationNatural()
+ device.waitForWindowUpdate(context.packageName, UI_TIMEOUT_MILLIS)
+ device.waitForIdle()
+ device.findRequiredObject(playlistOverview)
+ assertTrue(
+ "The compact navigation reappeared after the detail state moved back",
+ device.wait(Until.gone(setting), UI_TIMEOUT_MILLIS),
+ )
+ }
+ } finally {
+ device.setOrientationNatural()
+ device.unfreezeRotation()
+ }
+ }
+
+ private fun navigationSelector(stringResId: Int): BySelector =
+ By.desc(caseInsensitive(context.getString(stringResId)))
+
+ private fun UiDevice.findRequiredObject(selector: BySelector): UiObject2 =
+ wait(Until.findObject(selector), UI_TIMEOUT_MILLIS)
+ ?: error("Required UI object was not found: $selector")
+
+ private fun UiDevice.assertSingleSelectedDestination(
+ destinations: List,
+ expected: BySelector,
+ ) {
+ val destinationNodes = destinations.map { findRequiredObject(it) }
+ assertTrue(
+ "Navigation should expose exactly one selected tab",
+ destinationNodes.count { it.isSelected } == 1,
+ )
+ assertTrue(
+ "The expected tab was not the selected member of the navigation group",
+ findRequiredObject(expected).isSelected,
+ )
+ }
+
+ private fun UiObject2.clickableAncestor(): UiObject2 {
+ var current = this
+ while (!current.isClickable) {
+ current = current.parent ?: return this
+ }
+ return current
+ }
+
+ private fun caseInsensitive(value: String): Pattern = Pattern.compile(
+ Pattern.quote(value),
+ Pattern.CASE_INSENSITIVE,
+ )
+
+ private companion object {
+ const val COMPACT_WIDTH_DP = 600f
+ const val NAVIGATION_DRAG_STEPS = 24
+ const val NAVIGATION_ANIMATION_SETTLE_MILLIS = 500L
+ const val UI_TIMEOUT_MILLIS = 5_000L
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/FloatingRemoteControlDockTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/FloatingRemoteControlDockTest.kt
new file mode 100644
index 000000000..56eccb041
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/FloatingRemoteControlDockTest.kt
@@ -0,0 +1,103 @@
+package com.m3u.testing
+
+import android.view.View
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.test.platform.app.InstrumentationRegistry
+import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
+import com.m3u.core.foundation.architecture.preferences.set
+import com.m3u.core.foundation.architecture.preferences.settings
+import com.m3u.smartphone.MainActivity
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeTrue
+import org.junit.Rule
+import org.junit.Test
+import kotlin.math.abs
+
+class FloatingRemoteControlDockTest {
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
+
+ @Test
+ fun remoteControlIsAnIndependentLogicalTrailingAction() {
+ val density = context.resources.displayMetrics.density
+ assumeTrue(
+ context.resources.configuration.screenWidthDp < COMPACT_WIDTH_DP,
+ )
+
+ runBlocking {
+ context.settings.set(PreferencesKeys.REMOTE_CONTROL, true)
+ }
+ try {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodesWithTag(REMOTE_CONTROL_ACTION_TAG)
+ .fetchSemanticsNodes()
+ .isNotEmpty()
+ }
+ composeRule.waitForIdle()
+
+ val remoteControlNode = composeRule
+ .onNodeWithTag(REMOTE_CONTROL_ACTION_TAG)
+ .fetchSemanticsNode()
+ val navigationNode = composeRule
+ .onNodeWithTag(FLOATING_NAVIGATION_TAG)
+ .fetchSemanticsNode()
+ assertEquals(
+ "Remote control must remain an action instead of becoming a tab",
+ Role.Button,
+ remoteControlNode.config[SemanticsProperties.Role],
+ )
+ assertFalse(
+ "Remote control must not participate in destination selection",
+ remoteControlNode.config.contains(SemanticsProperties.Selected),
+ )
+ assertTrue(
+ "Remote control action must provide at least a 48dp touch target",
+ remoteControlNode.boundsInWindow.width >= MINIMUM_TOUCH_TARGET_DP * density &&
+ remoteControlNode.boundsInWindow.height >=
+ MINIMUM_TOUCH_TARGET_DP * density,
+ )
+
+ val remoteBounds = remoteControlNode.boundsInWindow
+ val navigationBounds = navigationNode.boundsInWindow
+ val isRtl =
+ context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
+ val logicalGap = if (isRtl) {
+ navigationBounds.left - remoteBounds.right
+ } else {
+ remoteBounds.left - navigationBounds.right
+ }
+ assertTrue(
+ "Remote control must sit outside the navigation at logical trailing",
+ logicalGap in
+ MINIMUM_DOCK_GAP_DP * density..MAXIMUM_DOCK_GAP_DP * density,
+ )
+ assertTrue(
+ "Remote control and navigation must share the same vertical center",
+ abs(remoteBounds.center.y - navigationBounds.center.y) <= density,
+ )
+ } finally {
+ runBlocking {
+ context.settings.set(PreferencesKeys.REMOTE_CONTROL, false)
+ }
+ }
+ }
+
+ private companion object {
+ const val COMPACT_WIDTH_DP = 600
+ const val MINIMUM_DOCK_GAP_DP = 10f
+ const val MAXIMUM_DOCK_GAP_DP = 14f
+ const val MINIMUM_TOUCH_TARGET_DP = 48f
+ const val UI_TIMEOUT_MILLIS = 5_000L
+ const val FLOATING_NAVIGATION_TAG = "floating-app-navigation"
+ const val REMOTE_CONTROL_ACTION_TAG = "floating-remote-control-action"
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/PlaylistAdaptiveLayoutTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/PlaylistAdaptiveLayoutTest.kt
new file mode 100644
index 000000000..28a1beff7
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/PlaylistAdaptiveLayoutTest.kt
@@ -0,0 +1,508 @@
+package com.m3u.testing
+
+import android.content.res.Configuration
+import android.view.View
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assertHasClickAction
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.DebugExtensionPlatformEntryPoint
+import com.m3u.smartphone.MainActivity
+import com.m3u.data.worker.playlistWorkTag
+import dagger.hilt.android.EntryPointAccessors
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+class PlaylistAdaptiveLayoutTest {
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val context = instrumentation.targetContext
+ private val device = UiDevice.getInstance(instrumentation)
+
+ @Test
+ fun narrowWidthOverviewSourcePickerAndEditorActionsRemainUsable() {
+ val configuration = currentConfiguration()
+ assertEquals(
+ MATRIX_CASE_COMPACT_NARROW_LTR,
+ requestedAccessibilityMatrixCase(),
+ )
+ assertTrue(
+ "Expected a 320dp narrow window, actual=${configuration.screenWidthDp}",
+ configuration.screenWidthDp in NARROW_WIDTH_RANGE,
+ )
+
+ openPlaylistManagementOverview()
+ composeRule.onNodeWithTag(ADD_ACTION_TAG).run {
+ performScrollTo()
+ performClick()
+ }
+ waitUntilTagExists(SOURCE_PICKER_TAG)
+ composeRule.onNodeWithTag(M3U_SOURCE_TAG).run {
+ performScrollTo()
+ assertHasClickAction()
+ performClick()
+ }
+ waitUntilTagExists(M3U_EDITOR_TAG)
+
+ val submitBounds = composeRule.onNodeWithTag(SUBMIT_ACTION_TAG)
+ .fetchSemanticsNode()
+ .boundsInWindow
+ val pasteAction = hasContentDescription(
+ context.getString(string.feat_setting_label_parse_from_clipboard),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+ waitUntilMatcherExists(pasteAction)
+ val pasteBounds = composeRule.onNode(pasteAction)
+ .fetchSemanticsNode()
+ .boundsInWindow
+ val density = composeRule.density.density
+
+ assertTrue(
+ "Submit action escaped the 320dp window: $submitBounds",
+ submitBounds.left >= 0f && submitBounds.right <= device.displayWidth,
+ )
+ assertTrue(
+ "Paste action escaped the 320dp window: $pasteBounds",
+ pasteBounds.left >= 0f && pasteBounds.right <= device.displayWidth,
+ )
+ assertFalse(
+ "Submit and paste actions overlap at 320dp: " +
+ "submit=$submitBounds, paste=$pasteBounds",
+ submitBounds.intersects(pasteBounds),
+ )
+ assertTrue(
+ "Paste action must keep a 48dp touch target: $pasteBounds",
+ pasteBounds.width >= MINIMUM_TOUCH_TARGET_DP * density &&
+ pasteBounds.height >= MINIMUM_TOUCH_TARGET_DP * density,
+ )
+ }
+
+ @Test
+ fun mediumWidthSideRailUsesSinglePlaylistPaneHeadersAndBackNavigation() {
+ val configuration = currentConfiguration()
+ assertEquals(MATRIX_CASE_MEDIUM_LTR, requestedAccessibilityMatrixCase())
+ assertEquals(
+ "The medium tablet case must use English resources",
+ LOCALE_ENGLISH,
+ configuration.locales[0].toLanguageTag(),
+ )
+ assertEquals(
+ "The medium tablet case must be LTR",
+ View.LAYOUT_DIRECTION_LTR,
+ configuration.layoutDirection,
+ )
+ assertTrue(
+ "Expected 600–839dp medium width, actual=${configuration.screenWidthDp}",
+ configuration.screenWidthDp in MEDIUM_WIDTH_RANGE,
+ )
+ assertTrue(
+ "The medium tablet case should use normal font scale",
+ configuration.fontScale < LARGE_TEXT_THRESHOLD,
+ )
+
+ openPlaylistManagementOverview()
+ assertSideRailAndSinglePane()
+ val overviewBack = assertPaneHeader(
+ contentTag = OVERVIEW_TAG,
+ title = context.getString(string.feat_setting_playlist_management),
+ )
+
+ composeRule.onNodeWithTag(ADD_ACTION_TAG).run {
+ performScrollTo()
+ performClick()
+ }
+ waitUntilTagExists(SOURCE_PICKER_TAG)
+ val pickerBack = assertPaneHeader(
+ contentTag = SOURCE_PICKER_TAG,
+ title = context.getString(
+ string.feat_setting_playlist_source_picker_title
+ ),
+ )
+ clickBoundsCenter(pickerBack)
+ waitUntilTagGone(SOURCE_PICKER_TAG)
+ waitUntilTagExists(OVERVIEW_TAG)
+
+ val restoredOverviewBack = assertPaneHeader(
+ contentTag = OVERVIEW_TAG,
+ title = context.getString(string.feat_setting_playlist_management),
+ )
+ assertEquals(
+ "The overview pane header moved after returning from the source picker",
+ overviewBack,
+ restoredOverviewBack,
+ )
+ clickBoundsCenter(restoredOverviewBack)
+ waitUntilTagGone(OVERVIEW_TAG)
+ waitUntilMatcherExists(playlistManagementEntryMatcher())
+ }
+
+ @Test
+ fun epgLeafDeleteActionDoesNotOverlapContentAtTwoHundredPercentText() {
+ val configuration = currentConfiguration()
+ assertEquals(
+ MATRIX_CASE_COMPACT_RTL_LARGE,
+ requestedAccessibilityMatrixCase(),
+ )
+ assertEquals(
+ "The large-text case must use the RTL pseudo locale",
+ LOCALE_RTL_PSEUDO,
+ configuration.locales[0].toLanguageTag(),
+ )
+ assertEquals(
+ "The large-text case must be RTL",
+ View.LAYOUT_DIRECTION_RTL,
+ configuration.layoutDirection,
+ )
+ assertTrue(
+ "Expected 200% text, actual=${configuration.fontScale}",
+ configuration.fontScale >= LARGE_TEXT_MINIMUM_SCALE,
+ )
+ assertTrue(
+ "Expected a 320dp narrow window, actual=${configuration.screenWidthDp}",
+ configuration.screenWidthDp in NARROW_WIDTH_RANGE,
+ )
+
+ val playlistRepository = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ DebugExtensionPlatformEntryPoint::class.java,
+ ).playlistRepository()
+ runBlocking {
+ playlistRepository.get(TEST_EPG_URL)?.let {
+ playlistRepository.deleteEpgPlaylistAndProgrammes(TEST_EPG_URL)
+ }
+ playlistRepository.insertEpgAsPlaylist(
+ title = TEST_EPG_TITLE,
+ epg = TEST_EPG_URL,
+ )
+ }
+
+ try {
+ openPlaylistManagementOverview()
+ composeRule.onNodeWithTag(EPG_SOURCES_ACTION_TAG).run {
+ performScrollTo()
+ performClick()
+ }
+ waitUntilTagExists(EPG_SOURCES_LIST_TAG)
+ waitUntilTagExists(epgItemTag())
+
+ val itemBounds = composeRule.onNodeWithTag(epgItemTag())
+ .fetchSemanticsNode()
+ .boundsInWindow
+ val deleteAction = hasContentDescription(
+ TEST_EPG_TITLE,
+ substring = true,
+ ignoreCase = false,
+ ) and hasClickAction()
+ waitUntilMatcherExists(deleteAction)
+ val deleteBounds = composeRule.onNode(deleteAction)
+ .fetchSemanticsNode()
+ .boundsInWindow
+ val titleBounds = composeRule.onNode(
+ hasTextIgnoringBidiControls(
+ expected = TEST_EPG_TITLE,
+ substring = false,
+ ),
+ useUnmergedTree = true,
+ ).fetchSemanticsNode().boundsInWindow
+ val urlBounds = composeRule.onNode(
+ hasTextIgnoringBidiControls(
+ expected = TEST_EPG_DISPLAY_REFERENCE,
+ substring = true,
+ ),
+ useUnmergedTree = true,
+ ).fetchSemanticsNode().boundsInWindow
+ listOf(
+ "password",
+ "private-token",
+ "query-secret",
+ "large-text-guide.xml",
+ ).forEach { sensitiveValue ->
+ assertTrue(
+ "EPG semantics exposed sensitive source text: $sensitiveValue",
+ composeRule.onAllNodes(
+ hasText(
+ sensitiveValue,
+ substring = true,
+ ignoreCase = false,
+ ),
+ useUnmergedTree = true,
+ ).fetchSemanticsNodes().isEmpty(),
+ )
+ }
+ val density = composeRule.density.density
+
+ assertTrue(
+ "EPG delete action must keep a 48dp touch target: $deleteBounds",
+ deleteBounds.width >= MINIMUM_TOUCH_TARGET_DP * density &&
+ deleteBounds.height >= MINIMUM_TOUCH_TARGET_DP * density,
+ )
+ assertTrue(
+ "EPG delete action escaped its list item: " +
+ "action=$deleteBounds, item=$itemBounds",
+ itemBounds.contains(deleteBounds),
+ )
+ assertFalse(
+ "EPG delete action overlaps the title at 200% text: " +
+ "action=$deleteBounds, title=$titleBounds",
+ deleteBounds.intersects(titleBounds),
+ )
+ assertFalse(
+ "EPG delete action overlaps the URL at 200% text: " +
+ "action=$deleteBounds, url=$urlBounds",
+ deleteBounds.intersects(urlBounds),
+ )
+
+ composeRule.onNode(deleteAction)
+ .assertHasClickAction()
+ .performClick()
+ waitUntilTagExists(DELETE_EPG_DIALOG_TAG)
+ device.pressBack()
+ waitUntilTagGone(DELETE_EPG_DIALOG_TAG)
+ } finally {
+ runBlocking {
+ playlistRepository.get(TEST_EPG_URL)?.let {
+ playlistRepository.deleteEpgPlaylistAndProgrammes(TEST_EPG_URL)
+ }
+ }
+ }
+ }
+
+ private fun openPlaylistManagementOverview() {
+ if (tagExists(OVERVIEW_TAG)) return
+
+ val playlistManagement = playlistManagementEntryMatcher()
+ val settingsDestination = hasContentDescription(
+ context.getString(string.ui_destination_setting),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+ waitUntilMatcherExists(playlistManagement or settingsDestination)
+ if (composeRule.onAllNodes(playlistManagement).fetchSemanticsNodes().isEmpty()) {
+ composeRule.onNode(settingsDestination).performClick()
+ }
+ waitUntilMatcherExists(playlistManagement)
+ composeRule.onNode(playlistManagement).performClick()
+ waitUntilTagExists(OVERVIEW_TAG)
+ }
+
+ private fun assertSideRailAndSinglePane() {
+ val overviewBounds = composeRule.onNodeWithTag(OVERVIEW_TAG)
+ .fetchSemanticsNode()
+ .boundsInWindow
+ val density = composeRule.density.density
+ assertTrue(
+ "Playlist content did not leave space for the side rail: " +
+ "overview=$overviewBounds",
+ overviewBounds.left >= MINIMUM_SIDE_RAIL_WIDTH_DP * density,
+ )
+ assertTrue(
+ "Playlist detail did not occupy the medium window's single content pane: " +
+ "overview=$overviewBounds, displayWidth=${device.displayWidth}",
+ overviewBounds.width >=
+ device.displayWidth * MINIMUM_SINGLE_PANE_WIDTH_FRACTION,
+ )
+ }
+
+ private fun assertPaneHeader(
+ contentTag: String,
+ title: String,
+ ): Rect {
+ val contentBounds = composeRule.onNodeWithTag(contentTag)
+ .fetchSemanticsNode()
+ .boundsInWindow
+ val heading = hasText(
+ title,
+ substring = false,
+ ignoreCase = true,
+ ) and SemanticsMatcher("is heading") { node ->
+ node.config.contains(SemanticsProperties.Heading)
+ }
+ val headingBounds = composeRule.onAllNodes(heading)
+ .fetchSemanticsNodes()
+ .map { node -> node.boundsInWindow }
+ .firstOrNull { bounds -> bounds.isHeaderFor(contentBounds) }
+ ?: error(
+ "Playlist pane heading was not found above $contentTag: " +
+ "title=$title, content=$contentBounds",
+ )
+ val back = hasContentDescription(
+ context.getString(string.ui_cd_top_bar_on_back_pressed),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+ val backNode = composeRule.onAllNodes(back)
+ .fetchSemanticsNodes()
+ .firstOrNull { node -> node.boundsInWindow.isHeaderFor(contentBounds) }
+ ?: error(
+ "Playlist pane back action was not found above $contentTag: " +
+ "content=$contentBounds",
+ )
+ val backBounds = backNode.boundsInWindow
+ val touchBounds = backNode.touchBoundsInRoot
+ val touchDensity = backNode.layoutInfo.density.density
+
+ assertTrue(
+ "Pane heading overlaps content: heading=$headingBounds, " +
+ "content=$contentBounds",
+ headingBounds.bottom <= contentBounds.top + BOUNDS_TOLERANCE_PX,
+ )
+ assertTrue(
+ "Pane back action must keep a 48dp touch target: $touchBounds",
+ touchBounds.width >= MINIMUM_TOUCH_TARGET_DP * touchDensity &&
+ touchBounds.height >= MINIMUM_TOUCH_TARGET_DP * touchDensity,
+ )
+ assertFalse(
+ "Pane heading overlaps its back action: " +
+ "heading=$headingBounds, back=$backBounds",
+ headingBounds.intersects(backBounds),
+ )
+ return backBounds
+ }
+
+ private fun clickBoundsCenter(bounds: Rect) {
+ assertTrue(
+ "Could not click pane action at $bounds",
+ device.click(bounds.center.x.toInt(), bounds.center.y.toInt()),
+ )
+ }
+
+ private fun playlistManagementEntryMatcher(): SemanticsMatcher =
+ hasText(
+ context.getString(string.feat_setting_playlist_management),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+
+ private fun currentConfiguration(): Configuration = composeRule.runOnIdle {
+ Configuration(composeRule.activity.resources.configuration)
+ }
+
+ private fun requestedAccessibilityMatrixCase(): String =
+ InstrumentationRegistry.getArguments()
+ .getString(ARG_ACCESSIBILITY_MATRIX_CASE)
+ ?: error(
+ "Missing required instrumentation argument " +
+ "$ARG_ACCESSIBILITY_MATRIX_CASE. Run " +
+ "testing/bin/run-smartphone-provider-ui-matrix.sh.",
+ )
+
+ private fun waitUntilTagExists(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) { tagExists(tag) }
+ }
+
+ private fun waitUntilTagGone(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) { !tagExists(tag) }
+ }
+
+ private fun waitUntilMatcherExists(matcher: SemanticsMatcher) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun hasTextIgnoringBidiControls(
+ expected: String,
+ substring: Boolean,
+ ): SemanticsMatcher {
+ val normalizedExpected = expected.withoutBidiControls()
+ return SemanticsMatcher(
+ "Text matches '$normalizedExpected' after removing bidi controls",
+ ) { node ->
+ node.config
+ .getOrElse(SemanticsProperties.Text) { emptyList() }
+ .any { text ->
+ val normalizedActual = text.text.withoutBidiControls()
+ if (substring) {
+ normalizedActual.contains(normalizedExpected)
+ } else {
+ normalizedActual == normalizedExpected
+ }
+ }
+ }
+ }
+
+ private fun String.withoutBidiControls(): String = filterNot { character ->
+ character == '\u061C' ||
+ character == '\u200E' ||
+ character == '\u200F' ||
+ character in '\u202A'..'\u202E' ||
+ character in '\u2066'..'\u2069'
+ }
+
+ private fun tagExists(tag: String): Boolean =
+ composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
+
+ private fun Rect.isHeaderFor(content: Rect): Boolean =
+ bottom <= content.top + BOUNDS_TOLERANCE_PX &&
+ right > content.left &&
+ left < content.right
+
+ private fun Rect.intersects(other: Rect): Boolean =
+ left < other.right &&
+ right > other.left &&
+ top < other.bottom &&
+ bottom > other.top
+
+ private fun Rect.contains(other: Rect): Boolean =
+ left <= other.left + BOUNDS_TOLERANCE_PX &&
+ top <= other.top + BOUNDS_TOLERANCE_PX &&
+ right >= other.right - BOUNDS_TOLERANCE_PX &&
+ bottom >= other.bottom - BOUNDS_TOLERANCE_PX
+
+ private fun epgItemTag(): String =
+ "playlist-epg:${playlistWorkTag(TEST_EPG_URL)}"
+
+ private companion object {
+ const val UI_TIMEOUT_MILLIS = 15_000L
+ const val ARG_ACCESSIBILITY_MATRIX_CASE = "accessibilityMatrixCase"
+ const val MATRIX_CASE_MEDIUM_LTR = "medium-ltr"
+ const val MATRIX_CASE_COMPACT_NARROW_LTR = "compact-narrow-ltr"
+ const val MATRIX_CASE_COMPACT_RTL_LARGE = "compact-rtl-large"
+ const val LOCALE_ENGLISH = "en"
+ const val LOCALE_RTL_PSEUDO = "ar-XB"
+ const val MEDIUM_WIDTH_MINIMUM_DP = 600
+ const val MEDIUM_WIDTH_MAXIMUM_DP = 839
+ val MEDIUM_WIDTH_RANGE =
+ MEDIUM_WIDTH_MINIMUM_DP..MEDIUM_WIDTH_MAXIMUM_DP
+ val NARROW_WIDTH_RANGE = 315..325
+ const val LARGE_TEXT_MINIMUM_SCALE = 1.95f
+ const val LARGE_TEXT_THRESHOLD = 1.3f
+ const val MINIMUM_TOUCH_TARGET_DP = 48
+ const val MINIMUM_SIDE_RAIL_WIDTH_DP = 72
+ const val MINIMUM_SINGLE_PANE_WIDTH_FRACTION = 0.75f
+ const val BOUNDS_TOLERANCE_PX = 2f
+
+ const val OVERVIEW_TAG = "playlist-management-overview"
+ const val ADD_ACTION_TAG = "playlist-add-action"
+ const val SOURCE_PICKER_TAG = "playlist-source-picker"
+ const val M3U_SOURCE_TAG = "playlist-source:data-source:m3u"
+ const val M3U_EDITOR_TAG = "playlist-editor:data-source:m3u"
+ const val SUBMIT_ACTION_TAG = "subscription-submit-action"
+ const val EPG_SOURCES_ACTION_TAG = "playlist-overview-epg-sources"
+ const val EPG_SOURCES_LIST_TAG = "playlist-list:epg-sources"
+ const val DELETE_EPG_DIALOG_TAG = "playlist-delete-epg-dialog"
+ const val TEST_EPG_TITLE = "قناة News 24"
+ const val TEST_EPG_URL =
+ "https://viewer:password@example.invalid/private-token/" +
+ "large-text-guide.xml?access_token=query-secret"
+ const val TEST_EPG_DISPLAY_REFERENCE = "https://example.invalid"
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/PlaylistManagementFlowTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/PlaylistManagementFlowTest.kt
new file mode 100644
index 000000000..1ba40404a
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/PlaylistManagementFlowTest.kt
@@ -0,0 +1,641 @@
+package com.m3u.testing
+
+import android.Manifest
+import android.net.Uri
+import android.os.Build
+import android.os.SystemClock
+import android.view.View
+import androidx.annotation.StringRes
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertHasClickAction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertTextContains
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.hasSetTextAction
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performTextClearance
+import androidx.compose.ui.test.performTextReplacement
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.text.AnnotatedString
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import androidx.work.WorkManager
+import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.data.worker.playlistWorkTag
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.DebugExtensionPlatformEntryPoint
+import com.m3u.smartphone.MainActivity
+import dagger.hilt.android.EntryPointAccessors
+import java.io.File
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+class PlaylistManagementFlowTest {
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val context = instrumentation.targetContext
+ private val device = UiDevice.getInstance(instrumentation)
+ private val playlistRepository: PlaylistRepository by lazy {
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ DebugExtensionPlatformEntryPoint::class.java,
+ ).playlistRepository()
+ }
+
+ @Test
+ fun existingPlaylistRowOpensItsConfigurationScreen() {
+ val fixture = createM3uFixture("existing")
+ val playlistUrl = Uri.fromFile(fixture).toString()
+
+ try {
+ removePlaylistIfPresent(playlistUrl)
+ runBlocking {
+ playlistRepository.m3uOrThrow(
+ title = EXISTING_PLAYLIST_TITLE,
+ url = playlistUrl,
+ )
+ }
+
+ openPlaylistManagementOverview()
+ val playlistItemTag =
+ "playlist-management-item:${playlistWorkTag(playlistUrl)}"
+ waitUntilTagExists(playlistItemTag)
+ composeRule.onNodeWithTag(playlistItemTag).run {
+ performScrollTo()
+ assertHasClickAction()
+ assertTextContains(
+ EXISTING_PLAYLIST_TITLE,
+ substring = false,
+ ignoreCase = false,
+ )
+ performClick()
+ }
+
+ waitUntilTagExists(PLAYLIST_CONFIGURATION_TAG)
+ composeRule.onNodeWithTag(PLAYLIST_CONFIGURATION_TAG)
+ .assertIsDisplayed()
+ composeRule.onNodeWithTag(PLAYLIST_CONFIGURATION_TITLE_TAG)
+ .assertTextContains(
+ EXISTING_PLAYLIST_TITLE,
+ substring = false,
+ ignoreCase = false,
+ )
+ assertTrue(
+ "Playlist configuration must replace the global search chrome",
+ composeRule.onAllNodes(
+ hasText(
+ context.getString(string.ui_search_placeholder),
+ substring = false,
+ ignoreCase = true,
+ )
+ ).fetchSemanticsNodes().isEmpty(),
+ )
+
+ val backAction = hasContentDescription(
+ context.getString(string.ui_cd_top_bar_on_back_pressed),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+ composeRule.onNode(backAction)
+ .assertIsDisplayed()
+ .performClick()
+ waitUntilTagGone(PLAYLIST_CONFIGURATION_TAG)
+ waitUntilTagExists(OVERVIEW_TAG)
+ } finally {
+ cleanupPlaylistFixture(playlistUrl, fixture)
+ }
+ }
+
+ @Test
+ fun blankConfigurationTitleCannotBeSaved() {
+ val fixture = createM3uFixture("blank-title")
+ val playlistUrl = Uri.fromFile(fixture).toString()
+
+ try {
+ removePlaylistIfPresent(playlistUrl)
+ runBlocking {
+ playlistRepository.m3uOrThrow(
+ title = TITLE_VALIDATION_PLAYLIST_TITLE,
+ url = playlistUrl,
+ )
+ }
+
+ openPlaylistManagementOverview()
+ val playlistItemTag =
+ "playlist-management-item:${playlistWorkTag(playlistUrl)}"
+ waitUntilTagExists(playlistItemTag)
+ composeRule.onNodeWithTag(playlistItemTag)
+ .performScrollTo()
+ .performClick()
+ waitUntilTagExists(PLAYLIST_CONFIGURATION_TAG)
+
+ composeRule.onNodeWithTag(PLAYLIST_CONFIGURATION_TITLE_TAG)
+ .performTextClearance()
+ assertEditorError(string.feat_setting_error_empty_title)
+ composeRule.onNodeWithTag(PLAYLIST_CONFIGURATION_SAVE_TAG)
+ .performScrollTo()
+ .assertIsNotEnabled()
+ assertEquals(
+ TITLE_VALIDATION_PLAYLIST_TITLE,
+ runBlocking { playlistRepository.get(playlistUrl) }?.title,
+ )
+ } finally {
+ cleanupPlaylistFixture(playlistUrl, fixture)
+ }
+ }
+
+ @Test
+ fun emptyM3uSubmissionShowsErrorsAndStaysOnEditor() {
+ openM3uEditor()
+ editorField(string.feat_setting_placeholder_title)
+ .performTextClearance()
+ editorField(string.feat_setting_placeholder_url)
+ .performTextClearance()
+
+ composeRule.onNodeWithTag(SUBMIT_ACTION_TAG).run {
+ performScrollTo()
+ assertHasClickAction()
+ performClick()
+ }
+
+ waitUntilTagExists(M3U_EDITOR_TAG)
+ composeRule.onNodeWithTag(M3U_EDITOR_TAG).assertIsDisplayed()
+ assertEditorError(string.feat_setting_error_empty_title)
+ assertEditorError(string.feat_setting_error_blank_url)
+ assertTrue(
+ "An invalid M3U submission must not leave the editor",
+ !tagExists(OVERVIEW_TAG) && !tagExists(SOURCE_PICKER_TAG),
+ )
+ }
+
+ @Test
+ fun reopeningM3uEditorStartsWithAFreshDraft() {
+ openM3uEditor()
+ editorField(string.feat_setting_placeholder_title)
+ .performTextReplacement("Discarded draft")
+ editorField(string.feat_setting_placeholder_url)
+ .performTextReplacement("https://example.invalid/discarded.m3u")
+
+ hideIme()
+ device.pressBack()
+ waitUntilTagExists(SOURCE_PICKER_TAG)
+ composeRule.onNodeWithTag(M3U_SOURCE_TAG)
+ .performScrollTo()
+ .performClick()
+ waitUntilTagExists(M3U_EDITOR_TAG)
+
+ editorField(string.feat_setting_placeholder_title)
+ .assert(
+ SemanticsMatcher.expectValue(
+ SemanticsProperties.EditableText,
+ AnnotatedString(""),
+ )
+ )
+ editorField(string.feat_setting_placeholder_url)
+ .assert(
+ SemanticsMatcher.expectValue(
+ SemanticsProperties.EditableText,
+ AnnotatedString(""),
+ )
+ )
+ }
+
+ @Test
+ fun acceptedM3uSubmissionReturnsToPlaylistManagementOverview() {
+ grantNotificationPermissionIfNeeded()
+ val fixture = createM3uFixture("accepted")
+ val playlistUrl = Uri.fromFile(fixture).toString()
+
+ try {
+ cancelPlaylistWork(playlistUrl)
+ removePlaylistIfPresent(playlistUrl)
+ openM3uEditor()
+ editorField(string.feat_setting_placeholder_title)
+ .performTextReplacement(ACCEPTED_PLAYLIST_TITLE)
+ editorField(string.feat_setting_placeholder_url)
+ .performTextReplacement(playlistUrl)
+
+ composeRule.onNodeWithTag(SUBMIT_ACTION_TAG).run {
+ performScrollTo()
+ assertHasClickAction()
+ performClick()
+ }
+
+ waitForPlaylistWorkRegistration(playlistUrl)
+ waitUntilTagExists(OVERVIEW_TAG)
+ waitUntilTagGone(M3U_EDITOR_TAG)
+ waitUntilTagGone(SOURCE_PICKER_TAG)
+ composeRule.onNodeWithTag(OVERVIEW_TAG).assertIsDisplayed()
+ waitUntilMatcherExists(
+ hasText(
+ ACCEPTED_PLAYLIST_TITLE,
+ substring = false,
+ ignoreCase = false,
+ ) or hasTestTag(UPDATE_IN_PROGRESS_TAG)
+ )
+ } finally {
+ cleanupPlaylistFixture(playlistUrl, fixture)
+ }
+ }
+
+ @Test
+ fun removingPlaylistRequiresConfirmationAndReturnsToManagement() {
+ val fixture = createM3uFixture("remove")
+ val playlistUrl = Uri.fromFile(fixture).toString()
+
+ try {
+ removePlaylistIfPresent(playlistUrl)
+ runBlocking {
+ playlistRepository.m3uOrThrow(
+ title = REMOVABLE_PLAYLIST_TITLE,
+ url = playlistUrl,
+ )
+ }
+
+ openPlaylistManagementOverview()
+ val playlistItemTag =
+ "playlist-management-item:${playlistWorkTag(playlistUrl)}"
+ waitUntilTagExists(playlistItemTag)
+ composeRule.onNodeWithTag(playlistItemTag)
+ .performScrollTo()
+ .performClick()
+ waitUntilTagExists(PLAYLIST_CONFIGURATION_TAG)
+
+ composeRule.onNodeWithTag(REMOVE_PLAYLIST_TAG).run {
+ performScrollTo()
+ assertHasClickAction()
+ performClick()
+ }
+ waitUntilTagExists(REMOVE_PLAYLIST_DIALOG_TAG)
+ assertTrue(
+ "Opening the confirmation dialog must not remove the playlist",
+ runBlocking { playlistRepository.get(playlistUrl) } != null,
+ )
+
+ composeRule.onNodeWithTag(REMOVE_PLAYLIST_CONFIRM_TAG)
+ .assertHasClickAction()
+ .performClick()
+
+ waitUntilTagGone(PLAYLIST_CONFIGURATION_TAG)
+ waitUntilTagExists(OVERVIEW_TAG)
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ runBlocking { playlistRepository.get(playlistUrl) } == null
+ }
+ assertTrue(
+ "The removed playlist must disappear from management",
+ !tagExists(playlistItemTag),
+ )
+ } finally {
+ cleanupPlaylistFixture(playlistUrl, fixture)
+ }
+ }
+
+ @Test
+ fun wideTabletSettingsListReturnsPlaylistEditorToManagementRoot() {
+ val configuration = composeRule.activity.resources.configuration
+ assertEquals(
+ MATRIX_CASE_WIDE_LTR,
+ requestedAccessibilityMatrixCase(),
+ )
+ assertEquals(
+ "The tablet settings-list test must be LTR",
+ View.LAYOUT_DIRECTION_LTR,
+ configuration.layoutDirection,
+ )
+ assertTrue(
+ "The settings list must remain visible beside the detail pane; " +
+ "actual width=${configuration.screenWidthDp}dp",
+ configuration.screenWidthDp >= WIDE_LIST_DETAIL_MINIMUM_DP,
+ )
+
+ openM3uEditor()
+ composeRule.onNode(playlistManagementEntryMatcher()).run {
+ assertIsDisplayed()
+ assertIsSelected()
+ performClick()
+ }
+
+ waitUntilTagExists(OVERVIEW_TAG)
+ waitUntilTagGone(M3U_EDITOR_TAG)
+ waitUntilTagGone(SOURCE_PICKER_TAG)
+ composeRule.onNodeWithTag(OVERVIEW_TAG).assertIsDisplayed()
+ }
+
+ @Test
+ fun wideTabletKeepsPlaylistConfigurationInsideSettingsContext() {
+ val configuration = composeRule.activity.resources.configuration
+ assertEquals(MATRIX_CASE_WIDE_LTR, requestedAccessibilityMatrixCase())
+ assertTrue(configuration.screenWidthDp >= WIDE_LIST_DETAIL_MINIMUM_DP)
+ val fixture = createM3uFixture("wide-configuration")
+ val playlistUrl = Uri.fromFile(fixture).toString()
+
+ try {
+ removePlaylistIfPresent(playlistUrl)
+ runBlocking {
+ playlistRepository.m3uOrThrow(
+ title = WIDE_PLAYLIST_TITLE,
+ url = playlistUrl,
+ )
+ }
+
+ openPlaylistManagementOverview()
+ val playlistItemTag =
+ "playlist-management-item:${playlistWorkTag(playlistUrl)}"
+ waitUntilTagExists(playlistItemTag)
+ composeRule.onNodeWithTag(playlistItemTag)
+ .performScrollTo()
+ .performClick()
+ waitUntilTagExists(PLAYLIST_CONFIGURATION_TAG)
+
+ composeRule.onNode(playlistManagementEntryMatcher())
+ .assertIsDisplayed()
+ .assertIsSelected()
+ .performClick()
+ waitUntilTagGone(PLAYLIST_CONFIGURATION_TAG)
+ waitUntilTagExists(OVERVIEW_TAG)
+ } finally {
+ cleanupPlaylistFixture(playlistUrl, fixture)
+ }
+ }
+
+ private fun openPlaylistManagementOverview() {
+ if (tagExists(OVERVIEW_TAG)) return
+
+ val playlistManagement = playlistManagementEntryMatcher()
+ val settingsDestination = hasContentDescription(
+ context.getString(string.ui_destination_setting),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+ waitUntilMatcherExists(playlistManagement or settingsDestination)
+ if (composeRule.onAllNodes(playlistManagement).fetchSemanticsNodes().isEmpty()) {
+ composeRule.onNode(settingsDestination).performClick()
+ }
+ waitUntilMatcherExists(playlistManagement)
+ composeRule.onNode(playlistManagement).performClick()
+ waitUntilTagExists(OVERVIEW_TAG)
+ }
+
+ private fun openM3uEditor() {
+ openPlaylistManagementOverview()
+ composeRule.onNodeWithTag(ADD_ACTION_TAG).run {
+ performScrollTo()
+ performClick()
+ }
+ waitUntilTagExists(SOURCE_PICKER_TAG)
+ composeRule.onNodeWithTag(M3U_SOURCE_TAG).run {
+ performScrollTo()
+ assertHasClickAction()
+ performClick()
+ }
+ waitUntilTagExists(M3U_EDITOR_TAG)
+ }
+
+ private fun editorField(@StringRes labelResource: Int): SemanticsNodeInteraction {
+ val matcher = hasSetTextAction() and hasText(
+ context.getString(labelResource),
+ substring = false,
+ ignoreCase = true,
+ )
+ waitUntilMatcherExists(matcher)
+ return composeRule.onNode(matcher)
+ }
+
+ private fun assertEditorError(@StringRes errorResource: Int) {
+ val matcher = hasText(
+ context.getString(errorResource),
+ substring = false,
+ ignoreCase = false,
+ )
+ waitUntilMatcherExists(matcher, useUnmergedTree = true)
+ composeRule.onNode(matcher, useUnmergedTree = true)
+ .performScrollTo()
+ .assertIsDisplayed()
+ }
+
+ private fun playlistManagementEntryMatcher(): SemanticsMatcher =
+ hasText(
+ context.getString(string.feat_setting_playlist_management),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+
+ private fun createM3uFixture(suffix: String): File =
+ File(
+ context.cacheDir,
+ "playlist-management-$suffix-${System.nanoTime()}.m3u",
+ ).apply {
+ writeText(
+ """
+ #EXTM3U
+ #EXTINF:-1 tvg-id="playlist.management.test",Test channel
+ https://example.invalid/testing/stream.m3u8
+ """.trimIndent() + "\n"
+ )
+ }
+
+ private fun grantNotificationPermissionIfNeeded() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ instrumentation.uiAutomation.grantRuntimePermission(
+ context.packageName,
+ Manifest.permission.POST_NOTIFICATIONS,
+ )
+ }
+ }
+
+ private fun hideIme() {
+ fun imeBottom(): Int {
+ var bottom = 0
+ composeRule.runOnIdle {
+ bottom = ViewCompat.getRootWindowInsets(
+ composeRule.activity.window.decorView
+ )
+ ?.getInsets(WindowInsetsCompat.Type.ime())
+ ?.bottom
+ ?: 0
+ }
+ return bottom
+ }
+
+ composeRule.waitForIdle()
+ composeRule.runOnIdle {
+ WindowCompat.getInsetsController(
+ composeRule.activity.window,
+ composeRule.activity.window.decorView,
+ ).hide(WindowInsetsCompat.Type.ime())
+ }
+ val deadlineMillis =
+ SystemClock.uptimeMillis() + IME_DISMISS_TIMEOUT_MILLIS
+ var stableSamples = 0
+ do {
+ stableSamples = if (imeBottom() == 0) {
+ stableSamples + 1
+ } else {
+ 0
+ }
+ if (stableSamples >= IME_HIDDEN_STABLE_SAMPLE_COUNT) return
+ SystemClock.sleep(WORK_QUIESCENCE_POLL_MILLIS)
+ } while (SystemClock.uptimeMillis() < deadlineMillis)
+ throw AssertionError("IME remained visible before navigating back")
+ }
+
+ private fun waitForPlaylistWorkRegistration(playlistUrl: String) {
+ val workManager = WorkManager.getInstance(context)
+ val workTag = playlistWorkTag(playlistUrl)
+ val deadlineNanos = System.nanoTime() +
+ TimeUnit.SECONDS.toNanos(WORK_QUIESCENCE_TIMEOUT_SECONDS)
+ do {
+ val workRegistered = workManager
+ .getWorkInfosByTag(workTag)
+ .get(WORK_OPERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .isNotEmpty()
+ if (workRegistered) return
+ Thread.sleep(WORK_QUIESCENCE_POLL_MILLIS)
+ } while (System.nanoTime() < deadlineNanos)
+ throw AssertionError("Playlist work was not registered: $workTag")
+ }
+
+ private fun cancelPlaylistWork(playlistUrl: String) {
+ val workManager = WorkManager.getInstance(context)
+ val workTag = playlistWorkTag(playlistUrl)
+ workManager
+ .cancelAllWorkByTag(workTag)
+ .result
+ .get(WORK_OPERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+
+ val deadlineNanos = System.nanoTime() +
+ TimeUnit.SECONDS.toNanos(WORK_QUIESCENCE_TIMEOUT_SECONDS)
+ do {
+ val allWorkFinished = workManager
+ .getWorkInfosByTag(workTag)
+ .get(WORK_OPERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .all { workInfo -> workInfo.state.isFinished }
+ if (allWorkFinished) return
+ Thread.sleep(WORK_QUIESCENCE_POLL_MILLIS)
+ } while (System.nanoTime() < deadlineNanos)
+
+ throw AssertionError(
+ "Playlist work did not reach a terminal state during test cleanup: $workTag"
+ )
+ }
+
+ private fun removePlaylistIfPresent(playlistUrl: String) {
+ runBlocking {
+ playlistRepository.get(playlistUrl)?.let {
+ playlistRepository.unsubscribe(playlistUrl)
+ }
+ }
+ }
+
+ private fun cleanupPlaylistFixture(
+ playlistUrl: String,
+ fixture: File,
+ ) {
+ val failures = buildList {
+ runCatching { cancelPlaylistWork(playlistUrl) }
+ .exceptionOrNull()
+ ?.let(::add)
+ runCatching { removePlaylistIfPresent(playlistUrl) }
+ .exceptionOrNull()
+ ?.let(::add)
+ if (fixture.exists() && !fixture.delete()) {
+ add(IllegalStateException("Unable to delete M3U fixture: $fixture"))
+ }
+ }
+ if (failures.isNotEmpty()) {
+ throw AssertionError(
+ "Failed to clean playlist-management test state",
+ failures.first(),
+ ).also { error ->
+ failures.drop(1).forEach(error::addSuppressed)
+ }
+ }
+ }
+
+ private fun requestedAccessibilityMatrixCase(): String =
+ InstrumentationRegistry.getArguments()
+ .getString(ARG_ACCESSIBILITY_MATRIX_CASE)
+ ?: error(
+ "Missing required instrumentation argument " +
+ "$ARG_ACCESSIBILITY_MATRIX_CASE.",
+ )
+
+ private fun waitUntilTagExists(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) { tagExists(tag) }
+ }
+
+ private fun waitUntilTagGone(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) { !tagExists(tag) }
+ }
+
+ private fun waitUntilMatcherExists(
+ matcher: SemanticsMatcher,
+ useUnmergedTree: Boolean = false,
+ ) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodes(
+ matcher,
+ useUnmergedTree = useUnmergedTree,
+ ).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun tagExists(tag: String): Boolean =
+ composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
+
+ private companion object {
+ const val UI_TIMEOUT_MILLIS = 15_000L
+ const val IME_DISMISS_TIMEOUT_MILLIS = 5_000L
+ const val IME_HIDDEN_STABLE_SAMPLE_COUNT = 3
+ const val WORK_OPERATION_TIMEOUT_SECONDS = 5L
+ const val WORK_QUIESCENCE_TIMEOUT_SECONDS = 10L
+ const val WORK_QUIESCENCE_POLL_MILLIS = 50L
+ const val ARG_ACCESSIBILITY_MATRIX_CASE = "accessibilityMatrixCase"
+ const val MATRIX_CASE_WIDE_LTR = "wide-ltr"
+ const val WIDE_LIST_DETAIL_MINIMUM_DP = 840
+
+ const val EXISTING_PLAYLIST_TITLE = "Existing playlist navigation"
+ const val ACCEPTED_PLAYLIST_TITLE = "Accepted playlist navigation"
+ const val REMOVABLE_PLAYLIST_TITLE = "Removable playlist navigation"
+ const val TITLE_VALIDATION_PLAYLIST_TITLE = "Protected playlist title"
+ const val WIDE_PLAYLIST_TITLE = "Wide playlist navigation"
+ const val OVERVIEW_TAG = "playlist-management-overview"
+ const val ADD_ACTION_TAG = "playlist-add-action"
+ const val SOURCE_PICKER_TAG = "playlist-source-picker"
+ const val M3U_SOURCE_TAG = "playlist-source:data-source:m3u"
+ const val M3U_EDITOR_TAG = "playlist-editor:data-source:m3u"
+ const val SUBMIT_ACTION_TAG = "subscription-submit-action"
+ const val PLAYLIST_CONFIGURATION_TAG = "playlist-configuration"
+ const val PLAYLIST_CONFIGURATION_TITLE_TAG =
+ "playlist-configuration-title"
+ const val PLAYLIST_CONFIGURATION_SAVE_TAG =
+ "playlist-configuration-save"
+ const val UPDATE_IN_PROGRESS_TAG =
+ "playlist-management-update-in-progress"
+ const val REMOVE_PLAYLIST_TAG = "playlist-configuration-remove"
+ const val REMOVE_PLAYLIST_DIALOG_TAG =
+ "playlist-configuration-remove-dialog"
+ const val REMOVE_PLAYLIST_CONFIRM_TAG =
+ "playlist-configuration-remove-confirm"
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/SubscriptionContentPaddingTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/SubscriptionContentPaddingTest.kt
new file mode 100644
index 000000000..144bd4841
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/SubscriptionContentPaddingTest.kt
@@ -0,0 +1,110 @@
+package com.m3u.testing
+
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollToNode
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeUp
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.MainActivity
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+class SubscriptionContentPaddingTest {
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ @Test
+ fun overviewRestoreActionCanScrollAboveTheSystemSafeArea() {
+ openPlaylistManagementOverview()
+
+ val restoreAction = hasClickAction() and hasTestTag(RESTORE_ACTION_TAG)
+ val content = composeRule.onNodeWithTag(OVERVIEW_TAG)
+ content.performScrollToNode(restoreAction)
+ content.performTouchInput { swipeUp() }
+ composeRule.waitForIdle()
+
+ val actionBounds = composeRule.onNodeWithTag(RESTORE_ACTION_TAG)
+ .fetchSemanticsNode()
+ .boundsInWindow
+ val rootBottom = composeRule.onRoot().fetchSemanticsNode().boundsInWindow.bottom
+ val safeBottom = composeRule.runOnIdle {
+ val activity = composeRule.activity
+ val inset = ViewCompat.getRootWindowInsets(activity.window.decorView)
+ ?.getInsets(
+ WindowInsetsCompat.Type.systemBars() or
+ WindowInsetsCompat.Type.displayCutout()
+ )
+ ?.bottom
+ ?: 0
+ val gap = MINIMUM_PROVIDER_BOTTOM_GAP_DP *
+ activity.resources.displayMetrics.density
+ rootBottom - inset - gap
+ }
+
+ assertTrue(
+ "The playlist restore action remains behind the system safe area: " +
+ "action=$actionBounds, safeBottom=$safeBottom",
+ actionBounds.bottom <= safeBottom,
+ )
+ }
+
+ private fun openPlaylistManagementOverview() {
+ val playlistManagement =
+ composeRule.activity.getString(string.feat_setting_playlist_management)
+ waitUntilExists(
+ hasText(playlistManagement, substring = false, ignoreCase = true) or
+ hasContentDescription(
+ composeRule.activity.getString(string.ui_destination_setting),
+ substring = false,
+ ignoreCase = true,
+ )
+ )
+ if (
+ composeRule.onAllNodes(
+ hasText(playlistManagement, substring = false, ignoreCase = true) and
+ hasClickAction()
+ ).fetchSemanticsNodes().isEmpty()
+ ) {
+ composeRule.onNode(
+ hasContentDescription(
+ composeRule.activity.getString(string.ui_destination_setting),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+ ).performClick()
+ }
+ waitUntilExists(
+ hasText(playlistManagement, substring = false, ignoreCase = true) and
+ hasClickAction()
+ )
+ composeRule.onNode(
+ hasText(playlistManagement, substring = false, ignoreCase = true) and
+ hasClickAction()
+ ).performClick()
+ waitUntilExists(hasTestTag(OVERVIEW_TAG))
+ }
+
+ private fun waitUntilExists(matcher: SemanticsMatcher) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private companion object {
+ const val UI_TIMEOUT_MILLIS = 5_000L
+ const val MINIMUM_PROVIDER_BOTTOM_GAP_DP = 12
+ const val OVERVIEW_TAG = "playlist-management-overview"
+ const val RESTORE_ACTION_TAG = "playlist-restore-action"
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/SubscriptionSourceSelectionTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/SubscriptionSourceSelectionTest.kt
new file mode 100644
index 000000000..20b327994
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/SubscriptionSourceSelectionTest.kt
@@ -0,0 +1,705 @@
+package com.m3u.testing
+
+import android.content.res.Configuration
+import android.os.SystemClock
+import android.view.View
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assertHasClickAction
+import androidx.compose.ui.test.assertTextContains
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performScrollToNode
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.BySelector
+import androidx.test.uiautomator.StaleObjectException
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.UiObject2
+import androidx.test.uiautomator.Until
+import com.m3u.extension.api.subscription.SubscriptionProviderSettingKeys
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.MainActivity
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.regex.Pattern
+import kotlin.math.abs
+
+class SubscriptionSourceSelectionTest {
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val context = instrumentation.targetContext
+ private val device = UiDevice.getInstance(instrumentation)
+
+ @Test
+ fun embyAndJellyfinCanBeSelectedAcrossTheFullSourceRow() {
+ openSourcePicker()
+
+ clickSourceAcrossFullRow(JELLYFIN_SOURCE_KEY)
+ assertProviderFieldsVisible(JELLYFIN_SOURCE_KEY)
+
+ device.pressBack()
+ waitUntilTagExists(SOURCE_PICKER_TAG)
+ clickSourceAcrossFullRow(EMBY_SOURCE_KEY)
+ assertProviderFieldsVisible(EMBY_SOURCE_KEY)
+ }
+
+ @Test
+ fun builtInProviderVariantLoadsItsDescriptorFormDirectly() {
+ openSourcePicker()
+
+ clickSourceAcrossFullRow(EMBY_SOURCE_KEY)
+
+ waitUntilTagExists(editorTag(EMBY_SOURCE_KEY))
+ assertProviderFieldsVisible(EMBY_SOURCE_KEY)
+ }
+
+ @Test
+ fun providerFormWorksInRequestedAccessibilityConfiguration() {
+ val configuration = composeRule.runOnIdle {
+ Configuration(composeRule.activity.resources.configuration)
+ }
+ val matrixCase = requestedAccessibilityMatrixCase()
+ assertRequestedAccessibilityConfiguration(configuration, matrixCase)
+
+ openSourcePicker()
+ assertSourceRowAccessibility(
+ sourceKey = M3U_SOURCE_KEY,
+ labelResId = string.feat_setting_data_source_m3u,
+ )
+ assertSourceRowAccessibility(
+ sourceKey = JELLYFIN_SOURCE_KEY,
+ labelResId = string.feat_setting_data_source_jellyfin,
+ )
+ clickSourceAcrossFullRow(JELLYFIN_SOURCE_KEY)
+ assertProviderFieldEdgesAligned(JELLYFIN_SOURCE_KEY)
+ if (matrixCase == MATRIX_CASE_WIDE_LTR) {
+ assertWideSettingPanesAreArrangedSideBySide(JELLYFIN_SOURCE_KEY)
+ }
+ }
+
+ @Test
+ fun sourceRowsExposeLocalizedNamesAndButtonRolesAndCanNavigateBack() {
+ openSourcePicker()
+
+ assertSourceRowAccessibility(
+ sourceKey = M3U_SOURCE_KEY,
+ labelResId = string.feat_setting_data_source_m3u,
+ )
+ composeRule.onNodeWithTag(sourceTag(M3U_SOURCE_KEY)).performClick()
+ waitUntilTagExists(editorTag(M3U_SOURCE_KEY))
+
+ device.pressBack()
+ waitUntilTagExists(SOURCE_PICKER_TAG)
+ assertSourceRowAccessibility(
+ sourceKey = M3U_SOURCE_KEY,
+ labelResId = string.feat_setting_data_source_m3u,
+ )
+ }
+
+ @Test
+ fun overviewSourcePickerAndEditorBackStackRestoreEachLevel() {
+ openSourcePicker()
+ scrollSourcePickerTo(sourceTag(JELLYFIN_SOURCE_KEY))
+ clickSourceAcrossFullRow(JELLYFIN_SOURCE_KEY)
+
+ device.pressBack()
+ waitUntilTagExists(SOURCE_PICKER_TAG)
+ composeRule.onNodeWithTag(sourceTag(JELLYFIN_SOURCE_KEY))
+ .assertHasClickAction()
+
+ device.pressBack()
+ waitUntilTagExists(OVERVIEW_TAG)
+ composeRule.onNodeWithTag(ADD_ACTION_TAG).assertHasClickAction()
+
+ device.pressBack()
+ waitUntilTagGone(OVERVIEW_TAG)
+ device.clickRequiredObject(
+ By.text(
+ caseInsensitive(
+ context.getString(string.feat_setting_playlist_management)
+ )
+ )
+ )
+ waitUntilTagExists(OVERVIEW_TAG)
+ composeRule.onNodeWithTag(ADD_ACTION_TAG).assertHasClickAction()
+ }
+
+ @Test
+ fun overviewDestinationsOpenDedicatedManagementLists() {
+ openPlaylistManagementOverview()
+
+ composeRule.onNodeWithTag(EPG_SOURCES_ACTION_TAG).run {
+ performScrollTo()
+ assertHasClickAction()
+ performClick()
+ }
+ waitUntilTagExists(EPG_SOURCES_LIST_TAG)
+ composeRule.onNodeWithTag(ADD_EPG_ACTION_TAG).run {
+ assertHasClickAction()
+ performClick()
+ }
+ waitUntilTagExists(editorTag(EPG_SOURCE_KEY))
+ device.pressBack()
+ waitUntilTagExists(EPG_SOURCES_LIST_TAG)
+ device.pressBack()
+ waitUntilTagExists(OVERVIEW_TAG)
+ assertOverviewDestination(
+ actionTag = HIDDEN_CHANNELS_ACTION_TAG,
+ destinationTag = HIDDEN_CHANNELS_LIST_TAG,
+ )
+ assertOverviewDestination(
+ actionTag = HIDDEN_CATEGORIES_ACTION_TAG,
+ destinationTag = HIDDEN_CATEGORIES_LIST_TAG,
+ )
+
+ composeRule.onNodeWithTag(BACKUP_ACTION_TAG).run {
+ performScrollTo()
+ assertHasClickAction()
+ }
+ composeRule.onNodeWithTag(RESTORE_ACTION_TAG).run {
+ performScrollTo()
+ assertHasClickAction()
+ }
+ }
+
+ @Test
+ fun jellyfinPasswordFieldIsBroughtAboveTheIme() {
+ openSourcePicker()
+ clickSourceAcrossFullRow(JELLYFIN_SOURCE_KEY)
+ val passwordField = findProviderField(
+ editorSourceKey = JELLYFIN_SOURCE_KEY,
+ fieldKey = SubscriptionProviderSettingKeys.Password,
+ labelResId = string.feat_setting_placeholder_password,
+ ).second
+ passwordField.click()
+
+ val imeBottom = waitForStableImeBottom()
+ device.waitForIdle()
+ SystemClock.sleep(IME_RELOCATION_SETTLE_MILLIS)
+ val focusedField = device.findRequiredObject(
+ By.clazz("android.widget.EditText").focused(true)
+ )
+ val imeTop = device.displayHeight - imeBottom
+
+ assertTrue(
+ "Focused password field ${focusedField.visibleBounds} overlaps IME top $imeTop",
+ focusedField.visibleBounds.bottom <= imeTop,
+ )
+
+ device.pressBack()
+ waitForImeHidden()
+ }
+
+ private fun openPlaylistManagementOverview() {
+ if (tagExists(OVERVIEW_TAG)) return
+
+ val settingsDestination = hasContentDescription(
+ context.getString(string.ui_destination_setting),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+ val playlistManagement = hasText(
+ context.getString(string.feat_setting_playlist_management),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+
+ waitUntilMatcherExists(
+ playlistManagement or settingsDestination,
+ )
+ if (composeRule.onAllNodes(playlistManagement).fetchSemanticsNodes().isEmpty()) {
+ composeRule.onNode(settingsDestination).performClick()
+ }
+ waitUntilMatcherExists(playlistManagement)
+ composeRule.onNode(playlistManagement).performClick()
+ waitUntilTagExists(OVERVIEW_TAG)
+ }
+
+ private fun openSourcePicker() {
+ openPlaylistManagementOverview()
+ composeRule.onNodeWithTag(ADD_ACTION_TAG).run {
+ performScrollTo()
+ performClick()
+ }
+ waitUntilTagExists(SOURCE_PICKER_TAG)
+ }
+
+ private fun assertOverviewDestination(
+ actionTag: String,
+ destinationTag: String,
+ ) {
+ composeRule.onNodeWithTag(actionTag).run {
+ performScrollTo()
+ assertHasClickAction()
+ performClick()
+ }
+ waitUntilTagExists(destinationTag)
+ device.pressBack()
+ waitUntilTagExists(OVERVIEW_TAG)
+ }
+
+ private fun clickSourceAcrossFullRow(sourceKey: String) {
+ val tag = sourceTag(sourceKey)
+ scrollSourcePickerTo(tag)
+ val row = composeRule.onNodeWithTag(tag)
+ row.assertHasClickAction()
+ val bounds = row.fetchSemanticsNode().boundsInWindow
+ val density = composeRule.activity.resources.displayMetrics.density
+ assertTrue(
+ "Source row $sourceKey must provide at least a 48dp touch target: $bounds",
+ bounds.height >= MINIMUM_TOUCH_TARGET_DP * density,
+ )
+
+ val isRtl =
+ context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
+ val edgeInset = FULL_ROW_CLICK_INSET_DP * density
+ val x = if (isRtl) bounds.left + edgeInset else bounds.right - edgeInset
+ assertTrue(
+ "Could not click the logical end of source row $sourceKey at $bounds",
+ device.click(x.toInt(), bounds.center.y.toInt()),
+ )
+ waitUntilTagExists(editorTag(sourceKey))
+ composeRule.waitForIdle()
+ }
+
+ private fun assertSourceRowAccessibility(
+ sourceKey: String,
+ labelResId: Int,
+ ) {
+ val tag = sourceTag(sourceKey)
+ scrollSourcePickerTo(tag)
+ val node = composeRule.onNodeWithTag(tag)
+ node.assertHasClickAction()
+ val semanticsNode = node.fetchSemanticsNode()
+ assertEquals(
+ "Source row $sourceKey must expose a button role",
+ Role.Button,
+ semanticsNode.config[SemanticsProperties.Role],
+ )
+ val density = composeRule.activity.resources.displayMetrics.density
+ assertTrue(
+ "Source row $sourceKey has a touch target shorter than 48dp",
+ semanticsNode.boundsInWindow.height >= MINIMUM_TOUCH_TARGET_DP * density,
+ )
+
+ val localizedLabel = context.getString(labelResId).withoutBidiControls()
+ node.assertTextContains(
+ localizedLabel,
+ substring = true,
+ ignoreCase = true,
+ )
+ }
+
+ private fun scrollSourcePickerTo(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ runCatching {
+ composeRule.onNodeWithTag(SOURCE_PICKER_TAG)
+ .performScrollToNode(hasTestTag(tag))
+ }.isSuccess
+ }
+ waitUntilTagExists(tag)
+ composeRule.waitForIdle()
+ }
+
+ private fun assertProviderFieldsVisible(editorSourceKey: String) {
+ PROVIDER_FIELDS.forEach { (fieldKey, labelResId) ->
+ findProviderField(editorSourceKey, fieldKey, labelResId)
+ }
+ }
+
+ private fun assertProviderFieldEdgesAligned(editorSourceKey: String) {
+ val isRtl = context.resources.configuration.layoutDirection ==
+ View.LAYOUT_DIRECTION_RTL
+ PROVIDER_FIELDS.forEach { (fieldKey, labelResId) ->
+ val (labelNode, fieldNode) = findProviderField(
+ editorSourceKey = editorSourceKey,
+ fieldKey = fieldKey,
+ labelResId = labelResId,
+ )
+ val labelEdge = if (isRtl) {
+ labelNode.visibleBounds.right
+ } else {
+ labelNode.visibleBounds.left
+ }
+ val fieldEdge = if (isRtl) {
+ fieldNode.visibleBounds.right
+ } else {
+ fieldNode.visibleBounds.left
+ }
+ assertTrue(
+ "Provider label and field are not aligned for " +
+ "${context.getString(labelResId)}: " +
+ "label=${labelNode.visibleBounds}, field=${fieldNode.visibleBounds}",
+ abs(labelEdge - fieldEdge) <= FIELD_EDGE_TOLERANCE_PX,
+ )
+ }
+ }
+
+ private fun findProviderField(
+ editorSourceKey: String,
+ fieldKey: String,
+ labelResId: Int,
+ ): Pair {
+ val label = context.getString(labelResId).withoutBidiControls()
+ val fieldTag = providerFieldTag(fieldKey)
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ runCatching {
+ composeRule.onNodeWithTag(editorTag(editorSourceKey))
+ .performScrollToNode(hasTestTag(fieldTag))
+ }.isSuccess
+ }
+ waitUntilTagExists(fieldTag)
+ composeRule.onNodeWithTag(fieldTag).performScrollTo()
+ composeRule.waitForIdle()
+ device.waitForIdle()
+ val deadline = SystemClock.uptimeMillis() + UI_TIMEOUT_MILLIS
+ while (SystemClock.uptimeMillis() < deadline) {
+ val labelNode = device.findObjects(
+ By.text(caseInsensitiveContaining(label))
+ ).firstOrNull { node ->
+ runCatching {
+ node.className == "android.widget.TextView"
+ }.getOrDefault(false)
+ }
+ val fieldNode = runCatching {
+ device.findObject(
+ By.desc(caseInsensitiveContaining(label))
+ )?.ancestorOfClass("android.widget.EditText")
+ }.getOrNull()
+ if (labelNode != null && fieldNode != null) {
+ val nodesAreStable = runCatching {
+ !labelNode.visibleBounds.isEmpty &&
+ !fieldNode.visibleBounds.isEmpty
+ }.getOrDefault(false)
+ if (nodesAreStable) {
+ return labelNode to fieldNode
+ }
+ }
+ SystemClock.sleep(TAG_POLL_MILLIS)
+ }
+ error("Provider field was not exposed after scrolling to $fieldTag: $label")
+ }
+
+ private fun assertWideSettingPanesAreArrangedSideBySide(sourceKey: String) {
+ val editorBounds = composeRule.onNodeWithTag(editorTag(sourceKey))
+ .fetchSemanticsNode()
+ .boundsInWindow
+ val playlistLabel = context.getString(string.feat_setting_playlist_management)
+ val listPaneLabel = (
+ device.findObjects(By.text(caseInsensitive(playlistLabel))) +
+ device.findObjects(By.desc(caseInsensitive(playlistLabel)))
+ )
+ .firstOrNull { candidate ->
+ candidate.visibleBounds.right <=
+ editorBounds.left + BOUNDS_TOLERANCE_PX
+ }
+ ?: error(
+ "Wide settings list pane was not found beside provider detail: " +
+ "editor=$editorBounds",
+ )
+ assertTrue(
+ "Wide settings list and provider editor overlap: " +
+ "list=${listPaneLabel.visibleBounds}, editor=$editorBounds",
+ listPaneLabel.visibleBounds.right <=
+ editorBounds.left + BOUNDS_TOLERANCE_PX,
+ )
+ }
+
+ private fun waitForStableImeBottom(): Int {
+ val bottom = AtomicInteger()
+ val deadline = SystemClock.uptimeMillis() + UI_TIMEOUT_MILLIS
+ var lastBottom = 0
+ var stableSamples = 0
+ while (SystemClock.uptimeMillis() < deadline) {
+ composeRule.runOnIdle {
+ bottom.set(
+ ViewCompat.getRootWindowInsets(
+ composeRule.activity.window.decorView
+ )
+ ?.getInsets(WindowInsetsCompat.Type.ime())
+ ?.bottom
+ ?: 0
+ )
+ }
+ val currentBottom = bottom.get()
+ stableSamples = if (currentBottom > 0 && currentBottom == lastBottom) {
+ stableSamples + 1
+ } else {
+ 0
+ }
+ if (stableSamples >= IME_STABLE_SAMPLE_COUNT) {
+ return currentBottom
+ }
+ lastBottom = currentBottom
+ SystemClock.sleep(IME_INSET_POLL_MILLIS)
+ }
+ error("IME did not become visible and stable; last bottom inset=${bottom.get()}")
+ }
+
+ private fun waitForImeHidden() {
+ val bottom = AtomicInteger(Int.MAX_VALUE)
+ val deadline = SystemClock.uptimeMillis() + UI_TIMEOUT_MILLIS
+ while (SystemClock.uptimeMillis() < deadline) {
+ composeRule.runOnIdle {
+ bottom.set(
+ ViewCompat.getRootWindowInsets(
+ composeRule.activity.window.decorView
+ )
+ ?.getInsets(WindowInsetsCompat.Type.ime())
+ ?.bottom
+ ?: 0
+ )
+ }
+ if (bottom.get() == 0) return
+ SystemClock.sleep(IME_INSET_POLL_MILLIS)
+ }
+ error("IME remained visible after pressing Back; last bottom inset=${bottom.get()}")
+ }
+
+ private fun assertRequestedAccessibilityConfiguration(
+ configuration: Configuration,
+ matrixCase: String,
+ ) {
+ when (matrixCase) {
+ MATRIX_CASE_COMPACT_LTR,
+ MATRIX_CASE_COMPACT_NARROW_LTR -> {
+ assertLocale(configuration, LOCALE_ENGLISH, matrixCase)
+ assertLayoutDirection(configuration, View.LAYOUT_DIRECTION_LTR, matrixCase)
+ assertTrue(
+ "Expected normal text for $matrixCase, actual=${configuration.fontScale}",
+ configuration.fontScale < LARGE_TEXT_THRESHOLD,
+ )
+ assertTrue(
+ "Expected compact width for $matrixCase, " +
+ "actual=${configuration.screenWidthDp}",
+ configuration.screenWidthDp < WIDE_WINDOW_MINIMUM_DP,
+ )
+ if (matrixCase == MATRIX_CASE_COMPACT_NARROW_LTR) {
+ assertTrue(
+ "Expected a 320dp narrow window, " +
+ "actual=${configuration.screenWidthDp}",
+ configuration.screenWidthDp in NARROW_WIDTH_RANGE,
+ )
+ }
+ }
+
+ MATRIX_CASE_COMPACT_RTL_LARGE -> {
+ assertLocale(configuration, LOCALE_RTL_PSEUDO, matrixCase)
+ assertLayoutDirection(configuration, View.LAYOUT_DIRECTION_RTL, matrixCase)
+ assertTrue(
+ "Expected 200% text for $matrixCase, actual=${configuration.fontScale}",
+ configuration.fontScale >= LARGE_TEXT_MINIMUM_SCALE,
+ )
+ assertTrue(
+ "Expected compact width for $matrixCase, " +
+ "actual=${configuration.screenWidthDp}",
+ configuration.screenWidthDp < WIDE_WINDOW_MINIMUM_DP,
+ )
+ }
+
+ MATRIX_CASE_WIDE_LTR -> {
+ assertLocale(configuration, LOCALE_ENGLISH, matrixCase)
+ assertLayoutDirection(configuration, View.LAYOUT_DIRECTION_LTR, matrixCase)
+ assertTrue(
+ "Expected normal text for $matrixCase, actual=${configuration.fontScale}",
+ configuration.fontScale < LARGE_TEXT_THRESHOLD,
+ )
+ assertTrue(
+ "Expected wide width for $matrixCase, " +
+ "actual=${configuration.screenWidthDp}",
+ configuration.screenWidthDp >= WIDE_WINDOW_MINIMUM_DP,
+ )
+ }
+
+ else -> error(
+ "Unknown $ARG_ACCESSIBILITY_MATRIX_CASE=$matrixCase. " +
+ "Expected $MATRIX_CASE_COMPACT_LTR, " +
+ "$MATRIX_CASE_COMPACT_NARROW_LTR, " +
+ "$MATRIX_CASE_COMPACT_RTL_LARGE, or $MATRIX_CASE_WIDE_LTR.",
+ )
+ }
+ }
+
+ private fun requestedAccessibilityMatrixCase(): String =
+ InstrumentationRegistry.getArguments()
+ .getString(ARG_ACCESSIBILITY_MATRIX_CASE)
+ ?: error(
+ "Missing required instrumentation argument " +
+ "$ARG_ACCESSIBILITY_MATRIX_CASE. Run " +
+ "testing/bin/run-smartphone-provider-ui-matrix.sh.",
+ )
+
+ private fun assertLocale(
+ configuration: Configuration,
+ expected: String,
+ matrixCase: String,
+ ) {
+ assertEquals(
+ "Unexpected app locale for $matrixCase",
+ expected,
+ configuration.locales[0].toLanguageTag(),
+ )
+ }
+
+ private fun assertLayoutDirection(
+ configuration: Configuration,
+ expected: Int,
+ matrixCase: String,
+ ) {
+ assertEquals(
+ "Unexpected layout direction for $matrixCase",
+ expected,
+ configuration.layoutDirection,
+ )
+ }
+
+ private fun waitUntilTagExists(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) { tagExists(tag) }
+ }
+
+ private fun waitUntilTagGone(tag: String) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) { !tagExists(tag) }
+ }
+
+ private fun waitUntilMatcherExists(matcher: SemanticsMatcher) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun tagExists(tag: String): Boolean =
+ composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
+
+ private fun UiDevice.findRequiredObject(selector: BySelector): UiObject2 =
+ wait(Until.findObject(selector), UI_TIMEOUT_MILLIS)
+ ?: error("Required UI object was not found: $selector")
+
+ private fun UiDevice.clickRequiredObject(selector: BySelector) {
+ val deadline = SystemClock.uptimeMillis() + UI_TIMEOUT_MILLIS
+ var lastFailure: StaleObjectException? = null
+ while (SystemClock.uptimeMillis() < deadline) {
+ val target = findObject(selector)
+ if (target != null) {
+ try {
+ target.clickableAncestor().click()
+ return
+ } catch (failure: StaleObjectException) {
+ lastFailure = failure
+ }
+ }
+ SystemClock.sleep(TAG_POLL_MILLIS)
+ }
+ throw AssertionError(
+ "Required UI object could not be clicked: $selector",
+ lastFailure,
+ )
+ }
+
+ private fun caseInsensitive(value: String): Pattern = Pattern.compile(
+ Pattern.quote(value),
+ Pattern.CASE_INSENSITIVE,
+ )
+
+ private fun caseInsensitiveContaining(value: String): Pattern = Pattern.compile(
+ ".*${Pattern.quote(value)}.*",
+ Pattern.CASE_INSENSITIVE,
+ )
+
+ private fun String.withoutBidiControls(): String = filterNot { char ->
+ char == '\u061C' ||
+ char == '\u200E' ||
+ char == '\u200F' ||
+ char in '\u202A'..'\u202E' ||
+ char in '\u2066'..'\u2069'
+ }
+
+ private fun UiObject2.clickableAncestor(): UiObject2 {
+ var current = this
+ while (!current.isClickable) {
+ current = current.parent ?: return this
+ }
+ return current
+ }
+
+ private fun UiObject2.ancestorOfClass(className: String): UiObject2 {
+ var current: UiObject2? = this
+ while (current != null) {
+ if (current.className == className) return current
+ current = current.parent
+ }
+ error("Object has no $className ancestor: $this")
+ }
+
+ private fun sourceTag(sourceKey: String): String = "playlist-source:$sourceKey"
+
+ private fun editorTag(sourceKey: String): String = "playlist-editor:$sourceKey"
+
+ private fun providerFieldTag(fieldKey: String): String = "provider-field:$fieldKey"
+
+ private companion object {
+ val PROVIDER_FIELDS = listOf(
+ SubscriptionProviderSettingKeys.BaseUrl to
+ string.feat_setting_placeholder_basic_url,
+ SubscriptionProviderSettingKeys.Username to
+ string.feat_setting_placeholder_username,
+ SubscriptionProviderSettingKeys.Password to
+ string.feat_setting_placeholder_password,
+ )
+
+ const val UI_TIMEOUT_MILLIS = 15_000L
+ const val TAG_POLL_MILLIS = 50L
+ const val MINIMUM_TOUCH_TARGET_DP = 48
+ const val FULL_ROW_CLICK_INSET_DP = 12
+ const val BOUNDS_TOLERANCE_PX = 2
+ const val FIELD_EDGE_TOLERANCE_PX = 2
+ const val IME_INSET_POLL_MILLIS = 50L
+ const val IME_STABLE_SAMPLE_COUNT = 3
+ const val IME_RELOCATION_SETTLE_MILLIS = 300L
+ const val ARG_ACCESSIBILITY_MATRIX_CASE = "accessibilityMatrixCase"
+ const val MATRIX_CASE_COMPACT_LTR = "compact-ltr"
+ const val MATRIX_CASE_COMPACT_NARROW_LTR = "compact-narrow-ltr"
+ const val MATRIX_CASE_COMPACT_RTL_LARGE = "compact-rtl-large"
+ const val MATRIX_CASE_WIDE_LTR = "wide-ltr"
+ const val LOCALE_ENGLISH = "en"
+ const val LOCALE_RTL_PSEUDO = "ar-XB"
+ const val LARGE_TEXT_MINIMUM_SCALE = 1.95f
+ const val LARGE_TEXT_THRESHOLD = 1.3f
+ const val WIDE_WINDOW_MINIMUM_DP = 600
+ val NARROW_WIDTH_RANGE = 315..325
+
+ const val OVERVIEW_TAG = "playlist-management-overview"
+ const val ADD_ACTION_TAG = "playlist-add-action"
+ const val SOURCE_PICKER_TAG = "playlist-source-picker"
+ const val EPG_SOURCES_ACTION_TAG = "playlist-overview-epg-sources"
+ const val HIDDEN_CHANNELS_ACTION_TAG = "playlist-overview-hidden-channels"
+ const val HIDDEN_CATEGORIES_ACTION_TAG =
+ "playlist-overview-hidden-categories"
+ const val EPG_SOURCES_LIST_TAG = "playlist-list:epg-sources"
+ const val ADD_EPG_ACTION_TAG = "playlist-add-epg-action"
+ const val HIDDEN_CHANNELS_LIST_TAG = "playlist-list:hidden-channels"
+ const val HIDDEN_CATEGORIES_LIST_TAG = "playlist-list:hidden-categories"
+ const val BACKUP_ACTION_TAG = "playlist-backup-action"
+ const val RESTORE_ACTION_TAG = "playlist-restore-action"
+ const val M3U_SOURCE_KEY = "data-source:m3u"
+ const val EPG_SOURCE_KEY = "data-source:epg"
+ const val PROVIDER_ID = "com.m3u.provider.emby-compatible"
+ const val JELLYFIN_SOURCE_KEY = "provider:$PROVIDER_ID:jellyfin"
+ const val EMBY_SOURCE_KEY = "provider:$PROVIDER_ID:emby"
+ }
+}
diff --git a/app/smartphone/src/androidTest/java/com/m3u/testing/ThemeSelectionAccessibilityTest.kt b/app/smartphone/src/androidTest/java/com/m3u/testing/ThemeSelectionAccessibilityTest.kt
new file mode 100644
index 000000000..390e50754
--- /dev/null
+++ b/app/smartphone/src/androidTest/java/com/m3u/testing/ThemeSelectionAccessibilityTest.kt
@@ -0,0 +1,223 @@
+package com.m3u.testing
+
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assertHasClickAction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollToNode
+import androidx.datastore.preferences.core.edit
+import androidx.test.platform.app.InstrumentationRegistry
+import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
+import com.m3u.core.foundation.architecture.preferences.ThemePreset
+import com.m3u.core.foundation.architecture.preferences.ThemeStyle
+import com.m3u.core.foundation.architecture.preferences.get
+import com.m3u.core.foundation.architecture.preferences.settings
+import com.m3u.core.foundation.architecture.preferences.themePreferences
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.MainActivity
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+class ThemeSelectionAccessibilityTest {
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
+
+ @Test
+ fun warmThemesExposeOneNamedRadioButtonWithoutAnUnavailableEditor() {
+ val previous = runBlocking {
+ ThemePreferences(
+ argb = context.settings[PreferencesKeys.COLOR_ARGB],
+ isDark = context.settings[PreferencesKeys.DARK_MODE],
+ style = context.settings[PreferencesKeys.THEME_STYLE],
+ presetId = context.settings[PreferencesKeys.THEME_PRESET_ID],
+ useDynamicColors = context.settings[PreferencesKeys.USE_DYNAMIC_COLORS],
+ followSystemTheme = context.settings[PreferencesKeys.FOLLOW_SYSTEM_THEME],
+ )
+ }
+ try {
+ runBlocking {
+ context.settings.edit { preferences ->
+ preferences[PreferencesKeys.COLOR_ARGB] =
+ ThemePreset.WARM_EDITORIAL_SEED
+ preferences[PreferencesKeys.DARK_MODE] = false
+ preferences[PreferencesKeys.THEME_STYLE] =
+ ThemeStyle.WARM_EDITORIAL
+ preferences[PreferencesKeys.THEME_PRESET_ID] =
+ ThemePreset.WARM_EDITORIAL
+ preferences[PreferencesKeys.USE_DYNAMIC_COLORS] = false
+ preferences[PreferencesKeys.FOLLOW_SYSTEM_THEME] = false
+ }
+ }
+
+ openAppearance()
+ val parchment = context.getString(string.feat_setting_theme_parchment)
+ val ink = context.getString(string.feat_setting_theme_ink)
+ scrollThemeListTo(ink)
+
+ assertThemeRadioButton(
+ name = parchment,
+ expectedSelected = true,
+ )
+ assertThemeRadioButton(
+ name = ink,
+ expectedSelected = false,
+ )
+
+ composeRule.onNode(
+ hasContentDescription(
+ ink,
+ substring = false,
+ ignoreCase = false,
+ )
+ ).performClick()
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ themeIsSelected(ink)
+ }
+ assertThemeRadioButton(
+ name = parchment,
+ expectedSelected = false,
+ )
+ assertThemeRadioButton(
+ name = ink,
+ expectedSelected = true,
+ )
+ assertTrue(
+ runBlocking {
+ context.settings.themePreferences().first()
+ .selection.isDark
+ }
+ )
+ } finally {
+ runBlocking {
+ context.settings.edit { preferences ->
+ preferences[PreferencesKeys.COLOR_ARGB] = previous.argb
+ preferences[PreferencesKeys.DARK_MODE] = previous.isDark
+ preferences[PreferencesKeys.THEME_STYLE] = previous.style
+ preferences[PreferencesKeys.THEME_PRESET_ID] =
+ previous.presetId
+ preferences[PreferencesKeys.USE_DYNAMIC_COLORS] =
+ previous.useDynamicColors
+ preferences[PreferencesKeys.FOLLOW_SYSTEM_THEME] =
+ previous.followSystemTheme
+ }
+ }
+ }
+ }
+
+ private fun assertThemeRadioButton(
+ name: String,
+ expectedSelected: Boolean,
+ ) {
+ val matcher = hasContentDescription(
+ name,
+ substring = false,
+ ignoreCase = false,
+ )
+ val item = composeRule.onNode(matcher)
+ .assertIsDisplayed()
+ .assertHasClickAction()
+ val semantics = item.fetchSemanticsNode().config
+
+ assertEquals(Role.RadioButton, semantics[SemanticsProperties.Role])
+ assertEquals(expectedSelected, semantics[SemanticsProperties.Selected])
+ assertFalse(
+ "$name is a built-in preset and must not advertise a color editor",
+ semantics.contains(SemanticsActions.OnLongClick),
+ )
+ val minimumSize = 48f * context.resources.displayMetrics.density
+ val size = item.fetchSemanticsNode().size
+ assertTrue("$name is narrower than 48dp", size.width >= minimumSize)
+ assertTrue("$name is shorter than 48dp", size.height >= minimumSize)
+ }
+
+ private fun openAppearance() {
+ if (tagExists(THEME_LIST_TAG)) return
+
+ val appearance = hasText(
+ context.getString(string.feat_setting_appearance),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+ val settings = hasContentDescription(
+ context.getString(string.ui_destination_setting),
+ substring = false,
+ ignoreCase = true,
+ ) and hasClickAction()
+
+ waitUntilMatcherExists(appearance or settings)
+ if (composeRule.onAllNodes(appearance).fetchSemanticsNodes().isEmpty()) {
+ composeRule.onNode(settings).performClick()
+ }
+ waitUntilMatcherExists(appearance)
+ composeRule.onNode(appearance).performClick()
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) { tagExists(THEME_LIST_TAG) }
+ }
+
+ private fun scrollThemeListTo(name: String) {
+ val target = hasContentDescription(
+ name,
+ substring = false,
+ ignoreCase = false,
+ )
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ runCatching {
+ composeRule.onNodeWithTag(THEME_LIST_TAG)
+ .performScrollToNode(target)
+ }.isSuccess
+ }
+ composeRule.waitForIdle()
+ }
+
+ private fun waitUntilMatcherExists(matcher: SemanticsMatcher) {
+ composeRule.waitUntil(UI_TIMEOUT_MILLIS) {
+ composeRule.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun tagExists(tag: String): Boolean =
+ composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
+
+ private fun themeIsSelected(name: String): Boolean {
+ val nodes = composeRule.onAllNodes(
+ hasContentDescription(
+ name,
+ substring = false,
+ ignoreCase = false,
+ )
+ ).fetchSemanticsNodes()
+ return nodes.singleOrNull()
+ ?.config
+ ?.getOrElse(SemanticsProperties.Selected) { false }
+ ?: false
+ }
+
+ private data class ThemePreferences(
+ val argb: Int,
+ val isDark: Boolean,
+ val style: Int,
+ val presetId: String,
+ val useDynamicColors: Boolean,
+ val followSystemTheme: Boolean,
+ )
+
+ private companion object {
+ const val THEME_LIST_TAG = "appearance-theme-selection-list"
+ const val UI_TIMEOUT_MILLIS = 10_000L
+ }
+}
diff --git a/app/smartphone/src/debug/assets/default-library/manifest.json b/app/smartphone/src/debug/assets/default-library/manifest.json
new file mode 100644
index 000000000..ca1f2621e
--- /dev/null
+++ b/app/smartphone/src/debug/assets/default-library/manifest.json
@@ -0,0 +1,40 @@
+{
+ "schemaVersion": 1,
+ "revision": "2026-07-29.1",
+ "title": "Debug playback samples",
+ "playlistAsset": "default-library/playlist.m3u",
+ "playlistSha256": "4ca3ab4ffbf27e48ecd4641139864b93cb39f5c6c56784959c52b65343de7809",
+ "expectedChannelIds": [
+ "apple.bipbop.avc",
+ "blender.big-buck-bunny.hls",
+ "blender.sintel.trailer"
+ ],
+ "allowedMediaHosts": [
+ "devstreaming-cdn.apple.com",
+ "test-streams.mux.dev",
+ "download.blender.org"
+ ],
+ "sources": [
+ {
+ "name": "Apple HTTP Live Streaming examples",
+ "url": "https://developer.apple.com/streaming/examples/",
+ "usage": "Apple-hosted developer test stream; this asset stores only its URL"
+ },
+ {
+ "name": "Big Buck Bunny adaptive HLS test stream",
+ "url": "https://test-streams.mux.dev/",
+ "license": "CC-BY-3.0",
+ "licenseUrl": "https://creativecommons.org/licenses/by/3.0/",
+ "attribution": "© 2008 Blender Foundation / www.bigbuckbunny.org",
+ "usage": "Mux-hosted playback test stream; this asset stores only its URL"
+ },
+ {
+ "name": "Sintel",
+ "url": "https://durian.blender.org/",
+ "license": "CC-BY 3.0",
+ "licenseUrl": "https://creativecommons.org/licenses/by/3.0/",
+ "attribution": "© copyright Blender Foundation | durian.blender.org",
+ "usage": "Blender-hosted open movie trailer; this asset stores only its URL"
+ }
+ ]
+}
diff --git a/app/smartphone/src/debug/assets/default-library/playlist.m3u b/app/smartphone/src/debug/assets/default-library/playlist.m3u
new file mode 100644
index 000000000..05dfcc22d
--- /dev/null
+++ b/app/smartphone/src/debug/assets/default-library/playlist.m3u
@@ -0,0 +1,7 @@
+#EXTM3U
+#EXTINF:-1 tvg-id="apple.bipbop.avc" group-title="HLS samples",Apple Bip Bop · AVC HLS
+https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8
+#EXTINF:-1 tvg-id="blender.big-buck-bunny.hls" group-title="HLS samples",Big Buck Bunny · Adaptive HLS
+https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8
+#EXTINF:-1 tvg-id="blender.sintel.trailer" group-title="Open movies",Blender Sintel · Trailer
+https://download.blender.org/durian/trailer/sintel_trailer-480p.mp4
diff --git a/app/smartphone/src/debug/java/com/m3u/smartphone/DebugExtensionPlatformEntryPoint.kt b/app/smartphone/src/debug/java/com/m3u/smartphone/DebugExtensionPlatformEntryPoint.kt
new file mode 100644
index 000000000..8bbd754a9
--- /dev/null
+++ b/app/smartphone/src/debug/java/com/m3u/smartphone/DebugExtensionPlatformEntryPoint.kt
@@ -0,0 +1,16 @@
+package com.m3u.smartphone
+
+import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.data.repository.plugin.ExtensionPluginRepository
+import com.m3u.data.repository.provider.SubscriptionProviderRepository
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface DebugExtensionPlatformEntryPoint {
+ fun pluginRepository(): ExtensionPluginRepository
+ fun providerRepository(): SubscriptionProviderRepository
+ fun playlistRepository(): PlaylistRepository
+}
diff --git a/app/smartphone/src/debug/java/com/m3u/smartphone/startup/DebugDefaultLibraryManifest.kt b/app/smartphone/src/debug/java/com/m3u/smartphone/startup/DebugDefaultLibraryManifest.kt
new file mode 100644
index 000000000..4018020dd
--- /dev/null
+++ b/app/smartphone/src/debug/java/com/m3u/smartphone/startup/DebugDefaultLibraryManifest.kt
@@ -0,0 +1,195 @@
+package com.m3u.smartphone.startup
+
+import java.net.URI
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+internal data class DebugDefaultLibraryManifest(
+ val revision: String,
+ val title: String,
+ val playlistAsset: String,
+ val playlistSha256: String,
+ val expectedChannelIds: Set,
+ val allowedMediaHosts: Set,
+)
+
+internal object DebugDefaultLibraryManifestParser {
+ private val json = Json {
+ ignoreUnknownKeys = true
+ isLenient = false
+ }
+ private val channelIdPattern = Regex("""\btvg-id="([^"]+)"""")
+ private val assetIdPattern = Regex("[a-z0-9][a-z0-9._-]{0,127}")
+ private val sha256Pattern = Regex("[0-9a-f]{64}")
+ private val hostPattern = Regex("[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?")
+
+ fun parse(rawManifest: String): DebugDefaultLibraryManifest {
+ val root = try {
+ json.parseToJsonElement(rawManifest).jsonObject
+ } catch (error: Exception) {
+ throw DebugDefaultLibraryFormatException(
+ message = "The bundled default library manifest is not valid JSON",
+ cause = error,
+ )
+ }
+ val schemaVersion = root["schemaVersion"]?.jsonPrimitive?.intOrNull
+ formatCheck(schemaVersion == SUPPORTED_SCHEMA_VERSION) {
+ "Unsupported bundled default library schema: $schemaVersion"
+ }
+ val revision = root.requiredString("revision")
+ formatCheck(revision.length in 1..64) {
+ "The bundled default library revision is invalid"
+ }
+ val title = root.requiredString("title")
+ formatCheck(title == title.trim() && title.length in 1..120) {
+ "The bundled default library title is invalid"
+ }
+ val playlistAsset = root.requiredString("playlistAsset")
+ formatCheck(
+ playlistAsset.startsWith("$ASSET_DIRECTORY/") &&
+ playlistAsset.endsWith(".m3u") &&
+ '\\' !in playlistAsset &&
+ playlistAsset.split('/').none { segment ->
+ segment.isBlank() || segment == "." || segment == ".."
+ }
+ ) {
+ "The bundled playlist asset path is invalid"
+ }
+ val playlistSha256 = root.requiredString("playlistSha256")
+ formatCheck(sha256Pattern.matches(playlistSha256)) {
+ "The bundled playlist SHA-256 is invalid"
+ }
+ val expectedChannelIds = root.requiredStringSet("expectedChannelIds")
+ formatCheck(
+ expectedChannelIds.isNotEmpty() &&
+ expectedChannelIds.size <= MAXIMUM_CHANNEL_COUNT &&
+ expectedChannelIds.all(assetIdPattern::matches)
+ ) {
+ "The bundled playlist channel identifiers are invalid"
+ }
+ val allowedMediaHosts = root.requiredStringSet("allowedMediaHosts")
+ .mapTo(mutableSetOf()) { host -> host.lowercase() }
+ formatCheck(
+ allowedMediaHosts.isNotEmpty() &&
+ allowedMediaHosts.size <= MAXIMUM_HOST_COUNT &&
+ allowedMediaHosts.all(hostPattern::matches)
+ ) {
+ "The bundled playlist media hosts are invalid"
+ }
+ return DebugDefaultLibraryManifest(
+ revision = revision,
+ title = title,
+ playlistAsset = playlistAsset,
+ playlistSha256 = playlistSha256,
+ expectedChannelIds = expectedChannelIds,
+ allowedMediaHosts = allowedMediaHosts,
+ )
+ }
+
+ fun validatePlaylist(
+ rawPlaylist: String,
+ manifest: DebugDefaultLibraryManifest,
+ ) {
+ val lines = rawPlaylist.lineSequence()
+ .map(String::trim)
+ .filter(String::isNotEmpty)
+ .toList()
+ formatCheck(lines.firstOrNull() == "#EXTM3U") {
+ "The bundled playlist must start with #EXTM3U"
+ }
+ val entryLines = lines.drop(1)
+ formatCheck(entryLines.size == manifest.expectedChannelIds.size * 2) {
+ "The bundled playlist must contain one EXTINF and URL per channel"
+ }
+ val entries = entryLines.chunked(2)
+ val channelIds = entries.map { (metadata, _) ->
+ formatCheck(metadata.startsWith("#EXTINF:", ignoreCase = true)) {
+ "The bundled playlist contains an unsupported directive"
+ }
+ val identifiers = channelIdPattern.findAll(metadata)
+ .map { match -> match.groupValues[1] }
+ .toList()
+ formatCheck(identifiers.size == 1) {
+ "Each bundled playlist entry must have one tvg-id"
+ }
+ identifiers.single()
+ }
+ formatCheck(
+ channelIds.size == channelIds.toSet().size &&
+ channelIds.toSet() == manifest.expectedChannelIds
+ ) {
+ "The bundled playlist channel identifiers do not match its manifest"
+ }
+ val mediaUrls = entries.map { (_, mediaUrl) -> mediaUrl }
+ mediaUrls.forEach { rawUrl ->
+ formatCheck(!rawUrl.startsWith('#')) {
+ "The bundled playlist contains an unsupported directive"
+ }
+ val uri = try {
+ URI(rawUrl)
+ } catch (error: Exception) {
+ throw DebugDefaultLibraryFormatException(
+ message = "The bundled playlist contains an invalid media URL",
+ cause = error,
+ )
+ }
+ formatCheck(
+ uri.scheme.equals("https", ignoreCase = true) &&
+ uri.rawUserInfo == null &&
+ uri.rawQuery == null &&
+ uri.rawFragment == null &&
+ uri.port in setOf(-1, 443) &&
+ uri.host?.lowercase() in manifest.allowedMediaHosts
+ ) {
+ "The bundled playlist contains a disallowed media URL"
+ }
+ }
+ }
+
+ private fun JsonObject.requiredString(key: String): String =
+ this[key]?.jsonPrimitive?.contentOrNull
+ ?: throw DebugDefaultLibraryFormatException(
+ "Missing bundled default library field: $key"
+ )
+
+ private fun JsonObject.requiredStringSet(key: String): Set {
+ val values = try {
+ getValue(key).jsonArray.map { element ->
+ element.jsonPrimitive.content
+ }
+ } catch (error: Exception) {
+ throw DebugDefaultLibraryFormatException(
+ message = "Invalid bundled default library field: $key",
+ cause = error,
+ )
+ }
+ formatCheck(values.size == values.toSet().size) {
+ "Bundled default library values must be unique: $key"
+ }
+ return values.toSet()
+ }
+
+ private const val SUPPORTED_SCHEMA_VERSION = 1
+ private const val ASSET_DIRECTORY = "default-library"
+ private const val MAXIMUM_CHANNEL_COUNT = 64
+ private const val MAXIMUM_HOST_COUNT = 16
+}
+
+internal class DebugDefaultLibraryFormatException(
+ message: String,
+ cause: Throwable? = null,
+) : IllegalArgumentException(message, cause)
+
+private inline fun formatCheck(
+ condition: Boolean,
+ lazyMessage: () -> String,
+) {
+ if (!condition) {
+ throw DebugDefaultLibraryFormatException(lazyMessage())
+ }
+}
diff --git a/app/smartphone/src/debug/java/com/m3u/smartphone/startup/DebugDefaultLibraryStartupTask.kt b/app/smartphone/src/debug/java/com/m3u/smartphone/startup/DebugDefaultLibraryStartupTask.kt
new file mode 100644
index 000000000..57f796d2a
--- /dev/null
+++ b/app/smartphone/src/debug/java/com/m3u/smartphone/startup/DebugDefaultLibraryStartupTask.kt
@@ -0,0 +1,26 @@
+package com.m3u.smartphone.startup
+
+import androidx.work.WorkManager
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+import javax.inject.Inject
+
+internal class DebugDefaultLibraryStartupTask @Inject constructor() :
+ ApplicationStartupTask {
+ override fun enqueue(workManager: WorkManager) {
+ DebugDefaultLibraryWorker.enqueue(workManager)
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal interface DebugDefaultLibraryStartupModule {
+ @Binds
+ @IntoSet
+ fun bindDebugDefaultLibraryStartupTask(
+ task: DebugDefaultLibraryStartupTask,
+ ): ApplicationStartupTask
+}
diff --git a/app/smartphone/src/debug/java/com/m3u/smartphone/startup/DebugDefaultLibraryWorker.kt b/app/smartphone/src/debug/java/com/m3u/smartphone/startup/DebugDefaultLibraryWorker.kt
new file mode 100644
index 000000000..eb7c5ffac
--- /dev/null
+++ b/app/smartphone/src/debug/java/com/m3u/smartphone/startup/DebugDefaultLibraryWorker.kt
@@ -0,0 +1,251 @@
+package com.m3u.smartphone.startup
+
+import android.content.Context
+import androidx.core.content.FileProvider
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import com.m3u.data.database.model.Playlist
+import com.m3u.data.database.model.PlaylistWithChannels
+import com.m3u.data.repository.playlist.PlaylistRepository
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.security.MessageDigest
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+
+@HiltWorker
+internal class DebugDefaultLibraryWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted params: WorkerParameters,
+ private val playlistRepository: PlaylistRepository,
+) : CoroutineWorker(context, params) {
+ override suspend fun doWork(): Result = try {
+ when (readBootstrapState()) {
+ BootstrapState.IMPORTED,
+ BootstrapState.OPTED_OUT -> Result.success()
+ BootstrapState.PENDING -> resumePendingImport()
+ null -> beginFirstImport()
+ }
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (_: DebugDefaultLibraryFormatException) {
+ Result.failure()
+ } catch (_: Exception) {
+ if (runAttemptCount < MAXIMUM_RETRY_COUNT) {
+ Result.retry()
+ } else {
+ Result.failure()
+ }
+ }
+
+ private suspend fun beginFirstImport(): Result {
+ if (playlistRepository.getAll().isNotEmpty()) {
+ writeBootstrapState(BootstrapState.OPTED_OUT)
+ return Result.success()
+ }
+ writeBootstrapState(BootstrapState.PENDING)
+ return resumePendingImport()
+ }
+
+ private suspend fun resumePendingImport(): Result {
+ val manifest = loadManifest()
+ val currentPlaylists = playlistRepository.getAll()
+ if (currentPlaylists.isNotEmpty()) {
+ writeBootstrapState(
+ if (currentPlaylists.containsDefaultLibrary(manifest)) {
+ BootstrapState.IMPORTED
+ } else {
+ BootstrapState.OPTED_OUT
+ }
+ )
+ return Result.success()
+ }
+
+ val playlistFile = installPlaylistAsset(manifest)
+ if (playlistRepository.getAll().isNotEmpty()) {
+ writeBootstrapState(BootstrapState.OPTED_OUT)
+ return Result.success()
+ }
+ val playlistUri = FileProvider.getUriForFile(
+ applicationContext,
+ "${applicationContext.packageName}.provider",
+ playlistFile,
+ )
+ playlistRepository.m3uOrThrow(
+ title = manifest.title,
+ url = playlistUri.toString(),
+ )
+ writeBootstrapState(BootstrapState.IMPORTED)
+ return Result.success()
+ }
+
+ private suspend fun List.containsDefaultLibrary(
+ manifest: DebugDefaultLibraryManifest,
+ ): Boolean = any { playlist ->
+ if (playlist.title != manifest.title) {
+ false
+ } else {
+ playlistRepository.getPlaylistWithChannels(playlist.url)
+ ?.matches(manifest)
+ ?: false
+ }
+ }
+
+ private fun PlaylistWithChannels.matches(
+ manifest: DebugDefaultLibraryManifest,
+ ): Boolean = channels.size == manifest.expectedChannelIds.size &&
+ channels.mapNotNullTo(mutableSetOf()) { channel ->
+ channel.relationId
+ } == manifest.expectedChannelIds
+
+ private suspend fun loadManifest(): DebugDefaultLibraryManifest =
+ withContext(Dispatchers.IO) {
+ val bytes = applicationContext.assets.open(MANIFEST_ASSET).use { input ->
+ input.readBoundedBytes(MAXIMUM_MANIFEST_BYTES)
+ }
+ DebugDefaultLibraryManifestParser.parse(bytes.decodeToString())
+ }
+
+ private suspend fun installPlaylistAsset(
+ manifest: DebugDefaultLibraryManifest,
+ ): File = withContext(Dispatchers.IO) {
+ val bytes = applicationContext.assets.open(manifest.playlistAsset).use { input ->
+ input.readBoundedBytes(MAXIMUM_PLAYLIST_BYTES)
+ }
+ val actualSha256 = MessageDigest.getInstance("SHA-256")
+ .digest(bytes)
+ .joinToString(separator = "") { byte ->
+ "%02x".format(byte.toInt() and 0xff)
+ }
+ if (actualSha256 != manifest.playlistSha256) {
+ throw DebugDefaultLibraryFormatException(
+ "The bundled playlist SHA-256 does not match its manifest"
+ )
+ }
+ DebugDefaultLibraryManifestParser.validatePlaylist(
+ rawPlaylist = bytes.decodeToString(),
+ manifest = manifest,
+ )
+ val directory = File(applicationContext.cacheDir, CACHE_DIRECTORY)
+ check(directory.exists() || directory.mkdirs()) {
+ "Unable to create the bundled default library cache"
+ }
+ val destination = File(directory, CACHE_PLAYLIST_NAME)
+ val temporary = File(directory, "$CACHE_PLAYLIST_NAME.tmp")
+ FileOutputStream(temporary).use { output ->
+ output.write(bytes)
+ output.fd.sync()
+ }
+ temporary.moveReplacing(destination)
+ destination
+ }
+
+ private suspend fun readBootstrapState(): BootstrapState? =
+ withContext(Dispatchers.IO) {
+ val stateFile = bootstrapStateFile()
+ if (!stateFile.exists()) {
+ null
+ } else {
+ BootstrapState.entries.singleOrNull { state ->
+ state.serializedValue == stateFile.readText().trim()
+ } ?: BootstrapState.OPTED_OUT
+ }
+ }
+
+ private suspend fun writeBootstrapState(state: BootstrapState) {
+ withContext(NonCancellable + Dispatchers.IO) {
+ val destination = bootstrapStateFile()
+ val directory = checkNotNull(destination.parentFile)
+ check(directory.exists() || directory.mkdirs()) {
+ "Unable to create the bundled default library state directory"
+ }
+ val temporary = File(directory, "${destination.name}.tmp")
+ FileOutputStream(temporary).use { output ->
+ output.write(state.serializedValue.toByteArray())
+ output.fd.sync()
+ }
+ temporary.moveReplacing(destination)
+ }
+ }
+
+ private fun bootstrapStateFile(): File = File(
+ applicationContext.noBackupFilesDir,
+ "$STATE_DIRECTORY/$STATE_FILE_NAME",
+ )
+
+ private fun File.moveReplacing(destination: File) {
+ try {
+ Files.move(
+ toPath(),
+ destination.toPath(),
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ } catch (_: AtomicMoveNotSupportedException) {
+ Files.move(
+ toPath(),
+ destination.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ }
+ }
+
+ private fun InputStream.readBoundedBytes(maximumBytes: Int): ByteArray {
+ val output = ByteArrayOutputStream(minOf(maximumBytes, ASSET_COPY_BUFFER_BYTES))
+ val buffer = ByteArray(ASSET_COPY_BUFFER_BYTES)
+ var totalBytes = 0
+ while (true) {
+ val count = read(buffer)
+ if (count < 0) break
+ if (count == 0) continue
+ if (totalBytes > maximumBytes - count) {
+ throw DebugDefaultLibraryFormatException(
+ "A bundled default library asset exceeds its size limit"
+ )
+ }
+ output.write(buffer, 0, count)
+ totalBytes += count
+ }
+ return output.toByteArray()
+ }
+
+ private enum class BootstrapState(val serializedValue: String) {
+ PENDING("pending"),
+ IMPORTED("imported"),
+ OPTED_OUT("opted-out"),
+ }
+
+ companion object {
+ private const val UNIQUE_WORK_NAME = "debug-default-library-bootstrap"
+ private const val MANIFEST_ASSET = "default-library/manifest.json"
+ private const val CACHE_DIRECTORY = "debug-default-library"
+ private const val CACHE_PLAYLIST_NAME = "playlist.m3u"
+ private const val STATE_DIRECTORY = "debug-default-library"
+ private const val STATE_FILE_NAME = "bootstrap-state-v1"
+ private const val MAXIMUM_MANIFEST_BYTES = 64 * 1024
+ private const val MAXIMUM_PLAYLIST_BYTES = 512 * 1024
+ private const val ASSET_COPY_BUFFER_BYTES = 8 * 1024
+ private const val MAXIMUM_RETRY_COUNT = 2
+
+ fun enqueue(workManager: WorkManager) {
+ workManager.enqueueUniqueWork(
+ UNIQUE_WORK_NAME,
+ ExistingWorkPolicy.KEEP,
+ OneTimeWorkRequestBuilder().build(),
+ )
+ }
+ }
+}
diff --git a/app/smartphone/src/main/AndroidManifest.xml b/app/smartphone/src/main/AndroidManifest.xml
index cd70e4fdf..7865b66f7 100644
--- a/app/smartphone/src/main/AndroidManifest.xml
+++ b/app/smartphone/src/main/AndroidManifest.xml
@@ -2,6 +2,17 @@
+
+
+
+
+
+
+
+
+
@@ -12,11 +23,6 @@
-
-
-
+ android:launchMode="singleTask"
+ android:windowSoftInputMode="adjustResize">
@@ -56,19 +64,6 @@
android:supportsPictureInPicture="true"
android:theme="@style/Theme.M3U" />
-
-
-
-
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/M3UApplication.kt b/app/smartphone/src/main/java/com/m3u/smartphone/M3UApplication.kt
index 7a3e71539..2921432eb 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/M3UApplication.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/M3UApplication.kt
@@ -4,8 +4,14 @@ import android.app.Application
import android.content.Context
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
-import com.m3u.core.extension.Utils
+import androidx.work.WorkManager
+import com.m3u.data.worker.ExtensionPluginBootstrapWorker
+import com.m3u.data.worker.PersistedUriPermissionCleanupWorker
+import com.m3u.data.worker.ProviderCredentialRecoveryWorker
+import com.m3u.data.worker.ProviderSessionCleanupWorker
+import com.m3u.data.worker.initializePersistedUriPermissionLeases
import com.m3u.i18n.R.string
+import com.m3u.smartphone.startup.ApplicationStartupTask
import dagger.hilt.android.HiltAndroidApp
import org.acra.config.mailSender
import org.acra.config.notification
@@ -20,12 +26,25 @@ class M3UApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
+ @Inject
+ lateinit var startupTasks: Set<@JvmSuppressWildcards ApplicationStartupTask>
+
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(DebugTree())
}
- Utils.init(this)
+ initializePersistedUriPermissionLeases(this)
+ val workManager = WorkManager.getInstance(this)
+ PersistedUriPermissionCleanupWorker.enqueueRecovery(
+ workManager
+ )
+ ProviderCredentialRecoveryWorker.enqueue(workManager)
+ ProviderSessionCleanupWorker.enqueue(
+ workManager = workManager,
+ )
+ ExtensionPluginBootstrapWorker.enqueue(workManager)
+ startupTasks.forEach { task -> task.enqueue(workManager) }
}
override fun attachBaseContext(base: Context?) {
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/TimeUtils.kt b/app/smartphone/src/main/java/com/m3u/smartphone/TimeUtils.kt
index 7eb5d1b46..7539ffb0b 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/TimeUtils.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/TimeUtils.kt
@@ -1,5 +1,9 @@
package com.m3u.smartphone
+import android.text.format.DateFormat
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
import kotlinx.datetime.LocalDateTime
object TimeUtils {
@@ -7,48 +11,119 @@ object TimeUtils {
fun LocalDateTime.formatEOrSh(
twelveHourClock: Boolean,
- ignoreSeconds: Boolean = true
+ ignoreSeconds: Boolean = true,
+ locale: Locale = Locale.getDefault(Locale.Category.FORMAT),
): String {
- return if (twelveHourClock) {
- val hour12 = if (hour > 12) hour - 12 else if (hour == 0) 12 else hour
- val formattedHour = if (hour12 < 10) "0$hour12" else hour12.toString()
- val formattedMinute = if (minute < 10) "0$minute" else minute.toString()
- val formattedSecond = if (second < 10) "0$second" else second.toString()
- buildString {
- append("$formattedHour:")
- append(formattedMinute)
- if (!ignoreSeconds) {
- append(":$formattedSecond")
- }
- }
- } else {
- buildString {
- append("${if (hour < 10) "0$hour" else hour}:")
- append("${if (minute < 10) "0$minute" else minute}")
- if (!ignoreSeconds) {
- append(":${if (second < 10) "0$second" else second}")
- }
- }
- }
+ return formatTime(
+ hour = hour,
+ minute = minute,
+ second = second.takeUnless { ignoreSeconds },
+ twelveHourClock = twelveHourClock,
+ locale = locale,
+ )
}
- fun Float.formatEOrSh(use12HourFormat: Boolean): String {
+ fun Float.formatEOrSh(
+ use12HourFormat: Boolean,
+ locale: Locale = Locale.getDefault(Locale.Category.FORMAT),
+ ): String {
val hour = (this / 1).toInt()
val minute = (this % 1 * 60).toInt()
- val amPm = if (hour < 12) "AM" else "PM"
- val hour12 = when {
- !use12HourFormat -> hour
- hour > 12 -> hour - 12
- hour == 0 -> 12
- else -> hour
+ return formatTime(hour, minute, null, use12HourFormat, locale)
+ }
+
+ private fun formatTime(
+ hour: Int,
+ minute: Int,
+ second: Int?,
+ twelveHourClock: Boolean,
+ locale: Locale,
+ ): String {
+ val skeleton = buildString {
+ append(if (twelveHourClock) "hm" else "Hm")
+ if (second != null) append('s')
}
- val formattedHour = if (hour12 < 10) "0$hour12" else hour12.toString()
- val formattedMinute = if (minute < 10) "0$minute" else minute.toString()
- return if (use12HourFormat) {
- "$formattedHour:$formattedMinute $amPm"
+ val pattern = runCatching {
+ DateFormat.getBestDateTimePattern(locale, skeleton)
+ }.getOrNull()
+ ?.takeIf(String::isNotBlank)
+ ?: fallbackTimePattern(locale, twelveHourClock, second != null)
+ val endpointPattern = if (!twelveHourClock && hour == 24) {
+ pattern.replaceUnquotedHourSymbol(from = 'H', to = 'k')
} else {
- "$formattedHour:$formattedMinute"// $amPm"
+ pattern
}
+ return formatTimeWithPattern(
+ hour = hour,
+ minute = minute,
+ second = second,
+ locale = locale,
+ pattern = endpointPattern,
+ )
+ }
+}
+internal fun formatTimeWithPattern(
+ hour: Int,
+ minute: Int,
+ second: Int?,
+ locale: Locale,
+ pattern: String,
+): String {
+ val calendar = Calendar.getInstance(locale).apply {
+ clear()
+ set(2000, Calendar.JANUARY, 1, hour % 24, minute, second ?: 0)
+ }
+ return SimpleDateFormat(pattern, locale).format(calendar.time)
+}
+
+private fun fallbackTimePattern(
+ locale: Locale,
+ twelveHourClock: Boolean,
+ includeSeconds: Boolean,
+): String {
+ val defaultPattern = (
+ java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT, locale)
+ as? SimpleDateFormat
+ )?.toPattern().orEmpty()
+ val dayPeriodBeforeHour = defaultPattern.unquotedIndexOf('a')
+ .takeIf { it >= 0 }
+ ?.let { periodIndex ->
+ val hourIndex = listOf('h', 'H', 'k', 'K')
+ .map(defaultPattern::unquotedIndexOf)
+ .filter { it >= 0 }
+ .minOrNull()
+ ?: Int.MAX_VALUE
+ periodIndex < hourIndex
+ } == true
+ val time = if (includeSeconds) "h:mm:ss" else "h:mm"
+ return when {
+ !twelveHourClock -> if (includeSeconds) "HH:mm:ss" else "HH:mm"
+ dayPeriodBeforeHour -> "a$time"
+ else -> "$time a"
+ }
+}
+
+private fun String.unquotedIndexOf(target: Char): Int {
+ var quoted = false
+ forEachIndexed { index, character ->
+ if (character == '\'') {
+ quoted = !quoted
+ } else if (!quoted && character == target) {
+ return index
+ }
+ }
+ return -1
+}
+
+private fun String.replaceUnquotedHourSymbol(from: Char, to: Char): String = buildString(length) {
+ var quoted = false
+ this@replaceUnquotedHourSymbol.forEach { character ->
+ if (character == '\'') {
+ quoted = !quoted
+ append(character)
+ } else {
+ append(if (!quoted && character == from) to else character)
+ }
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/glance/FavouriteWidget.kt b/app/smartphone/src/main/java/com/m3u/smartphone/glance/FavouriteWidget.kt
index fe7616264..24dc02912 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/glance/FavouriteWidget.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/glance/FavouriteWidget.kt
@@ -154,7 +154,7 @@ private fun FavoriteGalleryItem(
}
programme?.let {
Text(
- text = it.readText(),
+ text = it.readText(context),
style = TextStyle(
color = ColorProvider(
GlanceTheme.colors.onSurfaceVariant
@@ -175,9 +175,12 @@ private fun FavoriteGalleryItem(
}
}
-private fun Programme.readText(): String = buildString {
+private fun Programme.readText(context: Context): String = buildString {
val start = Instant.fromEpochMilliseconds(start)
.toLocalDateTime(TimeZone.currentSystemDefault())
- .formatEOrSh(true)
+ .formatEOrSh(
+ twelveHourClock = true,
+ locale = context.resources.configuration.locales[0],
+ )
append("[$start] $title")
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/startup/ApplicationStartupTask.kt b/app/smartphone/src/main/java/com/m3u/smartphone/startup/ApplicationStartupTask.kt
new file mode 100644
index 000000000..a1c2f446e
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/startup/ApplicationStartupTask.kt
@@ -0,0 +1,7 @@
+package com.m3u.smartphone.startup
+
+import androidx.work.WorkManager
+
+fun interface ApplicationStartupTask {
+ fun enqueue(workManager: WorkManager)
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt
index ea5f5ad4b..1b34ce0ea 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt
@@ -2,21 +2,37 @@ package com.m3u.smartphone.ui
import android.app.ActivityOptions
import android.content.Intent
+import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.isImeVisible
+import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
+import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -24,45 +40,83 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.rounded.SettingsRemote
import androidx.compose.material3.ExpandedFullScreenSearchBar
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.material3.SearchBarState
import androidx.compose.material3.SearchBarValue
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopSearchBar
+import androidx.compose.material3.TopAppBar
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
+import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.movableContentOf
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import androidx.paging.PagingData
+import com.m3u.business.playlist.configuration.PlaylistConfigurationNavigation
import com.m3u.business.playlist.ChannelWithProgramme
import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
+import com.m3u.core.foundation.architecture.preferences.ThemeStyle
import com.m3u.core.foundation.architecture.preferences.preferenceOf
import com.m3u.data.service.MediaCommand
import com.m3u.data.tv.model.RemoteDirection
+import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.business.channel.PlayerActivity
import com.m3u.smartphone.ui.business.playlist.components.ChannelGallery
import com.m3u.smartphone.ui.common.AppNavHost
import com.m3u.smartphone.ui.common.connect.RemoteControlSheet
import com.m3u.smartphone.ui.common.connect.RemoteControlSheetValue
import com.m3u.smartphone.ui.common.helper.LocalHelper
+import com.m3u.smartphone.ui.common.helper.Metadata
import com.m3u.smartphone.ui.material.components.Destination
import com.m3u.smartphone.ui.material.components.SnackHost
+import com.m3u.smartphone.ui.material.components.withEditorialVoice
+import com.m3u.smartphone.ui.material.model.LocalThemeStyle
import com.m3u.smartphone.ui.material.model.LocalSpacing
+import com.m3u.smartphone.ui.navigation.AppContentInsets
+import com.m3u.smartphone.ui.navigation.AppNavigationMode
+import com.m3u.smartphone.ui.navigation.FloatingAppNavigationDock
+import com.m3u.smartphone.ui.navigation.calculateContentBottomPadding
+import com.m3u.smartphone.ui.navigation.calculateLayoutPadding
+import com.m3u.smartphone.ui.navigation.resolveAppNavigationMode
+import com.m3u.smartphone.ui.navigation.shouldCaptureNavigationBackdrop
+import com.m3u.smartphone.ui.navigation.shouldReserveBottomNavigationSpace
+import com.m3u.smartphone.ui.navigation.shouldShowBottomEdgeBlur
+import com.m3u.smartphone.ui.navigation.shouldShowBottomNavigation
+import com.m3u.smartphone.ui.navigation.shouldShowContextualTopBar
+import com.m3u.smartphone.ui.navigation.shouldShowRemoteControlAction
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
@@ -76,6 +130,7 @@ fun App(
AppImpl(
navController = navController,
channels = viewModel.channels,
+ onSearchQuery = { query -> viewModel.searchQuery.value = query },
isRemoteControlSheetVisible = viewModel.isConnectSheetVisible,
remoteControlSheetValue = viewModel.remoteControlSheetValue,
openRemoteControlSheet = { viewModel.isConnectSheetVisible = true },
@@ -87,14 +142,16 @@ fun App(
viewModel.code = ""
viewModel.isConnectSheetVisible = false
},
- modifier = modifier
+ modifier = modifier,
)
}
+@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun AppImpl(
navController: NavHostController,
channels: Flow>,
+ onSearchQuery: (String) -> Unit,
isRemoteControlSheetVisible: Boolean,
remoteControlSheetValue: RemoteControlSheetValue,
openRemoteControlSheet: () -> Unit,
@@ -103,22 +160,110 @@ private fun AppImpl(
forgetTvCodeOnSmartphone: () -> Unit,
onRemoteDirection: (RemoteDirection) -> Unit,
onDismissRequest: () -> Unit,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
) {
val context = LocalContext.current
+ val density = LocalDensity.current
+ val layoutDirection = LocalLayoutDirection.current
val spacing = LocalSpacing.current
val helper = LocalHelper.current
+ val coroutineScope = rememberCoroutineScope()
+ val searchBarState = rememberSearchBarState()
+ val textFieldState = rememberTextFieldState()
+ val navigationBackdrop = rememberLayerBackdrop()
+ var nestedDetailVisible by remember { mutableStateOf(false) }
+ val onNestedDetailVisibilityChanged = remember {
+ { visible: Boolean -> nestedDetailVisible = visible }
+ }
val zappingMode by preferenceOf(PreferencesKeys.ZAPPING_MODE)
val remoteControl by preferenceOf(PreferencesKeys.REMOTE_CONTROL)
-
val entry by navController.currentBackStackEntryAsState()
-
- val currentDestination by remember {
+ val currentDestination by remember(entry) {
derivedStateOf {
Destination.of(entry?.destination?.route)
}
}
+ val isRootPlaylistConfiguration =
+ entry?.destination?.route ==
+ PlaylistConfigurationNavigation.PLAYLIST_CONFIGURATION_ROUTE
+ val navigationMode = resolveAppNavigationMode(
+ with(density) {
+ LocalWindowInfo.current.containerSize.width.toDp()
+ },
+ )
+ val searchActive = searchBarState.currentValue != SearchBarValue.Collapsed ||
+ searchBarState.targetValue != SearchBarValue.Collapsed
+ val imeVisible = WindowInsets.isImeVisible
+ val isTopLevelRoute = currentDestination != null && !nestedDetailVisible
+ val showBottomNavigation = shouldShowBottomNavigation(
+ mode = navigationMode,
+ isTopLevelRoute = isTopLevelRoute,
+ isSearchActive = searchActive,
+ isImeVisible = imeVisible,
+ )
+ val bottomNavigationVisibility = remember(navigationMode) {
+ MutableTransitionState(showBottomNavigation)
+ }
+ LaunchedEffect(showBottomNavigation, bottomNavigationVisibility) {
+ bottomNavigationVisibility.targetState = showBottomNavigation
+ }
+ val supportsBackdropEffects = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ val captureNavigationBackdrop = shouldCaptureNavigationBackdrop(
+ mode = navigationMode,
+ supportsBackdropEffects = supportsBackdropEffects,
+ isNavigationCurrentlyVisible = bottomNavigationVisibility.currentState,
+ isNavigationTargetVisible = bottomNavigationVisibility.targetState,
+ )
+ val bottomNavigationOccupiesSpace = shouldReserveBottomNavigationSpace(
+ mode = navigationMode,
+ isNavigationCurrentlyVisible = bottomNavigationVisibility.currentState,
+ isNavigationTargetVisible = bottomNavigationVisibility.targetState,
+ )
+ val remoteControlVisible = shouldShowRemoteControlAction(
+ remoteControlEnabled = remoteControl,
+ isSearchActive = searchActive,
+ isImeVisible = imeVisible,
+ isRootPlaylistConfiguration = isRootPlaylistConfiguration,
+ isNestedDetailVisible = nestedDetailVisible,
+ )
+
+ var measuredNavigationHeight by remember { mutableStateOf(64.dp) }
+ val safeDrawingPadding = WindowInsets.safeDrawing.asPaddingValues()
+ val safeBottomInset = safeDrawingPadding.calculateBottomPadding()
+ val layoutPadding = calculateLayoutPadding(
+ mode = navigationMode,
+ safeStartInset = safeDrawingPadding.calculateStartPadding(layoutDirection),
+ safeEndInset = safeDrawingPadding.calculateEndPadding(layoutDirection),
+ )
+ val contentBottomPadding = calculateContentBottomPadding(
+ mode = navigationMode,
+ isTopLevelRoute = bottomNavigationOccupiesSpace,
+ safeBottomInset = safeBottomInset,
+ measuredNavigationHeight = measuredNavigationHeight,
+ floatingUtilityHeight = if (
+ navigationMode == AppNavigationMode.SideRail &&
+ remoteControlVisible
+ ) {
+ REMOTE_CONTROL_FAB_SIZE
+ } else {
+ 0.dp
+ },
+ regularContentSpacing = spacing.medium,
+ )
+ val contentInsets = AppContentInsets(
+ layoutPadding = layoutPadding,
+ contentPadding = PaddingValues(bottom = contentBottomPadding),
+ navigationClearance = if (bottomNavigationOccupiesSpace) {
+ safeBottomInset + FLOATING_NAVIGATION_BOTTOM_GAP + measuredNavigationHeight
+ } else {
+ safeBottomInset
+ },
+ )
+
+ LaunchedEffect(textFieldState) {
+ snapshotFlow { textFieldState.text.toString() }.collect(onSearchQuery)
+ }
val navigateToDestination = { destination: Destination ->
navController.navigate(destination.name, navOptions {
@@ -127,80 +272,299 @@ private fun AppImpl(
}
})
}
-
val navigateToChannel: () -> Unit = {
if (!zappingMode || !PlayerActivity.isInPipMode) {
- val options = ActivityOptions.makeCustomAnimation(
- context,
- 0,
- 0
- )
+ val options = ActivityOptions.makeCustomAnimation(context, 0, 0)
context.startActivity(
Intent(context, PlayerActivity::class.java),
- options.toBundle()
+ options.toBundle(),
+ )
+ }
+ }
+ val movableAppContent = remember {
+ movableContentOf { arguments ->
+ AppContent(
+ navController = arguments.navController,
+ channels = arguments.channels,
+ searchBarState = arguments.searchBarState,
+ textFieldState = arguments.textFieldState,
+ navigateToDestination = arguments.navigateToDestination,
+ navigateToChannel = arguments.navigateToChannel,
+ contentPadding = arguments.contentPadding,
+ showBottomEdgeBlur = arguments.showBottomEdgeBlur,
+ showContextualTopBar = arguments.showContextualTopBar,
+ onNestedDetailVisibilityChanged =
+ arguments.onNestedDetailVisibilityChanged,
)
}
}
+ val appContentArguments = AppContentArguments(
+ navController = navController,
+ channels = channels,
+ searchBarState = searchBarState,
+ textFieldState = textFieldState,
+ navigateToDestination = { navController.navigate(it.name) },
+ navigateToChannel = navigateToChannel,
+ contentPadding = contentInsets.contentPadding,
+ showBottomEdgeBlur = shouldShowBottomEdgeBlur(navigationMode),
+ showContextualTopBar = shouldShowContextualTopBar(
+ isRootPlaylistConfiguration = isRootPlaylistConfiguration,
+ isNestedDetailVisible = nestedDetailVisible,
+ ),
+ onNestedDetailVisibilityChanged = onNestedDetailVisibilityChanged,
+ )
- NavigationSuiteScaffold(
- navigationSuiteItems = {
- Destination.entries.forEach { destination ->
- val isSelected = destination == currentDestination
- item(
- icon = {
- Icon(
- imageVector = when {
- isSelected -> destination.selectedIcon
- else -> destination.unselectedIcon
+ Box(
+ modifier = modifier.fillMaxSize(),
+ ) {
+ when (navigationMode) {
+ AppNavigationMode.BottomOverlay -> {
+ Surface(
+ color = MaterialTheme.colorScheme.background,
+ contentColor = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier
+ .fillMaxSize()
+ .then(
+ if (captureNavigationBackdrop) {
+ Modifier.layerBackdrop(navigationBackdrop)
+ } else {
+ Modifier
},
- contentDescription = stringResource(destination.iconTextId)
- )
- },
- label = {
- Text(stringResource(destination.iconTextId))
- },
- selected = isSelected,
- onClick = { navigateToDestination(destination) },
- alwaysShowLabel = false
- )
+ ),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentInsets.layoutPadding),
+ ) {
+ movableAppContent(appContentArguments)
+ }
+ }
}
- },
- modifier = modifier
- ) {
- Column {
- val coroutineScope = rememberCoroutineScope()
- val searchBarState = rememberSearchBarState()
- val textFieldState = rememberTextFieldState()
- val inputField = @Composable {
- SearchBarDefaults.InputField(
- searchBarState = searchBarState,
- textFieldState = textFieldState,
- onSearch = { coroutineScope.launch { searchBarState.animateToCollapsed() } },
- placeholder = { Text("Search...") },
- leadingIcon = {
- if (searchBarState.currentValue == SearchBarValue.Expanded) {
- IconButton(
- onClick = { coroutineScope.launch { searchBarState.animateToCollapsed() } }
- ) {
- Icon(
- Icons.AutoMirrored.Default.ArrowBack,
- contentDescription = "Back"
- )
- }
- } else {
- Icon(Icons.Default.Search, contentDescription = null)
+
+ AppNavigationMode.SideRail -> {
+ NavigationSuiteScaffold(
+ navigationSuiteItems = {
+ Destination.entries.forEach { destination ->
+ val selected = destination == currentDestination
+ item(
+ icon = {
+ Icon(
+ imageVector = if (selected) {
+ destination.selectedIcon
+ } else {
+ destination.unselectedIcon
+ },
+ contentDescription = stringResource(destination.iconTextId),
+ )
+ },
+ label = {
+ Text(stringResource(destination.iconTextId))
+ },
+ selected = selected,
+ onClick = { navigateToDestination(destination) },
+ alwaysShowLabel = false,
+ modifier = Modifier.testTag(
+ "side-navigation-item:${destination.name}"
+ ),
+ )
}
},
- trailingIcon = { Icon(Icons.Default.MoreVert, contentDescription = null) },
+ layoutType = NavigationSuiteType.NavigationRail,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentInsets.layoutPadding),
+ ) {
+ movableAppContent(appContentArguments)
+ AppUtilityLayer(
+ remoteControlVisible = remoteControlVisible,
+ bottomNavigationVisible = false,
+ navigationClearance = contentInsets.navigationClearance,
+ safeBottomInset = safeBottomInset,
+ imeBottomInset = WindowInsets.ime
+ .asPaddingValues()
+ .calculateBottomPadding(),
+ onOpenRemoteControlSheet = openRemoteControlSheet,
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+ }
+ }
+ }
+ }
+
+ if (navigationMode == AppNavigationMode.BottomOverlay) {
+ AppUtilityLayer(
+ remoteControlVisible = false,
+ bottomNavigationVisible = bottomNavigationOccupiesSpace,
+ navigationClearance = contentInsets.navigationClearance,
+ safeBottomInset = safeBottomInset,
+ imeBottomInset = WindowInsets.ime.asPaddingValues().calculateBottomPadding(),
+ onOpenRemoteControlSheet = openRemoteControlSheet,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(contentInsets.layoutPadding),
+ )
+ }
+
+ if (navigationMode == AppNavigationMode.BottomOverlay) {
+ AnimatedVisibility(
+ visibleState = bottomNavigationVisibility,
+ enter = slideInVertically(initialOffsetY = { it / 3 }) +
+ fadeIn() +
+ scaleIn(initialScale = 0.94f),
+ exit = slideOutVertically(targetOffsetY = { it / 3 }) +
+ fadeOut() +
+ scaleOut(targetScale = 0.94f),
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(
+ start = safeDrawingPadding.calculateStartPadding(layoutDirection),
+ end = safeDrawingPadding.calculateEndPadding(layoutDirection),
+ bottom = safeBottomInset + FLOATING_NAVIGATION_BOTTOM_GAP,
+ ),
+ ) {
+ FloatingAppNavigationDock(
+ selectedDestination = currentDestination,
+ backdrop = navigationBackdrop,
+ useBackdropEffects = supportsBackdropEffects,
+ enabled = showBottomNavigation,
+ remoteControlVisible = remoteControlVisible,
+ onDestinationSelected = navigateToDestination,
+ onOpenRemoteControl = openRemoteControlSheet,
+ onHeightChanged = { measuredNavigationHeight = it },
)
}
+ }
+
+ RemoteControlSheet(
+ value = remoteControlSheetValue,
+ visible = isRemoteControlSheetVisible,
+ onCode = onCode,
+ checkTvCodeOnSmartphone = checkTvCodeOnSmartphone,
+ forgetTvCodeOnSmartphone = forgetTvCodeOnSmartphone,
+ onRemoteDirection = onRemoteDirection,
+ onDismissRequest = onDismissRequest,
+ )
+ }
+}
+
+private class AppContentArguments(
+ val navController: NavHostController,
+ val channels: Flow>,
+ val searchBarState: SearchBarState,
+ val textFieldState: TextFieldState,
+ val navigateToDestination: (Destination) -> Unit,
+ val navigateToChannel: () -> Unit,
+ val contentPadding: PaddingValues,
+ val showBottomEdgeBlur: Boolean,
+ val showContextualTopBar: Boolean,
+ val onNestedDetailVisibilityChanged: (Boolean) -> Unit,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AppContent(
+ navController: NavHostController,
+ channels: Flow>,
+ searchBarState: SearchBarState,
+ textFieldState: TextFieldState,
+ navigateToDestination: (Destination) -> Unit,
+ navigateToChannel: () -> Unit,
+ contentPadding: PaddingValues,
+ showBottomEdgeBlur: Boolean,
+ showContextualTopBar: Boolean,
+ onNestedDetailVisibilityChanged: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val helper = LocalHelper.current
+ val coroutineScope = rememberCoroutineScope()
+ val contextualTitleStyle = if (LocalThemeStyle.current == ThemeStyle.WARM_EDITORIAL) {
+ MaterialTheme.typography.titleLarge.withEditorialVoice()
+ } else {
+ MaterialTheme.typography.titleLarge
+ }
+ val inputField = @Composable {
+ SearchBarDefaults.InputField(
+ searchBarState = searchBarState,
+ textFieldState = textFieldState,
+ onSearch = { coroutineScope.launch { searchBarState.animateToCollapsed() } },
+ placeholder = { Text(stringResource(string.ui_search_placeholder)) },
+ leadingIcon = {
+ if (searchBarState.currentValue == SearchBarValue.Expanded) {
+ IconButton(
+ onClick = {
+ coroutineScope.launch {
+ searchBarState.animateToCollapsed()
+ }
+ },
+ ) {
+ Icon(
+ Icons.AutoMirrored.Default.ArrowBack,
+ contentDescription = stringResource(
+ string.ui_cd_top_bar_on_back_pressed,
+ ),
+ )
+ }
+ } else {
+ Icon(Icons.Default.Search, contentDescription = null)
+ }
+ },
+ trailingIcon = {
+ Icon(Icons.Default.MoreVert, contentDescription = null)
+ },
+ )
+ }
+
+ Column(modifier = modifier.fillMaxSize()) {
+ if (showContextualTopBar) {
+ TopAppBar(
+ title = {
+ Text(
+ text = Metadata.title,
+ style = contextualTitleStyle,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ navigationIcon = {
+ Metadata.fob?.let { backAction ->
+ IconButton(onClick = backAction.onClick) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(
+ string.ui_cd_top_bar_on_back_pressed
+ ),
+ )
+ }
+ }
+ },
+ actions = {
+ Metadata.actions.forEach { action ->
+ IconButton(
+ onClick = action.onClick,
+ enabled = action.enabled,
+ ) {
+ Icon(
+ imageVector = action.icon,
+ contentDescription = action.contentDescription,
+ )
+ }
+ }
+ },
+ windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top),
+ )
+ } else {
TopSearchBar(
state = searchBarState,
- inputField = inputField
+ inputField = inputField,
+ windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top),
)
ExpandedFullScreenSearchBar(
inputField = inputField,
- state = searchBarState
+ state = searchBarState,
) {
BackHandler {
coroutineScope.launch {
@@ -224,57 +588,80 @@ private fun AppImpl(
onLongClick = {},
reloadThumbnail = { null },
syncThumbnail = { null },
- contentPadding = WindowInsets.ime.asPaddingValues()
+ contentPadding = WindowInsets.ime.asPaddingValues(),
)
}
- AppNavHost(
- navController = navController,
- navigateToDestination = { navController.navigate(it.name) },
- navigateToChannel = navigateToChannel,
- modifier = Modifier
- .fillMaxWidth()
- .weight(1f)
- )
- // snack-host area
- Row(
- horizontalArrangement = Arrangement.spacedBy(spacing.small, Alignment.End),
- verticalAlignment = Alignment.Bottom,
- modifier = Modifier
- .fillMaxWidth()
- .padding(spacing.medium)
+ }
+ AppNavHost(
+ navController = navController,
+ navigateToDestination = navigateToDestination,
+ navigateToChannel = navigateToChannel,
+ contentPadding = contentPadding,
+ showBottomEdgeBlur = showBottomEdgeBlur,
+ onNestedDetailVisibilityChanged = onNestedDetailVisibilityChanged,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ )
+ }
+}
+
+@Composable
+private fun AppUtilityLayer(
+ remoteControlVisible: Boolean,
+ bottomNavigationVisible: Boolean,
+ navigationClearance: Dp,
+ safeBottomInset: Dp,
+ imeBottomInset: Dp,
+ onOpenRemoteControlSheet: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val spacing = LocalSpacing.current
+ val targetBottomPadding = if (bottomNavigationVisible) {
+ navigationClearance + FLOATING_NAVIGATION_CONTENT_GAP
+ } else {
+ maxOf(safeBottomInset, imeBottomInset) + spacing.medium
+ }
+ val bottomPadding by animateDpAsState(
+ targetValue = targetBottomPadding,
+ label = "app-utility-bottom-padding",
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(spacing.small, Alignment.End),
+ verticalAlignment = Alignment.Bottom,
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(
+ start = spacing.medium,
+ end = spacing.medium,
+ bottom = bottomPadding,
+ ),
+ ) {
+ SnackHost(Modifier.weight(1f))
+ AnimatedVisibility(
+ visible = remoteControlVisible,
+ enter = scaleIn(initialScale = 0.65f) + fadeIn(),
+ exit = scaleOut(targetScale = 0.65f) + fadeOut(),
+ ) {
+ FloatingActionButton(
+ elevation = FloatingActionButtonDefaults.elevation(
+ defaultElevation = spacing.none,
+ pressedElevation = spacing.none,
+ focusedElevation = spacing.extraSmall,
+ hoveredElevation = spacing.extraSmall,
+ ),
+ onClick = onOpenRemoteControlSheet,
) {
- SnackHost(Modifier.weight(1f))
- AnimatedVisibility(
- visible = remoteControl,
- enter = scaleIn(initialScale = 0.65f) + fadeIn(),
- exit = scaleOut(targetScale = 0.65f) + fadeOut()
- ) {
- FloatingActionButton(
- elevation = FloatingActionButtonDefaults.elevation(
- defaultElevation = spacing.none,
- pressedElevation = spacing.none,
- focusedElevation = spacing.extraSmall,
- hoveredElevation = spacing.extraSmall
- ),
- onClick = openRemoteControlSheet
- ) {
- Icon(
- imageVector = Icons.Rounded.SettingsRemote,
- contentDescription = stringResource(com.m3u.i18n.R.string.feat_setting_remote_control)
- )
- }
- }
+ Icon(
+ imageVector = Icons.Rounded.SettingsRemote,
+ contentDescription = stringResource(string.feat_setting_remote_control),
+ )
}
-
- RemoteControlSheet(
- value = remoteControlSheetValue,
- visible = isRemoteControlSheetVisible,
- onCode = onCode,
- checkTvCodeOnSmartphone = checkTvCodeOnSmartphone,
- forgetTvCodeOnSmartphone = forgetTvCodeOnSmartphone,
- onRemoteDirection = onRemoteDirection,
- onDismissRequest = onDismissRequest
- )
}
}
}
+
+private val FLOATING_NAVIGATION_BOTTOM_GAP = 12.dp
+private val FLOATING_NAVIGATION_CONTENT_GAP = 12.dp
+private val REMOTE_CONTROL_FAB_SIZE = 56.dp
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt
index 7de277ce3..baddda108 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/AppViewModel.kt
@@ -9,11 +9,15 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import androidx.paging.filter
+import androidx.paging.insertHeaderItem
import androidx.paging.map as pagingMap
import androidx.work.WorkManager
import com.m3u.business.playlist.ChannelWithProgramme
import com.m3u.data.api.TvApiDelegate
import com.m3u.data.repository.channel.ChannelRepository
+import com.m3u.data.repository.extension.ExtensionContributionRepository
import com.m3u.data.repository.playlist.PlaylistRepository
import com.m3u.data.repository.tv.ConnectionToTvValue
import com.m3u.data.repository.tv.TvRepository
@@ -22,29 +26,48 @@ import com.m3u.data.tv.model.TvInfo
import com.m3u.data.worker.SubscriptionWorker
import com.m3u.smartphone.ui.common.connect.RemoteControlSheetValue
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
+@OptIn(FlowPreview::class)
class AppViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository,
private val channelRepository: ChannelRepository,
+ private val extensionContributionRepository: ExtensionContributionRepository,
private val workManager: WorkManager,
private val tvRepository: TvRepository,
private val tvApi: TvApiDelegate,
) : ViewModel() {
- val channels: Flow> = snapshotFlow { searchQuery.value }
+ var searchQuery = mutableStateOf("")
+
+ private val searchQueries = snapshotFlow { searchQuery.value }
+ .map(String::trim)
+ .debounce(SEARCH_DEBOUNCE_MILLIS)
+ .distinctUntilChanged()
+ .stateIn(viewModelScope, SharingStarted.Eagerly, "")
+
+ private val localSearchResults: Flow> = searchQueries
.flatMapLatest { query ->
if (query.isBlank()) {
- emptyFlow()
+ flowOf(PagingData.empty())
} else {
Pager(
config = PagingConfig(
@@ -66,7 +89,31 @@ class AppViewModel @Inject constructor(
}
}
- var searchQuery = mutableStateOf("")
+ private val extensionSearchResults = searchQueries
+ .mapLatestSearchResults { query ->
+ val contributions = extensionContributionRepository.search(query, SEARCH_RESULT_LIMIT)
+ contributions
+ .map { contribution -> contribution.channel }
+ .distinctBy { channel -> channel.id }
+ }
+ .flowOn(Dispatchers.IO)
+
+ val channels: Flow> = combine(
+ searchQueries,
+ localSearchResults,
+ extensionSearchResults,
+ ) { query, local, extensionResults ->
+ val promotedChannels = extensionResults.itemsFor(query)
+ val promotedIds = promotedChannels.mapTo(mutableSetOf()) { channel -> channel.id }
+ promotedChannels.asReversed().fold(
+ local.filter { item -> item.channel.id !in promotedIds }
+ ) { paging, channel ->
+ paging.insertHeaderItem(
+ item = ChannelWithProgramme(channel = channel, programme = null)
+ )
+ }
+ }.cachedIn(viewModelScope)
+
private fun refreshProgrammes() {
viewModelScope.launch {
val playlists = playlistRepository.getAllAutoRefresh()
@@ -134,4 +181,28 @@ class AppViewModel @Inject constructor(
timber.d("remote direction: $direction")
viewModelScope.launch { tvApi.remoteDirection(direction.value) }
}
+
+ private companion object {
+ const val SEARCH_DEBOUNCE_MILLIS = 300L
+ const val SEARCH_RESULT_LIMIT = 50
+ }
+}
+
+internal fun Flow.mapLatestSearchResults(
+ search: suspend (query: String) -> List,
+): Flow> = flatMapLatest { query ->
+ flow {
+ emit(QuerySearchResults(query, emptyList()))
+ if (query.isNotBlank()) {
+ emit(QuerySearchResults(query, search(query)))
+ }
+ }
}
+
+internal data class QuerySearchResults(
+ val query: String,
+ val items: List,
+)
+
+internal fun QuerySearchResults.itemsFor(query: String): List =
+ items.takeIf { this.query == query }.orEmpty()
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt
index c4536144f..c8f3d5140 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelMask.kt
@@ -124,6 +124,7 @@ fun ChannelMask(
maskState: MaskState,
favourite: Boolean,
isSeriesPlaylist: Boolean,
+ supportsDirectSharing: Boolean,
isPanelExpanded: Boolean,
useVertical: Boolean,
hasTrack: Boolean,
@@ -319,17 +320,21 @@ fun ChannelMask(
)
}
- if (!currentUseVertical) {
- MaskButton(
- state = maskState,
- icon = if (currentIsPanelExpanded) Icons.Rounded.Archive
- else Icons.Rounded.Unarchive,
- onClick = openOrClosePanel,
- contentDescription = stringResource(string.feat_channel_tooltip_open_panel)
+ MaskButton(
+ state = maskState,
+ icon = if (currentIsPanelExpanded) Icons.Rounded.Archive
+ else Icons.Rounded.Unarchive,
+ onClick = openOrClosePanel,
+ contentDescription = stringResource(
+ if (currentIsPanelExpanded) {
+ string.ui_action_close_player_panel
+ } else {
+ string.ui_action_open_player_panel
+ }
)
- }
+ )
- if (screencast) {
+ if (screencast && supportsDirectSharing) {
MaskButton(
state = maskState,
icon = Icons.Rounded.Cast,
@@ -354,7 +359,7 @@ fun ChannelMask(
alwaysShowReplay,
playerState.playerError
)
- Box(Modifier.size(36.dp)) {
+ Box(Modifier.size(48.dp)) {
androidx.compose.animation.AnimatedVisibility(
visible = !currentIsPanelExpanded && adjacentChannels?.prevId != null,
enter = fadeIn() + slideInHorizontally(initialOffsetX = { -it / 6 }),
@@ -369,7 +374,7 @@ fun ChannelMask(
}
}
- Box(Modifier.size(52.dp)) {
+ Box(Modifier.size(64.dp)) {
androidx.compose.animation.AnimatedVisibility(
visible = !currentIsPanelExpanded && centerRole != MaskCenterRole.Loading,
enter = fadeIn(),
@@ -385,7 +390,7 @@ fun ChannelMask(
)
}
}
- Box(Modifier.size(36.dp)) {
+ Box(Modifier.size(48.dp)) {
androidx.compose.animation.AnimatedVisibility(
visible = !currentIsPanelExpanded && adjacentChannels?.nextId != null,
enter = fadeIn() + slideInHorizontally(initialOffsetX = { it / 6 }),
@@ -443,7 +448,7 @@ fun ChannelMask(
)
Column(Modifier.alpha(alpha)) {
Text(
- text = playlistTitle.trim().uppercase(),
+ text = playlistTitle.trim(),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
color = LocalContentColor.current.copy(0.54f),
@@ -470,7 +475,7 @@ fun ChannelMask(
}
if (playStateDisplayText.isNotEmpty()) {
Text(
- text = playStateDisplayText.uppercase(),
+ text = playStateDisplayText,
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current.copy(alpha = 0.75f),
maxLines = 1,
@@ -673,6 +678,14 @@ private fun MaskCenterButton(
MaskCenterRole.Pause -> Icons.Rounded.Pause
else -> Icons.Rounded.Refresh
},
+ contentDescription = stringResource(
+ when (centerRole) {
+ MaskCenterRole.Replay -> string.ui_action_retry
+ MaskCenterRole.Play -> string.ui_action_play
+ MaskCenterRole.Pause -> string.ui_action_pause
+ MaskCenterRole.Loading -> string.ui_state_loading
+ }
+ ),
onClick = when (centerRole) {
MaskCenterRole.Replay -> onRetry
MaskCenterRole.Play -> onPlay
@@ -680,7 +693,8 @@ private fun MaskCenterButton(
else -> {
{}
}
- }
+ },
+ enabled = centerRole != MaskCenterRole.Loading,
)
}
}
@@ -712,8 +726,15 @@ private fun MaskNavigateButton(
MaskNavigateRole.Next -> Icons.Rounded.SkipNext
MaskNavigateRole.Previous -> Icons.Rounded.SkipPrevious
},
+ contentDescription = stringResource(
+ when (navigateRole) {
+ MaskNavigateRole.Next -> string.ui_action_next_channel
+ MaskNavigateRole.Previous -> string.ui_action_previous_channel
+ }
+ ),
interactionSource = interactionSource,
onClick = onClick,
+ enabled = enabled,
modifier = Modifier
.thenIf(!enabled) { Modifier.alpha(0f) }
.graphicsLayer {
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt
index d2b711a18..927cc6cb1 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/ChannelScreen.kt
@@ -112,6 +112,7 @@ fun ChannelRoute(
val isProgrammeSupported by viewModel.isProgrammeSupported.collectAsStateWithLifecycle(
initialValue = false
)
+ val supportsDirectSharing = channel?.url?.let { url -> url != Channel.URL_DYNAMIC } == true
val channels = viewModel.pagingChannels.collectAsLazyPagingItems()
val programmes = viewModel.programmes.collectAsLazyPagingItems()
@@ -299,6 +300,7 @@ fun ChannelRoute(
playlist = playlist,
adjacentChannels = adjacentChannels,
channel = channel,
+ supportsDirectSharing = supportsDirectSharing,
hasTrack = tracks.isNotEmpty(),
isPanelExpanded = isPanelExpanded,
brightness = brightness,
@@ -331,11 +333,14 @@ fun ChannelRoute(
DlnaDevicesBottomSheet(
maskState = maskState,
searching = searching,
- isDevicesVisible = isDevicesVisible,
+ isDevicesVisible = isDevicesVisible && supportsDirectSharing,
devices = devices,
connectDlnaDevice = { viewModel.connectDlnaDevice(it) },
openInExternalPlayer = {
- val channelUrl = channel?.url ?: return@DlnaDevicesBottomSheet
+ val channelUrl = channel
+ ?.url
+ ?.takeUnless { url -> url == Channel.URL_DYNAMIC }
+ ?: return@DlnaDevicesBottomSheet
context.startActivity(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(channelUrl.toUri(), "video/*")
@@ -366,6 +371,7 @@ private fun ChannelPlayer(
playerState: PlayerState,
playlist: Playlist?,
channel: Channel?,
+ supportsDirectSharing: Boolean,
adjacentChannels: AdjacentChannels?,
isSeriesPlaylist: Boolean,
hasTrack: Boolean,
@@ -469,6 +475,7 @@ private fun ChannelPlayer(
maskState = maskState,
favourite = favourite,
isSeriesPlaylist = isSeriesPlaylist,
+ supportsDirectSharing = supportsDirectSharing,
useVertical = useVertical,
hasTrack = hasTrack,
cwPosition = cwPosition,
@@ -521,4 +528,4 @@ private fun ChannelPlayer(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/MaskGestureValuePanel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/MaskGestureValuePanel.kt
index 9d205a83a..f307e9848 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/MaskGestureValuePanel.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/MaskGestureValuePanel.kt
@@ -40,7 +40,7 @@ internal fun MaskGestureValuePanel(
) {
Icon(
imageVector = icon,
- contentDescription = "icon-gesture",
+ contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(spacing.extraSmall))
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/MaskValueButton.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/MaskValueButton.kt
index a327d582b..ce50eb4ce 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/MaskValueButton.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/MaskValueButton.kt
@@ -39,7 +39,7 @@ fun MaskTextButton(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
- Text(text = contentDescription.uppercase())
+ Text(text = contentDescription)
}
}
) {
@@ -73,4 +73,4 @@ fun MaskTextButton(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt
index 0a449842d..78050bc9c 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/PlayerPanel.kt
@@ -54,9 +54,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
@@ -71,6 +75,7 @@ import com.m3u.data.database.model.Episode
import com.m3u.data.database.model.Programme
import com.m3u.data.database.model.ProgrammeRange
import com.m3u.data.service.MediaCommand
+import com.m3u.i18n.R.string
import com.m3u.smartphone.TimeUtils.formatEOrSh
import com.m3u.smartphone.TimeUtils.toEOrSh
import com.m3u.smartphone.ui.common.helper.LocalHelper
@@ -105,6 +110,8 @@ internal fun PlayerPanel(
onRequestClosed: () -> Unit,
) {
val spacing = LocalSpacing.current
+ val layoutDirection = LocalLayoutDirection.current
+ val formatLocale = LocalConfiguration.current.locales[0]
Surface(
shape = if (useVertical) AbsoluteSmoothCornerShape(
@@ -112,12 +119,21 @@ internal fun PlayerPanel(
cornerRadiusTR = spacing.medium,
smoothnessAsPercentTL = 100,
smoothnessAsPercentTR = 100
- ) else AbsoluteSmoothCornerShape(
- cornerRadiusTL = spacing.medium,
- cornerRadiusBL = spacing.medium,
- smoothnessAsPercentTL = 100,
- smoothnessAsPercentBL = 100
- ),
+ ) else if (layoutDirection == LayoutDirection.Ltr) {
+ AbsoluteSmoothCornerShape(
+ cornerRadiusTL = spacing.medium,
+ cornerRadiusBL = spacing.medium,
+ smoothnessAsPercentTL = 100,
+ smoothnessAsPercentBL = 100
+ )
+ } else {
+ AbsoluteSmoothCornerShape(
+ cornerRadiusTR = spacing.medium,
+ cornerRadiusBR = spacing.medium,
+ smoothnessAsPercentTR = 100,
+ smoothnessAsPercentBR = 100
+ )
+ },
shadowElevation = 4.dp,
modifier = modifier
) {
@@ -205,8 +221,8 @@ internal fun PlayerPanel(
modifier = Modifier.weight(1f)
) {
Text(
- text = "${start.formatEOrSh(false)} - ${
- end.formatEOrSh(false)
+ text = "${start.formatEOrSh(false, locale = formatLocale)} - ${
+ end.formatEOrSh(false, locale = formatLocale)
}",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -320,7 +336,7 @@ fun PlayerPanelImpl(
modifier = Modifier.basicMarquee()
)
Text(
- text = playlistTitle.trim().uppercase(),
+ text = playlistTitle.trim(),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
color = LocalContentColor.current.copy(0.54f),
@@ -329,11 +345,14 @@ fun PlayerPanelImpl(
modifier = Modifier.basicMarquee()
)
}
- Icon(
+ IconButton(
+ onClick = onRequestClosed,
+ ) {
+ Icon(
imageVector = Icons.Rounded.Close,
- contentDescription = null,
- modifier = Modifier.clickable { onRequestClosed() }
- )
+ contentDescription = stringResource(string.ui_action_close_player_panel),
+ )
+ }
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/ProgrammeGuide.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/ProgrammeGuide.kt
index aba201c5e..992fd9dc5 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/ProgrammeGuide.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/channel/components/ProgrammeGuide.kt
@@ -52,7 +52,9 @@ import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAny
@@ -65,6 +67,7 @@ import com.m3u.core.foundation.architecture.preferences.preferenceOf
import com.m3u.data.database.model.Programme
import com.m3u.data.database.model.ProgrammeRange
import com.m3u.data.database.model.ProgrammeRange.Companion.HOUR_LENGTH
+import com.m3u.i18n.R.string
import com.m3u.smartphone.TimeUtils.formatEOrSh
import com.m3u.smartphone.TimeUtils.toEOrSh
import com.m3u.smartphone.ui.material.components.FontFamilies
@@ -316,6 +319,7 @@ private fun ProgrammeCell(
val currentOnPressed by rememberUpdatedState(onPressed)
val spacing = LocalSpacing.current
val clockMode by preferenceOf(PreferencesKeys.CLOCK_MODE)
+ val formatLocale = LocalConfiguration.current.locales[0]
val content = @Composable {
Column(
@@ -331,7 +335,9 @@ private fun ProgrammeCell(
.toLocalDateTime(TimeZone.currentSystemDefault())
.toEOrSh()
Text(
- text = "${start.formatEOrSh(clockMode)} - ${end.formatEOrSh(clockMode)}",
+ text = "${start.formatEOrSh(clockMode, locale = formatLocale)} - ${
+ end.formatEOrSh(clockMode, locale = formatLocale)
+ }",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
@@ -418,15 +424,20 @@ private fun CurrentTimelineCell(
val spacing = LocalSpacing.current
val twelveHourClock by preferenceOf(PreferencesKeys.CLOCK_MODE)
+ val formatLocale = LocalConfiguration.current.locales[0]
val color = MaterialTheme.colorScheme.error
val contentColor = MaterialTheme.colorScheme.onError
val currentMilliseconds by rememberUpdatedState(milliseconds)
- val time = remember(currentMilliseconds) {
+ val time = remember(currentMilliseconds, twelveHourClock, formatLocale) {
Instant
.fromEpochMilliseconds(currentMilliseconds)
.toLocalDateTime(TimeZone.currentSystemDefault())
- .formatEOrSh(twelveHourClock, ignoreSeconds = false)
+ .formatEOrSh(
+ twelveHourClock = twelveHourClock,
+ ignoreSeconds = false,
+ locale = formatLocale,
+ )
}
Box(contentAlignment = Alignment.CenterEnd) {
Canvas(
@@ -484,7 +495,7 @@ private fun Controls(
) {
Icon(
imageVector = Icons.Rounded.KeyboardDoubleArrowUp,
- contentDescription = "scroll to current timeline"
+ contentDescription = stringResource(string.ui_action_scroll_to_current_timeline)
)
}
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/PlaylistConfigurationNavigation.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/PlaylistConfigurationNavigation.kt
index 58e14b860..0b2862bf1 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/PlaylistConfigurationNavigation.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/PlaylistConfigurationNavigation.kt
@@ -13,6 +13,8 @@ import com.m3u.business.playlist.configuration.PlaylistConfigurationNavigation
fun NavGraphBuilder.playlistConfigurationScreen(
contentPadding: PaddingValues = PaddingValues(),
+ onBack: () -> Unit,
+ onPlaylistRemoved: () -> Unit,
) {
composable(
route = PlaylistConfigurationNavigation.PLAYLIST_CONFIGURATION_ROUTE,
@@ -27,7 +29,10 @@ fun NavGraphBuilder.playlistConfigurationScreen(
popExitTransition = { slideOutVertically { it } }
) {
PlaylistConfigurationRoute(
- contentPadding = contentPadding
+ contentPadding = contentPadding,
+ manageAppChrome = true,
+ onBack = onBack,
+ onPlaylistRemoved = onPlaylistRemoved,
)
}
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/PlaylistConfigurationScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/PlaylistConfigurationScreen.kt
index 122ddc977..dc9269b1f 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/PlaylistConfigurationScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/PlaylistConfigurationScreen.kt
@@ -1,264 +1,913 @@
package com.m3u.smartphone.ui.business.configuration
import android.Manifest
-import android.content.Intent
import android.os.Build
-import android.provider.Settings
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.animateDpAsState
-import androidx.compose.animation.expandIn
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.shrinkOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.automirrored.rounded.List
+import androidx.compose.material.icons.rounded.Cloud
+import androidx.compose.material.icons.rounded.DateRange
+import androidx.compose.material.icons.rounded.DeleteOutline
+import androidx.compose.material.icons.rounded.Extension
+import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.Save
-import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.rememberPermissionState
import com.m3u.business.playlist.configuration.EpgManifest
+import com.m3u.business.playlist.configuration.PlaylistConfigurationState
import com.m3u.business.playlist.configuration.PlaylistConfigurationViewModel
-import com.m3u.core.foundation.util.basic.title
+import com.m3u.business.playlist.configuration.PlaylistRefreshStatus
+import com.m3u.business.playlist.configuration.PlaylistRemovalState
+import com.m3u.business.setting.ProviderDiscoveryState
import com.m3u.core.foundation.wrapper.Resource
+import com.m3u.core.foundation.util.basic.PlaylistInputKind
+import com.m3u.core.foundation.util.basic.sanitizePlaylistInput
import com.m3u.data.database.model.DataSource
import com.m3u.data.database.model.Playlist
import com.m3u.data.database.model.epgUrlsOrXtreamXmlUrl
+import com.m3u.data.database.model.refreshable
import com.m3u.data.parser.xtream.XtreamUserInfo
import com.m3u.data.repository.playlist.PlaylistRepository
import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.business.configuration.components.AutoSyncProgrammesButton
import com.m3u.smartphone.ui.business.configuration.components.EpgManifestGallery
+import com.m3u.smartphone.ui.business.configuration.components.RefreshPlaylistButton
import com.m3u.smartphone.ui.business.configuration.components.SyncProgrammesButton
import com.m3u.smartphone.ui.business.configuration.components.XtreamPanel
-import com.m3u.smartphone.ui.common.helper.LocalHelper
+import com.m3u.smartphone.ui.common.helper.Fob
import com.m3u.smartphone.ui.common.helper.Metadata
import com.m3u.smartphone.ui.material.components.Background
-import com.m3u.smartphone.ui.material.components.PlaceholderField
-import com.m3u.smartphone.ui.material.ktx.checkPermissionOrRationale
-import com.m3u.smartphone.ui.material.model.LocalHazeState
-import com.m3u.smartphone.ui.material.model.LocalSpacing
-import dev.chrisbanes.haze.hazeSource
+import com.m3u.smartphone.ui.material.components.Destination
+import com.m3u.smartphone.ui.material.ktx.plus
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.safeDisplayText
+import com.m3u.smartphone.ui.material.ktx.safeSourceReference
import kotlinx.datetime.LocalDateTime
+private val PlaylistConfigurationMaxWidth = 720.dp
+
@Composable
internal fun PlaylistConfigurationRoute(
modifier: Modifier = Modifier,
viewModel: PlaylistConfigurationViewModel = hiltViewModel(),
- contentPadding: PaddingValues = PaddingValues()
+ contentPadding: PaddingValues = PaddingValues(),
+ playlistReference: String? = null,
+ providerDisplayNameOverride: String? = null,
+ manageAppChrome: Boolean = false,
+ onBack: (() -> Unit)? = null,
+ onPlaylistRemoved: () -> Unit = {},
) {
- val helper = LocalHelper.current
-
val permissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
} else {
null
}
- val playlist by viewModel.playlist.collectAsStateWithLifecycle()
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val providerAccountSummary by
+ viewModel.providerAccountSummary.collectAsStateWithLifecycle()
+ val discoveredProviders by
+ viewModel.discoveredProviders.collectAsStateWithLifecycle()
val manifest by viewModel.manifest.collectAsStateWithLifecycle()
- val subscribingOrRefreshingWorkInfo by viewModel.subscribingOrRefreshingWorkInfo.collectAsStateWithLifecycle()
+ val playlistRefreshStatus by
+ viewModel.playlistRefreshStatus.collectAsStateWithLifecycle()
+ val programmeRefreshStatus by
+ viewModel.programmeRefreshStatus.collectAsStateWithLifecycle()
+ val playlistRemovalState by
+ viewModel.playlistRemovalState.collectAsStateWithLifecycle()
val expired by viewModel.expired.collectAsStateWithLifecycle()
val xtreamUserInfo by viewModel.xtreamUserInfo.collectAsStateWithLifecycle()
- LifecycleResumeEffect(playlist?.title) {
- Metadata.title = AnnotatedString(playlist?.title?.title().orEmpty())
- Metadata.color = Color.Unspecified
- Metadata.contentColor = Color.Unspecified
- onPauseOrDispose {
+ LaunchedEffect(viewModel, playlistReference) {
+ playlistReference?.let(viewModel::openPlaylistReference)
+ }
+
+ val localeTag = LocalConfiguration.current.locales[0].toLanguageTag()
+ LaunchedEffect(
+ viewModel,
+ providerDisplayNameOverride,
+ providerAccountSummary,
+ localeTag,
+ ) {
+ if (
+ providerDisplayNameOverride == null &&
+ providerAccountSummary != null
+ ) {
+ viewModel.refreshProviderCatalog(localeTag)
}
}
- playlist?.let {
- PlaylistConfigurationScreen(
- playlist = it,
- manifest = manifest,
- subscribingOrRefreshing = subscribingOrRefreshingWorkInfo != null,
- expired = expired,
- xtreamUserInfo = xtreamUserInfo,
- onUpdatePlaylistTitle = viewModel::onUpdatePlaylistTitle,
- onUpdatePlaylistUserAgent = viewModel::onUpdatePlaylistUserAgent,
- onUpdateEpgPlaylist = viewModel::onUpdateEpgPlaylist,
- onUpdatePlaylistAutoRefreshProgrammes = viewModel::onUpdatePlaylistAutoRefreshProgrammes,
- onSyncProgrammes = {
- if (permissionState == null) {
- viewModel.onSyncProgrammes()
- return@PlaylistConfigurationScreen
- }
- permissionState.checkPermissionOrRationale(
- showRationale = {
- val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
- .apply {
- putExtra(
- Settings.EXTRA_APP_PACKAGE,
- helper.activityContext.packageName
- )
- }
- helper.activityContext.startActivity(intent)
+ val currentOnPlaylistRemoved by rememberUpdatedState(onPlaylistRemoved)
+ LaunchedEffect(viewModel, playlistRemovalState) {
+ if (playlistRemovalState == PlaylistRemovalState.REMOVED) {
+ currentOnPlaylistRemoved()
+ viewModel.acknowledgePlaylistRemoval()
+ }
+ }
+
+ val visibleState = if (
+ playlistReference == null ||
+ state.playlistReference == playlistReference
+ ) {
+ state
+ } else {
+ PlaylistConfigurationState.Loading
+ }
+ val playlist = (visibleState as? PlaylistConfigurationState.Content)?.playlist
+ val resolvedProviderDisplayName = providerDisplayNameOverride
+ ?.takeIf(String::isNotBlank)
+ ?: providerAccountSummary?.let { account ->
+ providerDisplayName(
+ account = account,
+ discoveryState = discoveredProviders
+ .takeIf { providers -> providers.isNotEmpty() }
+ ?.let { providers ->
+ ProviderDiscoveryState.Ready(providers)
},
- block = {
- viewModel.onSyncProgrammes()
+ )
+ }
+ val fallbackTitle = stringResource(string.feat_setting_playlist_management)
+
+ if (manageAppChrome) {
+ LifecycleResumeEffect(playlist?.title, fallbackTitle, onBack) {
+ Metadata.title = AnnotatedString(
+ playlist
+ ?.title
+ ?.safeDisplayText()
+ ?.takeIf(String::isNotBlank)
+ ?: fallbackTitle
+ )
+ Metadata.actions = emptyList()
+ Metadata.headlineUrl = ""
+ Metadata.color = Color.Unspecified
+ Metadata.contentColor = Color.Unspecified
+ val backAction = onBack?.let { navigateBack ->
+ Fob(
+ destination = Destination.Foryou,
+ icon = Icons.AutoMirrored.Rounded.ArrowBack,
+ iconTextId = string.ui_cd_top_bar_on_back_pressed,
+ onClick = navigateBack,
+ )
+ }
+ Metadata.fob = backAction
+ onPauseOrDispose {
+ if (Metadata.fob === backAction) {
+ Metadata.fob = null
+ }
+ }
+ }
+ }
+
+ when (val currentState = visibleState) {
+ PlaylistConfigurationState.Loading -> {
+ PlaylistConfigurationLoading(
+ modifier = modifier,
+ contentPadding = contentPadding,
+ )
+ }
+
+ is PlaylistConfigurationState.NotFound -> {
+ PlaylistConfigurationUnavailable(
+ onReturn = onBack ?: onPlaylistRemoved,
+ modifier = modifier,
+ contentPadding = contentPadding,
+ )
+ }
+
+ is PlaylistConfigurationState.Content -> {
+ PlaylistConfigurationScreen(
+ playlist = currentState.playlist,
+ providerDisplayName = resolvedProviderDisplayName,
+ manifest = manifest,
+ playlistRefreshStatus = playlistRefreshStatus,
+ programmeRefreshStatus = programmeRefreshStatus,
+ removingPlaylist =
+ playlistRemovalState == PlaylistRemovalState.REMOVING,
+ playlistRemovalFailed =
+ playlistRemovalState == PlaylistRemovalState.FAILED,
+ expired = expired,
+ xtreamUserInfo = xtreamUserInfo,
+ onUpdatePlaylistTitle = viewModel::onUpdatePlaylistTitle,
+ onUpdatePlaylistUserAgent = viewModel::onUpdatePlaylistUserAgent,
+ onUpdateEpgPlaylist = viewModel::onUpdateEpgPlaylist,
+ onUpdatePlaylistAutoRefreshProgrammes =
+ viewModel::onUpdatePlaylistAutoRefreshProgrammes,
+ onRefreshPlaylist = {
+ val refreshUsesForegroundNotification =
+ currentState.playlist.source == DataSource.M3U ||
+ currentState.playlist.source == DataSource.Xtream
+ if (
+ refreshUsesForegroundNotification &&
+ permissionState?.status is PermissionStatus.Denied
+ ) {
+ permissionState.launchPermissionRequest()
}
+ viewModel.onRefreshPlaylist()
+ },
+ onCancelRefreshPlaylist = viewModel::onCancelRefreshPlaylist,
+ onSyncProgrammes = {
+ if (permissionState?.status is PermissionStatus.Denied) {
+ permissionState.launchPermissionRequest()
+ }
+ viewModel.onSyncProgrammes()
+ },
+ onCancelSyncProgrammes = viewModel::onCancelSyncProgrammes,
+ onRemovePlaylist = viewModel::onRemovePlaylist,
+ onDismissRemovalFailure = viewModel::clearPlaylistRemovalFailure,
+ modifier = modifier,
+ contentPadding = contentPadding
+ )
+ }
+ }
+}
+
+@Composable
+private fun PlaylistConfigurationLoading(
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ val loadingLabel = stringResource(string.ui_state_loading)
+ Background(modifier) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .testTag("playlist-configuration-loading")
+ .semantics {
+ liveRegion = LiveRegionMode.Polite
+ stateDescription = loadingLabel
+ },
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(32.dp)
+ .clearAndSetSemantics {},
+ strokeWidth = 3.dp,
)
- },
- onCancelSyncProgrammes = viewModel::onCancelSyncProgrammes,
- modifier = modifier,
- contentPadding = contentPadding
- )
+ Text(
+ text = loadingLabel,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlaylistConfigurationUnavailable(
+ onReturn: () -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ Background(modifier) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .padding(horizontal = 24.dp, vertical = 16.dp)
+ .testTag("playlist-configuration-unavailable"),
+ ) {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ shape = MaterialTheme.shapes.extraLarge,
+ modifier = Modifier
+ .widthIn(max = 480.dp)
+ .fillMaxWidth(),
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(24.dp),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.List,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(40.dp),
+ )
+ Text(
+ text = stringResource(
+ string.feat_playlist_configuration_unavailable_title
+ ),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.semantics { heading() },
+ )
+ Text(
+ text = stringResource(
+ string.feat_playlist_configuration_unavailable_description
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Button(
+ onClick = onReturn,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp)
+ .testTag("playlist-configuration-return"),
+ ) {
+ Text(
+ stringResource(
+ string.feat_playlist_configuration_return_to_management
+ )
+ )
+ }
+ }
+ }
+ }
}
}
@Composable
private fun PlaylistConfigurationScreen(
playlist: Playlist,
+ providerDisplayName: String?,
manifest: EpgManifest,
- subscribingOrRefreshing: Boolean,
+ playlistRefreshStatus: PlaylistRefreshStatus,
+ programmeRefreshStatus: PlaylistRefreshStatus,
+ removingPlaylist: Boolean,
+ playlistRemovalFailed: Boolean,
expired: LocalDateTime?,
xtreamUserInfo: Resource,
onUpdatePlaylistTitle: (String) -> Unit,
onUpdatePlaylistUserAgent: (String?) -> Unit,
onUpdateEpgPlaylist: (PlaylistRepository.EpgPlaylistUseCase) -> Unit,
onUpdatePlaylistAutoRefreshProgrammes: () -> Unit,
+ onRefreshPlaylist: () -> Unit,
+ onCancelRefreshPlaylist: () -> Unit,
onSyncProgrammes: () -> Unit,
onCancelSyncProgrammes: () -> Unit,
+ onRemovePlaylist: () -> Unit,
+ onDismissRemovalFailure: () -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues()
) {
- val spacing = LocalSpacing.current
-
- var title: String by remember(playlist.title) { mutableStateOf(playlist.title) }
- var userAgent: String by remember(playlist.userAgent) { mutableStateOf(playlist.userAgent.orEmpty()) }
-
- val hasChanged by remember(playlist.title, playlist.userAgent) {
- derivedStateOf { title != playlist.title || userAgent != playlist.userAgent.orEmpty() }
+ val persistedTitle = remember(playlist.title) {
+ playlist.title.sanitizePlaylistInput(PlaylistInputKind.TITLE)
+ }
+ val persistedUserAgent = remember(playlist.userAgent) {
+ playlist.userAgent.orEmpty().sanitizePlaylistInput(
+ PlaylistInputKind.USER_AGENT
+ )
+ }
+ var title by remember(playlist.title) { mutableStateOf(persistedTitle) }
+ var userAgent by remember(playlist.userAgent) {
+ mutableStateOf(persistedUserAgent)
+ }
+ var showRemoveConfirmation by remember(playlist.url) {
+ mutableStateOf(false)
+ }
+ val normalizedTitle by remember {
+ derivedStateOf { title.trim() }
+ }
+ val titleInvalid by remember {
+ derivedStateOf { normalizedTitle.isEmpty() }
+ }
+ val hasChanged by remember(persistedTitle, persistedUserAgent) {
+ derivedStateOf {
+ normalizedTitle != persistedTitle || userAgent != persistedUserAgent
+ }
}
- Background(modifier) {
- Box {
- LazyColumn(
- verticalArrangement = Arrangement.spacedBy(spacing.small),
- contentPadding = contentPadding,
- modifier = Modifier
- .hazeSource(LocalHazeState.current)
- .fillMaxSize()
- .padding(spacing.medium)
- ) {
- item {
- PlaceholderField(
- text = title,
- placeholder = stringResource(string.feat_playlist_configuration_title).title(),
- onValueChange = { title = it },
- )
- }
- item {
- PlaceholderField(
- text = userAgent,
- placeholder = stringResource(string.feat_playlist_configuration_user_agent).title(),
- onValueChange = { userAgent = it }
- )
- }
+ Background(modifier) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .imePadding()
+ .testTag("playlist-configuration"),
+ contentPadding = contentPadding + PaddingValues(
+ horizontal = 16.dp,
+ vertical = 16.dp,
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item(key = "identity-and-details") {
+ PlaylistDetailsSection(
+ playlist = playlist,
+ providerDisplayName = providerDisplayName,
+ title = title,
+ userAgent = userAgent,
+ saveEnabled = hasChanged && !titleInvalid,
+ titleInvalid = titleInvalid,
+ onTitleChange = {
+ title = it.sanitizePlaylistInput(PlaylistInputKind.TITLE)
+ },
+ onUserAgentChange = {
+ userAgent = it.sanitizePlaylistInput(
+ PlaylistInputKind.USER_AGENT
+ )
+ },
+ onSave = {
+ if (normalizedTitle != persistedTitle) {
+ onUpdatePlaylistTitle(normalizedTitle)
+ }
+ if (userAgent != persistedUserAgent) {
+ onUpdatePlaylistUserAgent(userAgent)
+ }
+ },
+ modifier = Modifier.configurationPageWidth(),
+ )
+ }
- item {
- AnimatedVisibility(
- visible = playlist.epgUrlsOrXtreamXmlUrl().isNotEmpty(),
- enter = fadeIn() + expandIn(
- expandFrom = Alignment.BottomCenter,
- initialSize = { IntSize(it.width, 0) }
+ if (
+ playlist.refreshable ||
+ playlist.epgUrlsOrXtreamXmlUrl().isNotEmpty()
+ ) {
+ item(key = "updates") {
+ ConfigurationSection(
+ heading = stringResource(
+ string.feat_playlist_configuration_updates
),
- exit = fadeOut() + shrinkOut(
- shrinkTowards = Alignment.BottomCenter,
- targetSize = { IntSize(it.width, 0) }
- )
+ modifier = Modifier.configurationPageWidth(),
) {
- Column(
- verticalArrangement = Arrangement.spacedBy(spacing.small)
- ) {
+ if (playlist.refreshable) {
+ RefreshPlaylistButton(
+ status = playlistRefreshStatus,
+ onRefresh = onRefreshPlaylist,
+ onCancel = onCancelRefreshPlaylist,
+ )
+ }
+ if (playlist.epgUrlsOrXtreamXmlUrl().isNotEmpty()) {
+ if (playlist.refreshable) {
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp)
+ )
+ }
SyncProgrammesButton(
- subscribingOrRefreshing = subscribingOrRefreshing,
+ status = programmeRefreshStatus,
expired = expired,
onSyncProgrammes = onSyncProgrammes,
- onCancelSyncProgrammes = onCancelSyncProgrammes
+ onCancelSyncProgrammes = onCancelSyncProgrammes,
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp)
)
AutoSyncProgrammesButton(
checked = playlist.autoRefreshProgrammes,
- onCheckedChange = onUpdatePlaylistAutoRefreshProgrammes
+ onCheckedChange =
+ onUpdatePlaylistAutoRefreshProgrammes,
)
}
}
}
+ }
- if (playlist.source == DataSource.M3U) {
+ if (playlist.source == DataSource.M3U) {
+ item(key = "epg-manifest") {
EpgManifestGallery(
playlistUrl = playlist.url,
manifest = manifest,
- onUpdateEpgPlaylist = onUpdateEpgPlaylist
+ onUpdateEpgPlaylist = onUpdateEpgPlaylist,
+ modifier = Modifier.configurationPageWidth(),
)
}
+ }
- if (playlist.source == DataSource.Xtream) {
- item {
+ if (playlist.source == DataSource.Xtream) {
+ item(key = "xtream-account") {
+ Column(
+ modifier = Modifier.configurationPageWidth(),
+ ) {
+ ConfigurationSectionHeading(
+ text = stringResource(DataSource.Xtream.resId),
+ )
XtreamPanel(
info = xtreamUserInfo,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.fillMaxWidth(),
)
}
}
}
- val fabBottomPadding by animateDpAsState(
- targetValue = maxOf(
- WindowInsets.ime.asPaddingValues().calculateBottomPadding(),
- contentPadding.calculateBottomPadding()
+ item(key = "remove-source") {
+ ConfigurationSection(
+ heading = stringResource(
+ string.feat_playlist_configuration_remove_section
+ ),
+ modifier = Modifier.configurationPageWidth(),
+ ) {
+ PlaylistRemovalRow(
+ enabled = !removingPlaylist,
+ onClick = {
+ onDismissRemovalFailure()
+ showRemoveConfirmation = true
+ },
+ )
+ }
+ }
+ }
+ }
+
+ if (showRemoveConfirmation) {
+ val title = rememberUiBidiFormatter().natural(playlist.title)
+ val loadingDescription = stringResource(string.ui_state_loading)
+ AlertDialog(
+ onDismissRequest = {
+ if (!removingPlaylist) {
+ onDismissRemovalFailure()
+ showRemoveConfirmation = false
+ }
+ },
+ title = {
+ Text(
+ stringResource(
+ string.feat_playlist_configuration_remove_playlist_title
+ )
+ )
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ stringResource(
+ string.feat_playlist_configuration_remove_playlist_message,
+ title,
+ )
+ )
+ if (playlistRemovalFailed) {
+ Text(
+ text = stringResource(string.ui_error_unknown),
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.testTag(
+ "playlist-configuration-remove-error"
+ ),
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = onRemovePlaylist,
+ enabled = !removingPlaylist,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error,
+ ),
+ modifier = Modifier.testTag(
+ "playlist-configuration-remove-confirm"
+ ).semantics {
+ if (removingPlaylist) {
+ liveRegion = LiveRegionMode.Polite
+ stateDescription = loadingDescription
+ }
+ },
+ ) {
+ if (removingPlaylist) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(18.dp)
+ .clearAndSetSemantics {},
+ color = LocalContentColor.current,
+ strokeWidth = 2.dp,
+ )
+ Spacer(Modifier.size(8.dp))
+ }
+ Text(
+ stringResource(
+ string.feat_playlist_configuration_remove_playlist
+ )
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ onDismissRemovalFailure()
+ showRemoveConfirmation = false
+ },
+ enabled = !removingPlaylist,
+ ) {
+ Text(stringResource(android.R.string.cancel))
+ }
+ },
+ modifier = Modifier.testTag("playlist-configuration-remove-dialog"),
+ )
+ }
+}
+
+@Composable
+private fun PlaylistRemovalRow(
+ enabled: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val actionLabel = stringResource(
+ string.feat_playlist_configuration_remove_playlist
+ )
+ ListItem(
+ headlineContent = {
+ Text(
+ text = actionLabel,
+ )
+ },
+ supportingContent = {
+ Text(
+ text = stringResource(
+ string.feat_playlist_configuration_remove_playlist_description
),
- label = "apply changes bottom padding"
)
- AnimatedVisibility(
- visible = hasChanged,
- enter = fadeIn() + slideInVertically { it },
- exit = fadeOut() + slideOutVertically { it },
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Rounded.DeleteOutline,
+ contentDescription = null,
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ headlineColor = MaterialTheme.colorScheme.error,
+ supportingColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ leadingIconColor = MaterialTheme.colorScheme.error,
+ ),
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .alpha(if (enabled) 1f else 0.38f)
+ .clickable(
+ enabled = enabled,
+ role = Role.Button,
+ onClickLabel = actionLabel,
+ onClick = onClick,
+ )
+ .semantics { role = Role.Button }
+ .testTag("playlist-configuration-remove"),
+ )
+}
+
+@Composable
+private fun PlaylistDetailsSection(
+ playlist: Playlist,
+ providerDisplayName: String?,
+ title: String,
+ userAgent: String,
+ saveEnabled: Boolean,
+ titleInvalid: Boolean,
+ onTitleChange: (String) -> Unit,
+ onUserAgentChange: (String) -> Unit,
+ onSave: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val bidiFormatter = rememberUiBidiFormatter()
+ val focusManager = LocalFocusManager.current
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val sourceLabel = providerDisplayName
+ ?.takeIf(String::isNotBlank)
+ ?: stringResource(playlist.source.resId)
+ val sourceIcon = playlist.source.configurationIcon()
+ val displayReference = remember(playlist.url, playlist.source) {
+ if (
+ playlist.source == DataSource.Provider ||
+ playlist.source == DataSource.Emby ||
+ playlist.source == DataSource.Jellyfin
+ ) {
+ null
+ } else {
+ playlist.url.safeSourceReference()
+ }
+ }
+ val titleLabel = stringResource(
+ string.feat_playlist_configuration_title
+ )
+ val titleError = stringResource(string.feat_setting_error_empty_title)
+ val userAgentLabel = stringResource(
+ string.feat_playlist_configuration_user_agent
+ )
+ val saveLabel = stringResource(string.ui_action_save_changes)
+
+ Surface(
+ modifier = modifier,
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = playlist.title.safeDisplayText(),
+ style = MaterialTheme.typography.titleMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ supportingContent = {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = sourceLabel,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ displayReference?.let { reference ->
+ Text(
+ text = bidiFormatter.ltr(reference),
+ style = MaterialTheme.typography.bodySmall.copy(
+ textDirection = TextDirection.Ltr,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ },
+ leadingContent = {
+ Icon(
+ imageVector = sourceIcon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ supportingColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ leadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(bottom = fabBottomPadding)
+ .fillMaxWidth()
+ .heightIn(min = 72.dp)
+ .testTag("playlist-configuration-identity"),
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
+
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
) {
- FloatingActionButton(
+ OutlinedTextField(
+ value = title,
+ onValueChange = onTitleChange,
+ label = { Text(titleLabel) },
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ singleLine = true,
+ isError = titleInvalid,
+ supportingText = if (titleInvalid) {
+ { Text(titleError) }
+ } else {
+ null
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("playlist-configuration-title"),
+ )
+ OutlinedTextField(
+ value = userAgent,
+ onValueChange = onUserAgentChange,
+ label = { Text(userAgentLabel) },
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ textDirection = TextDirection.Ltr,
+ ),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("playlist-configuration-user-agent"),
+ )
+ Button(
onClick = {
- if (title != playlist.title) onUpdatePlaylistTitle(title)
- if (userAgent != playlist.userAgent) onUpdatePlaylistUserAgent(userAgent)
+ onSave()
+ focusManager.clearFocus(force = true)
+ keyboardController?.hide()
},
- modifier = Modifier.padding(spacing.medium)
+ enabled = saveEnabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp)
+ .testTag("playlist-configuration-save"),
) {
Icon(
imageVector = Icons.Rounded.Save,
- contentDescription = "apply changes"
+ contentDescription = null,
+ )
+ Text(
+ text = saveLabel,
+ modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
}
+
+@Composable
+private fun ConfigurationSection(
+ heading: String,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(modifier = modifier) {
+ ConfigurationSectionHeading(text = heading)
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column(content = content)
+ }
+ }
+}
+
+@Composable
+private fun ConfigurationSectionHeading(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 8.dp)
+ .semantics { heading() },
+ )
+}
+
+private fun Modifier.configurationPageWidth(): Modifier = widthIn(
+ max = PlaylistConfigurationMaxWidth,
+).fillMaxWidth()
+
+private fun DataSource.configurationIcon(): ImageVector = when (this) {
+ DataSource.M3U -> Icons.Rounded.Link
+ DataSource.EPG -> Icons.Rounded.DateRange
+ DataSource.Xtream -> Icons.Rounded.Cloud
+ DataSource.Emby, DataSource.Jellyfin, DataSource.Provider ->
+ Icons.Rounded.Extension
+ else -> Icons.AutoMirrored.Rounded.List
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/ProviderDisplayName.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/ProviderDisplayName.kt
new file mode 100644
index 000000000..b369bdf55
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/ProviderDisplayName.kt
@@ -0,0 +1,33 @@
+package com.m3u.smartphone.ui.business.configuration
+
+import com.m3u.business.setting.ProviderDiscoveryState
+import com.m3u.data.repository.provider.ProviderAccountSummary
+import com.m3u.smartphone.ui.material.ktx.safeDisplayText
+
+internal fun providerDisplayName(
+ account: ProviderAccountSummary,
+ discoveryState: ProviderDiscoveryState?,
+): String {
+ val descriptor = (discoveryState as? ProviderDiscoveryState.Ready)
+ ?.providers
+ .orEmpty()
+ .firstOrNull { provider ->
+ provider.descriptor.providerId == account.providerId
+ }
+ ?.descriptor
+ val variantName = descriptor
+ ?.variants
+ ?.firstOrNull { variant -> variant.kind == account.providerKind }
+ ?.displayName
+
+ return sequenceOf(
+ variantName,
+ descriptor?.displayName,
+ account.serverName,
+ account.providerKind.value,
+ )
+ .filterNotNull()
+ .map { value -> value.safeDisplayText() }
+ .firstOrNull(String::isNotBlank)
+ .orEmpty()
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/AutoSyncProgrammesButton.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/AutoSyncProgrammesButton.kt
index 8d268fd08..410ff0b9b 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/AutoSyncProgrammesButton.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/AutoSyncProgrammesButton.kt
@@ -1,23 +1,24 @@
package com.m3u.smartphone.ui.business.configuration.components
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.DateRange
+import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
-import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
-import com.m3u.core.foundation.util.basic.title
import com.m3u.i18n.R
-import com.m3u.smartphone.ui.material.components.SelectionsDefaults
-import com.m3u.smartphone.ui.material.model.LocalSpacing
-import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
@Composable
internal fun AutoSyncProgrammesButton(
@@ -25,41 +26,46 @@ internal fun AutoSyncProgrammesButton(
onCheckedChange: () -> Unit,
modifier: Modifier = Modifier
) {
- val spacing = LocalSpacing.current
ListItem(
headlineContent = {
Text(
- text = stringResource(R.string.feat_playlist_configuration_auto_refresh_programmes).title(),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ text = stringResource(
+ R.string.feat_playlist_configuration_auto_refresh_programmes
+ ),
)
},
supportingContent = {
Text(
- text = stringResource(R.string.feat_playlist_configuration_auto_refresh_programmes_description),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ text = stringResource(
+ R.string.feat_playlist_configuration_auto_refresh_programmes_description
+ ),
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Rounded.DateRange,
+ contentDescription = null,
)
},
trailingContent = {
Switch(
checked = checked,
- onCheckedChange = null
+ onCheckedChange = null,
)
},
colors = ListItemDefaults.colors(
- supportingColor = LocalContentColor.current.copy(0.38f)
+ containerColor = Color.Transparent,
+ supportingColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ leadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
- modifier = Modifier
- .border(
- 1.dp,
- LocalContentColor.current.copy(0.38f),
- SelectionsDefaults.Shape
- )
- .clip(AbsoluteSmoothCornerShape(spacing.medium, 65))
- .clickable(
- onClick = onCheckedChange
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .toggleable(
+ value = checked,
+ role = Role.Switch,
+ onValueChange = { onCheckedChange() },
)
- .then(modifier)
+ .testTag("playlist-configuration-auto-sync"),
)
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/EpgManifestGallery.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/EpgManifestGallery.kt
index 74fd6274b..afc0bdd12 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/EpgManifestGallery.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/EpgManifestGallery.kt
@@ -1,80 +1,98 @@
package com.m3u.smartphone.ui.business.configuration.components
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyListScope
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
-import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m3u.business.playlist.configuration.EpgManifest
-import com.m3u.core.foundation.util.basic.title
import com.m3u.data.database.model.Playlist
import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.data.worker.playlistWorkTag
import com.m3u.i18n.R.string
-import com.m3u.smartphone.ui.material.components.SelectionsDefaults
-import com.m3u.smartphone.ui.material.model.LocalSpacing
-import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.safeSourceReference
+import com.m3u.smartphone.ui.material.ktx.safeDisplayText
-internal fun LazyListScope.EpgManifestGallery(
+@Composable
+internal fun EpgManifestGallery(
playlistUrl: String,
manifest: EpgManifest,
- onUpdateEpgPlaylist: (PlaylistRepository.EpgPlaylistUseCase) -> Unit
+ onUpdateEpgPlaylist: (PlaylistRepository.EpgPlaylistUseCase) -> Unit,
+ modifier: Modifier = Modifier,
) {
- stickyHeader {
- val spacing = LocalSpacing.current
+ Column(modifier = modifier) {
Text(
- text = stringResource(string.feat_playlist_configuration_enabled_epgs).title(),
- style = MaterialTheme.typography.titleMedium,
+ text = stringResource(
+ string.feat_playlist_configuration_enabled_epgs
+ ),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
modifier = Modifier
- .background(MaterialTheme.colorScheme.surface)
.fillMaxWidth()
.padding(
- horizontal = spacing.medium,
- vertical = spacing.small
+ start = 16.dp,
+ end = 16.dp,
+ top = 12.dp,
+ bottom = 8.dp,
)
+ .semantics { heading() },
)
- }
- items(manifest.entries.toList()) { (epg, isChecked) ->
- Row(
- verticalAlignment = Alignment.CenterVertically
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
-// val isVisible = remember(manifest, epg) {
-// isChecked && manifest.entries.firstOrNull { it.value }?.key != epg
-// }
-// AnimatedVisibility(
-// visible = isVisible
-// ) {
-// IconButton(
-// icon = Icons.Rounded.ArrowUpward,
-// contentDescription = null,
-// onClick = {
-// onUpdateEpgPlaylist(
-// PlaylistRepository.EpgPlaylistUseCase.Upward(playlistUrl, epg.url)
-// )
-// }
-// )
-// }
- EpgManifestGalleryItem(
- playlistUrl = playlistUrl,
- epg = epg,
- isChecked = isChecked,
- onUpdateEpgPlaylist = onUpdateEpgPlaylist
- )
+ Column {
+ if (manifest.isEmpty()) {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = stringResource(
+ string.feat_setting_playlist_epg_sources_empty
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ } else {
+ manifest.entries.forEachIndexed { index, (epg, isChecked) ->
+ EpgManifestGalleryItem(
+ playlistUrl = playlistUrl,
+ epg = epg,
+ isChecked = isChecked,
+ onUpdateEpgPlaylist = onUpdateEpgPlaylist,
+ )
+ if (index != manifest.size - 1) {
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 16.dp)
+ )
+ }
+ }
+ }
+ }
}
}
}
@@ -85,51 +103,62 @@ private fun EpgManifestGalleryItem(
epg: Playlist,
isChecked: Boolean,
onUpdateEpgPlaylist: (PlaylistRepository.EpgPlaylistUseCase) -> Unit,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
) {
- val spacing = LocalSpacing.current
+ val bidiFormatter = rememberUiBidiFormatter()
+ val safeTitle = epg.title.safeDisplayText()
+ val safeReference = epg.url.safeSourceReference()
+ ?.let(bidiFormatter::ltr)
+
ListItem(
headlineContent = {
Text(
- text = epg.title,
- maxLines = 1,
+ text = safeTitle,
+ style = MaterialTheme.typography.titleMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
},
- supportingContent = {
- Text(
- text = epg.url,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ supportingContent = safeReference?.let { reference ->
+ {
+ Text(
+ text = reference,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.Ltr,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
},
trailingContent = {
Switch(
checked = isChecked,
- onCheckedChange = null
+ onCheckedChange = null,
)
},
colors = ListItemDefaults.colors(
- supportingColor = MaterialTheme
- .colorScheme
- .onSurfaceVariant.copy(0.38f)
+ containerColor = Color.Transparent,
+ supportingColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
- modifier = Modifier
- .border(
- 1.dp,
- LocalContentColor.current.copy(0.38f),
- SelectionsDefaults.Shape
- )
- .clip(AbsoluteSmoothCornerShape(spacing.medium, 65))
- .clickable {
- onUpdateEpgPlaylist(
- PlaylistRepository.EpgPlaylistUseCase.Check(
- playlistUrl = playlistUrl,
- epgUrl = epg.url,
- action = !isChecked
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .toggleable(
+ value = isChecked,
+ role = Role.Switch,
+ onValueChange = { checked ->
+ onUpdateEpgPlaylist(
+ PlaylistRepository.EpgPlaylistUseCase.Check(
+ playlistUrl = playlistUrl,
+ epgUrl = epg.url,
+ action = checked,
+ )
)
- )
- }
- .then(modifier)
+ },
+ )
+ .testTag("playlist-configuration-epg:${playlistWorkTag(epg.url)}"),
)
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/RefreshPlaylistButton.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/RefreshPlaylistButton.kt
new file mode 100644
index 000000000..21d625a17
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/RefreshPlaylistButton.kt
@@ -0,0 +1,160 @@
+package com.m3u.smartphone.ui.business.configuration.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material.icons.rounded.ErrorOutline
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.unit.dp
+import com.m3u.business.playlist.configuration.PlaylistRefreshStatus
+import com.m3u.i18n.R.string
+
+@Composable
+internal fun RefreshPlaylistButton(
+ status: PlaylistRefreshStatus,
+ onRefresh: () -> Unit,
+ onCancel: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val refreshing = status.isInProgress
+ val actionLabel = stringResource(
+ if (refreshing) {
+ string.feat_playlist_configuration_cancel_refresh_playlist
+ } else {
+ string.feat_playlist_configuration_refresh_playlist
+ }
+ )
+ val loadingDescription = stringResource(string.ui_state_loading)
+ val terminalDescription = when (status) {
+ PlaylistRefreshStatus.SUCCEEDED -> stringResource(
+ string.feat_playlist_configuration_update_succeeded
+ )
+ PlaylistRefreshStatus.FAILED -> stringResource(
+ string.feat_playlist_configuration_update_failed
+ )
+ PlaylistRefreshStatus.CANCELLED -> stringResource(
+ string.feat_setting_subscription_status_cancelled
+ )
+ else -> null
+ }
+ val colorScheme = MaterialTheme.colorScheme
+
+ ListItem(
+ headlineContent = {
+ Text(
+ text = actionLabel,
+ )
+ },
+ supportingContent = if (refreshing) null else {
+ {
+ Text(
+ text = terminalDescription ?: stringResource(
+ string.feat_playlist_configuration_refresh_playlist_description
+ ),
+ color = when (status) {
+ PlaylistRefreshStatus.FAILED -> colorScheme.error
+ PlaylistRefreshStatus.SUCCEEDED -> colorScheme.primary
+ else -> Color.Unspecified
+ },
+ )
+ }
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Rounded.Refresh,
+ contentDescription = null,
+ )
+ },
+ trailingContent = {
+ if (refreshing) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(24.dp)
+ .clearAndSetSemantics {},
+ strokeWidth = 2.dp,
+ )
+ } else if (status == PlaylistRefreshStatus.SUCCEEDED) {
+ Icon(
+ imageVector = Icons.Rounded.CheckCircle,
+ contentDescription = null,
+ tint = colorScheme.primary,
+ )
+ } else if (status == PlaylistRefreshStatus.FAILED) {
+ Icon(
+ imageVector = Icons.Rounded.ErrorOutline,
+ contentDescription = null,
+ tint = colorScheme.error,
+ )
+ } else if (status == PlaylistRefreshStatus.CANCELLED) {
+ Icon(
+ imageVector = Icons.Rounded.Info,
+ contentDescription = null,
+ tint = colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ colors = ListItemDefaults.colors(
+ headlineColor = if (refreshing) {
+ colorScheme.onTertiaryContainer
+ } else {
+ colorScheme.onSurface
+ },
+ supportingColor = if (refreshing) {
+ colorScheme.onTertiaryContainer
+ } else {
+ colorScheme.onSurfaceVariant
+ },
+ leadingIconColor = if (refreshing) {
+ colorScheme.onTertiaryContainer
+ } else {
+ colorScheme.onSurfaceVariant
+ },
+ containerColor = if (refreshing) {
+ colorScheme.tertiaryContainer
+ } else {
+ Color.Transparent
+ },
+ ),
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .clickable(
+ role = Role.Button,
+ onClickLabel = actionLabel,
+ onClick = if (refreshing) onCancel else onRefresh,
+ )
+ .semantics {
+ role = Role.Button
+ if (refreshing) {
+ stateDescription = loadingDescription
+ liveRegion = LiveRegionMode.Polite
+ } else if (terminalDescription != null) {
+ stateDescription = terminalDescription
+ liveRegion = LiveRegionMode.Polite
+ }
+ }
+ .testTag("playlist-configuration-refresh"),
+ )
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/SyncProgrammesButton.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/SyncProgrammesButton.kt
index e7c9df021..635830f87 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/SyncProgrammesButton.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/SyncProgrammesButton.kt
@@ -1,99 +1,181 @@
package com.m3u.smartphone.ui.business.configuration.components
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
-import androidx.compose.animation.togetherWith
-import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material.icons.rounded.ErrorOutline
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Sync
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
-import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
-import com.m3u.core.foundation.util.basic.title
+import com.m3u.business.playlist.configuration.PlaylistRefreshStatus
import com.m3u.i18n.R.string
-import com.m3u.core.foundation.components.CircularProgressIndicator
-import com.m3u.smartphone.ui.material.components.SelectionsDefaults
-import com.m3u.smartphone.ui.material.model.LocalSpacing
-import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
import kotlinx.datetime.LocalDateTime
@Composable
internal fun SyncProgrammesButton(
- subscribingOrRefreshing: Boolean,
+ status: PlaylistRefreshStatus,
expired: LocalDateTime?,
onSyncProgrammes: () -> Unit,
onCancelSyncProgrammes: () -> Unit,
modifier: Modifier = Modifier
) {
- val spacing = LocalSpacing.current
+ val subscribingOrRefreshing = status.isInProgress
+ val bidiFormatter = rememberUiBidiFormatter()
+ val actionLabel = stringResource(
+ if (subscribingOrRefreshing) {
+ string.feat_playlist_configuration_cancel_sync_programmes
+ } else {
+ string.feat_playlist_configuration_sync_programmes
+ }
+ )
+ val loadingDescription = stringResource(string.ui_state_loading)
+ val terminalDescription = when (status) {
+ PlaylistRefreshStatus.SUCCEEDED -> stringResource(
+ string.feat_playlist_configuration_update_succeeded
+ )
+ PlaylistRefreshStatus.FAILED -> stringResource(
+ string.feat_playlist_configuration_update_failed
+ )
+ PlaylistRefreshStatus.CANCELLED -> stringResource(
+ string.feat_setting_subscription_status_cancelled
+ )
+ else -> null
+ }
val colorScheme = MaterialTheme.colorScheme
+
ListItem(
headlineContent = {
Text(
- text = if (!subscribingOrRefreshing) stringResource(string.feat_playlist_configuration_sync_programmes).title()
- else stringResource(string.feat_playlist_configuration_cancel_sync_programmes).title(),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ text = actionLabel,
)
},
- supportingContent = {
- AnimatedContent(
- targetState = subscribingOrRefreshing,
- transitionSpec = {
- fadeIn() + slideInVertically { it } togetherWith fadeOut() + slideOutVertically { it }
- },
- label = "sync programmes state"
- ) { subscribingOrRefreshing ->
- if (!subscribingOrRefreshing) {
- Text(
- text = when (expired) {
- null -> stringResource(string.feat_playlist_configuration_programmes_expired)
- else -> stringResource(
- string.feat_playlist_configuration_programmes_expired_time,
- expired.toString()
- )
- },
- modifier = Modifier.fillMaxWidth()
- )
- }
+ supportingContent = if (!subscribingOrRefreshing) {
+ {
+ Text(
+ text = terminalDescription ?: when (expired) {
+ null -> stringResource(
+ string.feat_playlist_configuration_programmes_expired
+ )
+
+ else -> stringResource(
+ string.feat_playlist_configuration_programmes_expired_time,
+ bidiFormatter.ltr(expired.toString()),
+ )
+ },
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ color = when (status) {
+ PlaylistRefreshStatus.FAILED -> colorScheme.error
+ PlaylistRefreshStatus.SUCCEEDED -> colorScheme.primary
+ else -> Color.Unspecified
+ },
+ )
}
+ } else {
+ null
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Rounded.Sync,
+ contentDescription = null,
+ )
},
trailingContent = {
if (subscribingOrRefreshing) {
- CircularProgressIndicator()
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(24.dp)
+ .clearAndSetSemantics { },
+ strokeWidth = 2.dp,
+ )
+ } else if (status == PlaylistRefreshStatus.SUCCEEDED) {
+ Icon(
+ imageVector = Icons.Rounded.CheckCircle,
+ contentDescription = null,
+ tint = colorScheme.primary,
+ )
+ } else if (status == PlaylistRefreshStatus.FAILED) {
+ Icon(
+ imageVector = Icons.Rounded.ErrorOutline,
+ contentDescription = null,
+ tint = colorScheme.error,
+ )
+ } else if (status == PlaylistRefreshStatus.CANCELLED) {
+ Icon(
+ imageVector = Icons.Rounded.Info,
+ contentDescription = null,
+ tint = colorScheme.onSurfaceVariant,
+ )
}
},
colors = ListItemDefaults.colors(
- headlineColor = if (!subscribingOrRefreshing) LocalContentColor.current
- else colorScheme.onTertiaryContainer,
- supportingColor = if (!subscribingOrRefreshing) LocalContentColor.current.copy(0.38f)
- else colorScheme.onTertiaryContainer.copy(0.38f),
- containerColor = if (!subscribingOrRefreshing) colorScheme.surface
- else colorScheme.tertiaryContainer
+ headlineColor = if (subscribingOrRefreshing) {
+ colorScheme.onTertiaryContainer
+ } else {
+ colorScheme.onSurface
+ },
+ supportingColor = if (subscribingOrRefreshing) {
+ colorScheme.onTertiaryContainer
+ } else {
+ colorScheme.onSurfaceVariant
+ },
+ leadingIconColor = if (subscribingOrRefreshing) {
+ colorScheme.onTertiaryContainer
+ } else {
+ colorScheme.onSurfaceVariant
+ },
+ containerColor = if (subscribingOrRefreshing) {
+ colorScheme.tertiaryContainer
+ } else {
+ Color.Transparent
+ },
),
- modifier = Modifier
- .border(
- 1.dp,
- if (subscribingOrRefreshing) colorScheme.tertiaryContainer
- else LocalContentColor.current.copy(0.38f),
- SelectionsDefaults.Shape
- )
- .clip(AbsoluteSmoothCornerShape(spacing.medium, 65))
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
.clickable(
- onClick = if (subscribingOrRefreshing) onCancelSyncProgrammes
- else onSyncProgrammes,
+ role = Role.Button,
+ onClickLabel = actionLabel,
+ onClick = if (subscribingOrRefreshing) {
+ onCancelSyncProgrammes
+ } else {
+ onSyncProgrammes
+ },
)
- .then(modifier)
+ .semantics {
+ role = Role.Button
+ if (subscribingOrRefreshing) {
+ stateDescription = loadingDescription
+ liveRegion = LiveRegionMode.Polite
+ } else if (terminalDescription != null) {
+ stateDescription = terminalDescription
+ liveRegion = LiveRegionMode.Polite
+ }
+ }
+ .testTag("playlist-configuration-sync"),
)
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/XtreamPanel.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/XtreamPanel.kt
index eb73bb553..56f578d31 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/XtreamPanel.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/configuration/components/XtreamPanel.kt
@@ -1,32 +1,45 @@
package com.m3u.smartphone.ui.business.configuration.components
-import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Cloud
import androidx.compose.material.icons.rounded.Link
-import androidx.compose.material3.CardDefaults
+import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
-import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m3u.core.foundation.wrapper.Resource
+import com.m3u.data.database.model.DataSource
import com.m3u.data.parser.xtream.XtreamUserInfo
-import com.m3u.smartphone.ui.material.model.LocalSpacing
-import com.m3u.smartphone.ui.material.components.Badge
-import com.m3u.smartphone.ui.material.components.FontFamilies
-import com.m3u.smartphone.ui.material.components.TextBadge
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.material.ktx.safeDisplayText
import kotlin.time.Instant
@Composable
@@ -34,92 +47,240 @@ internal fun XtreamPanel(
info: Resource,
modifier: Modifier = Modifier
) {
- val spacing = LocalSpacing.current
- val containerColor by animateColorAsState(
- targetValue = when (info) {
- Resource.Loading -> MaterialTheme.colorScheme.primaryContainer
- is Resource.Success -> MaterialTheme.colorScheme.surfaceContainer
- is Resource.Failure -> MaterialTheme.colorScheme.errorContainer
- },
- label = "xtream-panel-container-color"
- )
- val contentColor by animateColorAsState(
- targetValue = when (info) {
- Resource.Loading -> MaterialTheme.colorScheme.onPrimaryContainer
- is Resource.Success -> MaterialTheme.colorScheme.onSurface
- is Resource.Failure -> MaterialTheme.colorScheme.onErrorContainer
- },
- label = "xtream-panel-content-color"
- )
- ElevatedCard(
- modifier = modifier,
- colors = CardDefaults.elevatedCardColors(containerColor, contentColor)
+ val loadingDescription = stringResource(string.ui_state_loading)
+
+ Surface(
+ modifier = modifier.testTag("playlist-configuration-xtream"),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
- Box(Modifier.padding(spacing.medium)) {
- when (info) {
- Resource.Loading -> {
- CircularProgressIndicator()
- }
+ when (info) {
+ Resource.Loading -> {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = loadingDescription,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Rounded.Cloud,
+ contentDescription = null,
+ )
+ },
+ trailingContent = {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(24.dp)
+ .clearAndSetSemantics { },
+ strokeWidth = 2.dp,
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ leadingIconColor =
+ MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .semantics {
+ stateDescription = loadingDescription
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ }
+
+ is Resource.Success -> {
+ XtreamAccountContent(userInfo = info.data)
+ }
- is Resource.Success -> {
- val userInfo = info.data
- Column(
- verticalArrangement = Arrangement.spacedBy(spacing.extraSmall)
- ) {
+ is Resource.Failure -> {
+ ListItem(
+ headlineContent = {
Text(
- text = userInfo.username.orEmpty(),
- style = MaterialTheme.typography.titleLarge
+ text = stringResource(DataSource.Xtream.resId),
)
+ },
+ supportingContent = {
Text(
- text = Instant.fromEpochSeconds(
- (userInfo.createdAt ?: "0").toLong()
- ).toString(),
- style = MaterialTheme.typography.bodySmall,
- color = LocalContentColor.current.copy(0.54f)
+ text = stringResource(
+ string.feat_setting_provider_subscription_failed
+ ),
+ style = MaterialTheme.typography.bodyMedium,
)
- Row(
- horizontalArrangement = Arrangement.spacedBy(spacing.small),
- verticalAlignment = Alignment.CenterVertically
- ) {
- TextBadge(
- text = userInfo.status.orEmpty()
- )
- if (userInfo.isTrial == "1") {
- TextBadge(
- text = "Trial",
- color = MaterialTheme.colorScheme.tertiary
- )
- }
- Badge(
- color = MaterialTheme.colorScheme.secondary
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(spacing.extraSmall)
- ) {
- Icon(
- imageVector = Icons.Rounded.Link,
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
- Text(
- text = "${userInfo.activeCons.orEmpty()}/${userInfo.maxConnections.orEmpty()}",
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamilies.LexendExa
- )
- }
- }
- }
- }
- }
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Rounded.Warning,
+ contentDescription = null,
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ leadingIconColor = MaterialTheme.colorScheme.error,
+ supportingColor =
+ MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .semantics { liveRegion = LiveRegionMode.Polite },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun XtreamAccountContent(
+ userInfo: XtreamUserInfo,
+) {
+ val username = userInfo.username
+ .orEmpty()
+ .safeDisplayText()
+ .takeIf(String::isNotBlank)
+ ?: stringResource(DataSource.Xtream.resId)
+ val createdAt = userInfo.createdAt.toDisplayInstant()
+ val status = userInfo.status
+ .orEmpty()
+ .safeDisplayText()
+ .takeIf(String::isNotBlank)
+ val activeConnections = userInfo.activeCons
+ .orEmpty()
+ .safeDisplayText()
+ val maximumConnections = userInfo.maxConnections
+ .orEmpty()
+ .safeDisplayText()
+ val connectionCount = if (
+ activeConnections.isNotBlank() || maximumConnections.isNotBlank()
+ ) {
+ "$activeConnections/$maximumConnections"
+ } else {
+ null
+ }
- is Resource.Failure -> {
+ Column {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = username,
+ style = MaterialTheme.typography.titleMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ supportingContent = createdAt?.let {
+ {
Text(
- text = info.message.orEmpty(),
- style = MaterialTheme.typography.bodyMedium
+ text = it,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.Ltr,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Rounded.Cloud,
+ contentDescription = null,
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ supportingColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ leadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp),
+ )
+
+ if (
+ status != null ||
+ userInfo.isTrial == "1" ||
+ connectionCount != null
+ ) {
+ FlowRow(
+ modifier = Modifier.padding(
+ start = 16.dp,
+ end = 16.dp,
+ bottom = 16.dp,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ status?.let {
+ XtreamInformationPill(
+ text = it,
+ textDirection = TextDirection.Ltr,
+ )
+ }
+ if (userInfo.isTrial == "1") {
+ XtreamInformationPill(
+ text = stringResource(
+ string.feat_playlist_configuration_xtream_trial
+ ),
+ )
+ }
+ connectionCount?.let {
+ XtreamInformationPill(
+ text = it,
+ icon = Icons.Rounded.Link,
+ textDirection = TextDirection.Ltr,
)
}
}
}
}
-}
\ No newline at end of file
+}
+
+@Composable
+private fun XtreamInformationPill(
+ text: String,
+ modifier: Modifier = Modifier,
+ icon: ImageVector? = null,
+ textDirection: TextDirection = TextDirection.Content,
+) {
+ Surface(
+ modifier = modifier,
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ ) {
+ Row(
+ modifier = Modifier
+ .heightIn(min = 32.dp)
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ icon?.let {
+ Icon(
+ imageVector = it,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge.copy(
+ textDirection = textDirection,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+private fun String?.toDisplayInstant(): String? {
+ val epochSeconds = this?.toLongOrNull()?.takeIf { it >= 0L } ?: return null
+ return runCatching {
+ Instant.fromEpochSeconds(epochSeconds).toString()
+ }.getOrNull()
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/extension/ExtensionScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/extension/ExtensionScreen.kt
deleted file mode 100644
index 37edf4812..000000000
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/extension/ExtensionScreen.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-package com.m3u.smartphone.ui.business.extension
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.unit.dp
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.lifecycle.compose.LifecycleResumeEffect
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil.compose.AsyncImage
-import com.m3u.business.extension.App
-import com.m3u.business.extension.ExtensionViewModel
-import com.m3u.smartphone.ui.common.helper.Metadata
-
-@Composable
-fun ExtensionRoute(
- modifier: Modifier = Modifier,
- viewModel: ExtensionViewModel = hiltViewModel(),
- contentPadding: PaddingValues = PaddingValues()
-) {
- val apps by viewModel.applications.collectAsStateWithLifecycle()
- ExtensionScreen(
- apps = apps,
- modifier = modifier,
- contentPadding = contentPadding,
- onAppClick = viewModel::runExtension
- )
-}
-
-@Composable
-private fun ExtensionScreen(
- apps: List,
- modifier: Modifier = Modifier,
- contentPadding: PaddingValues = PaddingValues(),
- onAppClick: (App) -> Unit,
-) {
- LifecycleResumeEffect(Unit) {
- Metadata.title = AnnotatedString("Extensions")
- Metadata.actions = emptyList()
- onPauseOrDispose { }
- }
- LazyColumn(
- modifier = modifier,
- contentPadding = contentPadding
- ) {
- items(apps, key = { it.packageName }) { app ->
- ListItem(
- headlineContent = {
- Text(
- text = app.name,
- style = MaterialTheme.typography.titleMedium
- )
- },
- leadingContent = {
- AsyncImage(
- model = app.icon,
- contentDescription = null,
- modifier = Modifier.size(56.dp)
- )
- },
- supportingContent = {
- Text(
- text = app.description,
- maxLines = 2
- )
- },
- trailingContent = {
- Text(app.version)
- },
- modifier = Modifier.clickable { onAppClick(app) }
- )
- HorizontalDivider()
- }
- }
-}
\ No newline at end of file
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt
index 32b88a065..e00e0dc92 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/favourite/FavouriteScreen.kt
@@ -57,6 +57,7 @@ fun FavoriteRoute(
viewModel: FavoriteViewModel = hiltViewModel()
) {
val title = stringResource(R.string.ui_title_favourite)
+ val sortContentDescription = stringResource(R.string.ui_sort)
val helper = LocalHelper.current
val context = LocalContext.current
@@ -88,7 +89,7 @@ fun FavoriteRoute(
Metadata.actions = listOf(
Action(
icon = Icons.AutoMirrored.Rounded.Sort,
- contentDescription = "sort",
+ contentDescription = sortContentDescription,
onClick = { isSortSheetVisible = true }
)
)
@@ -207,4 +208,4 @@ private fun FavoriteScreen(
onLongClick = onLongClickChannel,
modifier = modifier.hazeSource(LocalHazeState.current)
)
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt
index 0324335cf..70403fca0 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt
@@ -78,6 +78,7 @@ fun ForyouRoute(
val godMode by preferenceOf(PreferencesKeys.GOD_MODE)
val title = stringResource(string.ui_title_foryou)
+ val addContentDescription = stringResource(string.ui_action_add)
val playlists by viewModel.playlists.collectAsStateWithLifecycle()
val specs by viewModel.specs.collectAsStateWithLifecycle()
@@ -94,7 +95,7 @@ fun ForyouRoute(
Metadata.actions = listOf(
Action(
icon = Icons.Rounded.Add,
- contentDescription = "add",
+ contentDescription = addContentDescription,
onClick = navigateToSettingPlaylistManagement
)
)
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/HeadlineBackground.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/HeadlineBackground.kt
index 8b6b00d76..1f53c4642 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/HeadlineBackground.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/HeadlineBackground.kt
@@ -3,13 +3,11 @@ package com.m3u.smartphone.ui.business.foryou.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -19,6 +17,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -41,17 +40,9 @@ internal fun HeadlineBackground(modifier: Modifier = Modifier) {
val helper = LocalHelper.current
val colorScheme = MaterialTheme.colorScheme
- val darkMode by preferenceOf(PreferencesKeys.DARK_MODE)
- val followSystemTheme by preferenceOf(PreferencesKeys.FOLLOW_SYSTEM_THEME)
val noPictureMode by preferenceOf(PreferencesKeys.NO_PICTURE_MODE)
- val isSystemInDarkTheme = isSystemInDarkTheme()
-
- val useDarkTheme by remember {
- derivedStateOf {
- darkMode || (followSystemTheme && isSystemInDarkTheme)
- }
- }
+ val useDarkTheme = colorScheme.background.luminance() < 0.5f
val url = Metadata.headlineUrl
val fraction = Metadata.headlineFraction
@@ -113,4 +104,4 @@ internal fun HeadlineBackground(modifier: Modifier = Modifier) {
}
)
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistGallery.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistGallery.kt
index 08d35cbb7..55e1716ff 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistGallery.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistGallery.kt
@@ -136,7 +136,7 @@ private object PlaylistGalleryDefaults {
if (!refreshable) stringResource(string.feat_foryou_imported_playlist_title)
else ""
}
- return actual.uppercase()
+ return actual
}
@Composable
@@ -150,4 +150,4 @@ private object PlaylistGalleryDefaults {
end = if (index % rowCount == rowCount - 1) padding else 0.dp
)
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistItem.kt
index 36fe35dc9..bd13965ca 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistItem.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/PlaylistItem.kt
@@ -32,6 +32,7 @@ import com.m3u.smartphone.ui.material.model.LocalSpacing
import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
import com.m3u.smartphone.ui.material.components.Badge
import com.m3u.smartphone.ui.material.components.FontFamilies
+import java.util.Locale
@Composable
internal fun PlaylistItem(
@@ -64,7 +65,7 @@ internal fun PlaylistItem(
},
supportingContent = {
Text(
- text = type?.uppercase().orEmpty(),
+ text = type?.uppercase(Locale.ROOT).orEmpty(),
style = MaterialTheme.typography.bodySmall.copy(
letterSpacing = 1.sp,
baselineShift = BaselineShift.Subscript,
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/recommend/RecommendItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/recommend/RecommendItem.kt
index 9c9b7891a..3e5720ada 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/recommend/RecommendItem.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/components/recommend/RecommendItem.kt
@@ -186,7 +186,7 @@ private fun UnseenContent(spec: Recommend.UnseenSpec) {
cover = spec.channel.cover.orEmpty(),
primaryContent = {
Text(
- text = stringResource(string.feat_foryou_recommend_unseen_label).uppercase(),
+ text = stringResource(string.feat_foryou_recommend_unseen_label),
maxLines = 1
)
},
@@ -225,7 +225,7 @@ private fun DiscoverContent(spec: Recommend.DiscoverSpec) {
val playlist = spec.playlist
val category = spec.category
Text(
- text = stringResource(string.feat_foryou_recommend_unseen_label).uppercase(),
+ text = stringResource(string.feat_foryou_recommend_unseen_label),
style = MaterialTheme.typography.labelLarge,
maxLines = 1
)
@@ -259,7 +259,7 @@ private fun CwContent(spec: Recommend.CwSpec) {
cover = channel.cover.orEmpty(),
primaryContent = {
Text(
- text = stringResource(string.feat_foryou_recommend_cw_label).uppercase(),
+ text = stringResource(string.feat_foryou_recommend_cw_label),
maxLines = 1
)
},
@@ -351,4 +351,4 @@ private fun NewReleaseContent(spec: Recommend.NewRelease) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt
index d54f386fe..a352bd310 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/PlaylistScreen.kt
@@ -53,6 +53,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
@@ -160,7 +161,7 @@ internal fun PlaylistRoute(
LaunchedEffect(autoRefreshChannels, playlistUrl) {
if (playlistUrl.isNotEmpty() && autoRefreshChannels) {
- viewModel.refresh()
+ viewModel.refresh(background = true)
}
}
@@ -310,6 +311,8 @@ private fun PlaylistScreen(
actions: PlaylistScreenActions,
modifier: Modifier = Modifier
) {
+ val sortContentDescription = stringResource(string.ui_sort)
+ val refreshContentDescription = stringResource(string.ui_action_refresh)
val currentOnScrollUp by rememberUpdatedState(actions.onScrollUp)
val currentOnRefresh by rememberUpdatedState(actions.onRefresh)
@@ -347,13 +350,13 @@ private fun PlaylistScreen(
Metadata.actions = buildList {
Action(
icon = Icons.AutoMirrored.Rounded.Sort,
- contentDescription = "sort",
+ contentDescription = sortContentDescription,
onClick = { isSortSheetVisible = true }
).also { add(it) }
Action(
icon = Icons.Rounded.Refresh,
enabled = !state.refreshing,
- contentDescription = "refresh",
+ contentDescription = refreshContentDescription,
onClick = currentOnRefresh
).also { add(it) }
}
@@ -529,7 +532,7 @@ private fun UnsupportedUIModeContent(
),
modifier = modifier.fillMaxSize()
) {
- Text("Unsupported UI Mode: $device")
+ Text(stringResource(string.ui_unsupported_mode, device))
if (description != null) {
Text(description)
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt
index be2629f11..16fb6f4f5 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/ChannelItem.kt
@@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
@@ -240,14 +241,15 @@ internal fun Programme.readText(
timeColor: Color = MaterialTheme.colorScheme.secondary
): AnnotatedString = buildAnnotatedString {
val clockMode by preferenceOf(PreferencesKeys.CLOCK_MODE)
+ val formatLocale = LocalConfiguration.current.locales[0]
val start = Instant.fromEpochMilliseconds(start)
.toLocalDateTime(TimeZone.currentSystemDefault())
- .formatEOrSh(clockMode)
+ .formatEOrSh(clockMode, locale = formatLocale)
withStyle(
SpanStyle(color = timeColor, fontWeight = FontWeight.SemiBold)
) {
append("[$start] ")
}
append(title)
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt
index cc50423fd..1143f2ccf 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRow.kt
@@ -14,11 +14,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
@@ -49,17 +51,26 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
import com.m3u.core.foundation.ui.thenIf
+import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.material.effects.BackStackEntry
import com.m3u.smartphone.ui.material.effects.BackStackHandler
import com.m3u.smartphone.ui.material.ktx.Edge
import com.m3u.smartphone.ui.material.ktx.blurEdge
import com.m3u.smartphone.ui.material.model.LocalHazeState
import com.m3u.smartphone.ui.material.model.LocalSpacing
-import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
import dev.chrisbanes.haze.hazeSource
@Composable
@@ -78,6 +89,22 @@ internal fun PlaylistTabRow(
val spacing = LocalSpacing.current
val hapticFeedback = LocalHapticFeedback.current
val state = rememberLazyListState()
+ val pinDescription = stringResource(string.ui_action_pin)
+ val unpinDescription = stringResource(string.ui_action_unpin)
+ val hideDescription = stringResource(string.ui_action_hide)
+ val categoryOptionsDescription = stringResource(string.ui_action_category_options)
+ val expandDescription = stringResource(string.ui_action_expand_categories)
+ val collapseDescription = stringResource(string.ui_action_collapse_categories)
+ val expandedStateDescription = stringResource(string.ui_state_expanded)
+ val collapsedStateDescription = stringResource(string.ui_state_collapsed)
+ val pinnedStateDescription = stringResource(string.ui_state_pinned)
+ val categoryMenuSemantics = categoryMenuSemantics(
+ isExpanded = isExpanded,
+ expandedStateDescription = expandedStateDescription,
+ collapsedStateDescription = collapsedStateDescription,
+ expandActionLabel = expandDescription,
+ collapseActionLabel = collapseDescription,
+ )
Box(modifier) {
var focusCategory: String? by rememberSaveable { mutableStateOf(null) }
@@ -99,6 +126,10 @@ internal fun PlaylistTabRow(
horizontalArrangement = Arrangement.End
) {
IconButton(
+ modifier = Modifier.sizeIn(
+ minWidth = 48.dp,
+ minHeight = 48.dp
+ ),
onClick = {
name.let(onPinOrUnpinCategory)
focusCategory = null
@@ -106,10 +137,18 @@ internal fun PlaylistTabRow(
) {
Icon(
imageVector = Icons.Rounded.PushPin,
- contentDescription = "pin"
+ contentDescription = if (name in pinnedCategories) {
+ unpinDescription
+ } else {
+ pinDescription
+ }
)
}
IconButton(
+ modifier = Modifier.sizeIn(
+ minWidth = 48.dp,
+ minHeight = 48.dp
+ ),
onClick = {
name.let(onHideCategory)
focusCategory = null
@@ -117,17 +156,28 @@ internal fun PlaylistTabRow(
) {
Icon(
imageVector = Icons.Rounded.VisibilityOff,
- contentDescription = "hide"
+ contentDescription = hideDescription
)
}
}
} else {
IconButton(
+ modifier = Modifier
+ .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
+ .clearAndSetSemantics {
+ contentDescription = categoryOptionsDescription
+ role = Role.Button
+ stateDescription = categoryMenuSemantics.stateDescription
+ onClick(label = categoryMenuSemantics.actionLabel) {
+ onExpanded()
+ true
+ }
+ },
onClick = onExpanded
) {
Icon(
imageVector = Icons.Rounded.Menu,
- contentDescription = ""
+ contentDescription = null
)
}
}
@@ -159,7 +209,9 @@ internal fun PlaylistTabRow(
focusCategory = category
onCategoryChanged(category)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
- }
+ },
+ categoryOptionsDescription = categoryOptionsDescription,
+ pinnedStateDescription = pinnedStateDescription
)
}
}
@@ -172,6 +224,7 @@ internal fun PlaylistTabRow(
contentPadding = bottomContentPadding,
modifier = Modifier
.fillMaxSize()
+ .selectableGroup()
.background(MaterialTheme.colorScheme.surface)
.hazeSource(LocalHazeState.current),
content = categoriesContent
@@ -184,7 +237,8 @@ internal fun PlaylistTabRow(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.blurEdge(MaterialTheme.colorScheme.surface, Edge.End)
- .fillMaxWidth(),
+ .fillMaxWidth()
+ .selectableGroup(),
content = categoriesContent
)
}
@@ -198,6 +252,29 @@ internal fun PlaylistTabRow(
}
}
+internal data class CategoryMenuSemantics(
+ val stateDescription: String,
+ val actionLabel: String,
+)
+
+internal fun categoryMenuSemantics(
+ isExpanded: Boolean,
+ expandedStateDescription: String,
+ collapsedStateDescription: String,
+ expandActionLabel: String,
+ collapseActionLabel: String,
+): CategoryMenuSemantics = if (isExpanded) {
+ CategoryMenuSemantics(
+ stateDescription = expandedStateDescription,
+ actionLabel = collapseActionLabel,
+ )
+} else {
+ CategoryMenuSemantics(
+ stateDescription = collapsedStateDescription,
+ actionLabel = expandActionLabel,
+ )
+}
+
@Composable
private fun PlaylistTabRowItem(
name: String,
@@ -208,6 +285,8 @@ private fun PlaylistTabRowItem(
isExpanded: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
+ categoryOptionsDescription: String,
+ pinnedStateDescription: String,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
@@ -236,10 +315,18 @@ private fun PlaylistTabRowItem(
shape = shape,
modifier = Modifier
.clip(shape)
+ .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
+ .semantics {
+ this.selected = selected
+ if (pinned) {
+ stateDescription = pinnedStateDescription
+ }
+ }
.combinedClickable(
interactionSource = interactionSource,
indication = indication,
onClick = onClick,
+ onLongClickLabel = categoryOptionsDescription,
onLongClick = onLongClick,
role = Role.Tab
)
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/SettingScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/SettingScreen.kt
index 35123f990..fcbd0472f 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/SettingScreen.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/SettingScreen.kt
@@ -1,47 +1,104 @@
package com.m3u.smartphone.ui.business.setting
+import android.content.Intent
+import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.ChangeCircle
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
+import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
+import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.m3u.business.playlist.configuration.playlistConfigurationReference
import com.m3u.business.setting.BackingUpAndRestoringState
import com.m3u.business.setting.CodecPackState
+import com.m3u.business.setting.ExtensionPluginDiscoveryState
+import com.m3u.business.setting.ExtensionPluginOperationState
+import com.m3u.business.setting.ExtensionSettingsState
+import com.m3u.business.setting.ProviderDiscoveryState
+import com.m3u.business.setting.ProviderOperationState
+import com.m3u.business.setting.ProviderSubscriptionForm
+import com.m3u.business.setting.PlaylistSubscriptionPhase
+import com.m3u.business.setting.PlaylistSubscriptionState
import com.m3u.business.setting.SettingProperties
import com.m3u.business.setting.SettingViewModel
-import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
-import com.m3u.core.foundation.architecture.preferences.preferenceOf
+import com.m3u.core.foundation.architecture.preferences.ThemePreference
import com.m3u.core.foundation.util.basic.title
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.ColorScheme
+import com.m3u.data.database.model.DataSource
import com.m3u.data.database.model.Playlist
+import com.m3u.data.repository.extension.ExtensionSettingEditToken
+import com.m3u.data.repository.plugin.InstalledPlugin
+import com.m3u.data.repository.plugin.PluginAuthorizationToken
+import com.m3u.data.repository.provider.ProviderAccountSummary
+import com.m3u.data.worker.abandonPersistedUriPermissionLease
+import com.m3u.data.worker.beginPersistedUriPermissionLease
import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.business.configuration.PlaylistConfigurationRoute
+import com.m3u.smartphone.ui.business.configuration.providerDisplayName
import com.m3u.smartphone.ui.business.setting.components.CanvasBottomSheet
import com.m3u.smartphone.ui.business.setting.fragments.AppearanceFragment
import com.m3u.smartphone.ui.business.setting.fragments.CodecPackFragment
+import com.m3u.smartphone.ui.business.setting.fragments.ExtensionPluginAuthorizationScreen
+import com.m3u.smartphone.ui.business.setting.fragments.ExtensionPluginDetailScreen
+import com.m3u.smartphone.ui.business.setting.fragments.ExtensionPluginListScreen
+import com.m3u.smartphone.ui.business.setting.fragments.ExtensionSettingsScreen
+import com.m3u.smartphone.ui.business.setting.fragments.EpgSourceListScreen
+import com.m3u.smartphone.ui.business.setting.fragments.HiddenCategoryListScreen
+import com.m3u.smartphone.ui.business.setting.fragments.HiddenChannelListScreen
import com.m3u.smartphone.ui.business.setting.fragments.OptionalFragment
-import com.m3u.smartphone.ui.business.setting.fragments.SubscriptionsFragment
+import com.m3u.smartphone.ui.business.setting.fragments.PlaylistManagementOverviewScreen
+import com.m3u.smartphone.ui.business.setting.fragments.SubscriptionEditorScreen
+import com.m3u.smartphone.ui.business.setting.fragments.SubscriptionSourcePickerScreen
+import com.m3u.smartphone.ui.business.setting.fragments.providerSourceSelectionKey
+import com.m3u.smartphone.ui.business.setting.fragments.resolveExtensionPluginDetailContentState
+import com.m3u.smartphone.ui.business.setting.fragments.subscriptionSelectionKey
import com.m3u.smartphone.ui.business.setting.fragments.preferences.PreferencesFragment
import com.m3u.smartphone.ui.common.helper.Fob
import com.m3u.smartphone.ui.common.helper.Metadata
@@ -49,24 +106,66 @@ import com.m3u.smartphone.ui.common.internal.Events
import com.m3u.smartphone.ui.material.components.Destination
import com.m3u.smartphone.ui.material.components.EventHandler
import com.m3u.smartphone.ui.material.components.SettingDestination
+import com.m3u.smartphone.ui.material.ktx.withoutBidiControls
import com.m3u.smartphone.ui.material.model.LocalHazeState
+import com.m3u.smartphone.ui.navigation.AppNavigationMode
+import com.m3u.smartphone.ui.navigation.resolveAppNavigationMode
import dev.chrisbanes.haze.hazeSource
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
@Composable
fun SettingRoute(
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
+ onDetailVisibilityChanged: (Boolean) -> Unit = {},
viewModel: SettingViewModel = hiltViewModel()
) {
val controller = LocalSoftwareKeyboardController.current
val colorSchemes by viewModel.colorSchemes.collectAsStateWithLifecycle()
+ val playlists by viewModel.playlists.collectAsStateWithLifecycle()
+ val playlistSubscriptionInProgress by
+ viewModel.playlistSubscriptionInProgress.collectAsStateWithLifecycle()
+ val playlistSubscriptionState by
+ viewModel.playlistSubscriptionState.collectAsStateWithLifecycle()
val epgs by viewModel.epgs.collectAsStateWithLifecycle()
val hiddenChannels by viewModel.hiddenChannels.collectAsStateWithLifecycle()
val hiddenCategoriesWithPlaylists by viewModel.hiddenCategoriesWithPlaylists.collectAsStateWithLifecycle()
val backingUpOrRestoring by viewModel.backingUpOrRestoring.collectAsStateWithLifecycle()
val codecPackState by viewModel.codecPackState.collectAsStateWithLifecycle()
+ val extensionPluginDiscoveryState by
+ viewModel.extensionPluginDiscoveryState.collectAsStateWithLifecycle()
+ val extensionPluginOperationState by
+ viewModel.extensionPluginOperationState.collectAsStateWithLifecycle()
+ val extensionSettingsState by viewModel.extensionSettingsState.collectAsStateWithLifecycle()
+ val extensionPlugins = extensionPluginDiscoveryState.plugins
+ val providerDiscoveryState by viewModel.providerDiscoveryState.collectAsStateWithLifecycle()
+ val providerAccountSummaries by viewModel.providerAccountSummaries.collectAsStateWithLifecycle()
+ val providerSubscriptionForm by viewModel.providerSubscriptionForm.collectAsStateWithLifecycle()
+ val providerOperationState by viewModel.providerOperationState.collectAsStateWithLifecycle()
+ val localeTag = LocalConfiguration.current.locales[0].toLanguageTag()
+ val context = LocalContext.current
+ val diagnosticsShareTitle = stringResource(string.feat_setting_extension_diagnostics_share_title)
+ val fileAccessFailure = stringResource(string.feat_setting_playlist_file_access_failed)
+ val currentDiagnosticsShareTitle by rememberUpdatedState(diagnosticsShareTitle)
+
+ LaunchedEffect(viewModel, localeTag) {
+ viewModel.refreshSubscriptionProvidersForLocale(localeTag)
+ }
+ LaunchedEffect(viewModel, context) {
+ viewModel.extensionDiagnostics.collect { payload ->
+ context.startActivity(
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).apply {
+ type = "application/json"
+ putExtra(Intent.EXTRA_TEXT, payload)
+ },
+ currentDiagnosticsShareTitle,
+ )
+ )
+ }
+ }
val sheetState = rememberModalBottomSheetState()
var colorScheme: ColorScheme? by remember { mutableStateOf(null) }
@@ -74,16 +173,43 @@ fun SettingRoute(
val createDocumentLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) { uri ->
uri ?: return@rememberLauncherForActivityResult
- viewModel.backup(uri)
+ val permissionTag = beginPersistedUriPermissionLease(context, uri)
+ val accessPersisted = runCatching {
+ context.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
+ )
+ }.isSuccess
+ if (accessPersisted) {
+ viewModel.backup(uri)
+ } else {
+ abandonPersistedUriPermissionLease(context, permissionTag)
+ Toast.makeText(context, fileAccessFailure, Toast.LENGTH_LONG).show()
+ }
}
val openDocumentLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult
- viewModel.restore(uri)
+ val permissionTag = beginPersistedUriPermissionLease(context, uri)
+ val accessPersisted = runCatching {
+ context.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION,
+ )
+ }.isSuccess
+ if (accessPersisted) {
+ viewModel.restore(uri)
+ } else {
+ abandonPersistedUriPermissionLease(context, permissionTag)
+ Toast.makeText(context, fileAccessFailure, Toast.LENGTH_LONG).show()
+ }
}
val backup = {
- val filename = "Backup_${System.currentTimeMillis()}.txt"
+ val filename = context.getString(
+ string.feat_setting_playlist_backup_filename,
+ System.currentTimeMillis(),
+ )
createDocumentLauncher.launch(filename)
}
val restore = {
@@ -96,6 +222,13 @@ fun SettingRoute(
versionCode = viewModel.versionCode,
backingUpOrRestoring = backingUpOrRestoring,
codecPackState = codecPackState,
+ playlists = playlists,
+ playlistSubscriptionInProgress = playlistSubscriptionInProgress,
+ playlistSubscriptionState = playlistSubscriptionState,
+ onCancelPlaylistSubscription =
+ viewModel::cancelPlaylistSubscription,
+ onDismissPlaylistSubscription =
+ viewModel::dismissPlaylistSubscriptionStatus,
epgs = epgs,
hiddenChannels = hiddenChannels,
hiddenCategoriesWithPlaylists = hiddenCategoriesWithPlaylists,
@@ -103,8 +236,11 @@ fun SettingRoute(
restore = restore,
colorSchemes = colorSchemes,
openColorScheme = { colorScheme = it },
+ onSelectTheme = viewModel::selectTheme,
restoreSchemes = viewModel::restoreSchemes,
onClipboard = { viewModel.onClipboard(it) },
+ onBeginSubscriptionDraft = viewModel::beginSubscriptionDraft,
+ subscriptionAccepted = viewModel.subscriptionAccepted,
onSubscribe = {
controller?.hide()
viewModel.subscribe()
@@ -117,6 +253,39 @@ fun SettingRoute(
onInstallCodecPack = viewModel::installCodecPack,
onDeleteCodecPack = viewModel::deleteCodecPack,
onRefreshCodecPack = viewModel::refreshCodecPack,
+ extensionPlugins = extensionPlugins,
+ extensionPluginDiscoveryState = extensionPluginDiscoveryState,
+ extensionPluginOperationState = extensionPluginOperationState,
+ extensionSettingsState = extensionSettingsState,
+ providerDiscoveryState = providerDiscoveryState,
+ providerAccountSummaries = providerAccountSummaries,
+ providerSubscriptionForm = providerSubscriptionForm,
+ providerOperationState = providerOperationState,
+ onSelectSubscriptionProviderVariant = viewModel::selectSubscriptionProviderVariant,
+ onUpdateSubscriptionProviderSetting = viewModel::updateSubscriptionProviderSetting,
+ onRetryProviderDiscovery = viewModel::refreshSubscriptionProviders,
+ onReauthenticateProviderAccount = viewModel::reauthenticateProviderAccount,
+ onRefreshExtensionPlugins = viewModel::refreshExtensionPlugins,
+ onEnableExtensionPlugin = viewModel::enableExtensionPlugin,
+ onReauthorizeExtensionPlugin = viewModel::reauthorizeExtensionPlugin,
+ onDisableExtensionPlugin = viewModel::disableExtensionPlugin,
+ onRevokeExtensionPlugin = viewModel::revokeExtensionPlugin,
+ onClearExtensionData = viewModel::clearExtensionData,
+ onExportExtensionDiagnostics = viewModel::exportExtensionDiagnostics,
+ onOpenExtensionSettings = { extensionId ->
+ viewModel.openExtensionSettings(extensionId, localeTag)
+ },
+ onCloseExtensionSettings = viewModel::closeExtensionSettings,
+ onUpdateExtensionSetting = { sectionId, fieldKey, editToken, value ->
+ viewModel.updateExtensionSetting(
+ sectionId,
+ fieldKey,
+ editToken,
+ value,
+ localeTag,
+ )
+ },
+ onDetailVisibilityChanged = onDetailVisibilityChanged,
modifier = modifier.fillMaxSize(),
contentPadding = contentPadding,
)
@@ -141,6 +310,12 @@ private fun SettingScreen(
versionCode: Int,
backingUpOrRestoring: BackingUpAndRestoringState,
codecPackState: CodecPackState,
+ playlists: Map?,
+ playlistSubscriptionInProgress: Boolean,
+ playlistSubscriptionState: PlaylistSubscriptionState,
+ onCancelPlaylistSubscription: () -> Unit,
+ onDismissPlaylistSubscription: () -> Unit,
+ subscriptionAccepted: Flow,
onSubscribe: () -> Unit,
hiddenChannels: List,
hiddenCategoriesWithPlaylists: List>,
@@ -149,14 +324,39 @@ private fun SettingScreen(
backup: () -> Unit,
restore: () -> Unit,
onClipboard: (String) -> Unit,
+ onBeginSubscriptionDraft: (String, DataSource) -> Unit,
colorSchemes: List,
openColorScheme: (ColorScheme) -> Unit,
+ onSelectTheme: (ThemePreference) -> Unit,
restoreSchemes: () -> Unit,
epgs: List,
onDeleteEpgPlaylist: (String) -> Unit,
onInstallCodecPack: () -> Unit,
onDeleteCodecPack: () -> Unit,
onRefreshCodecPack: () -> Unit,
+ extensionPlugins: List,
+ extensionPluginDiscoveryState: ExtensionPluginDiscoveryState,
+ extensionPluginOperationState: ExtensionPluginOperationState,
+ extensionSettingsState: ExtensionSettingsState,
+ providerDiscoveryState: ProviderDiscoveryState,
+ providerAccountSummaries: List,
+ providerSubscriptionForm: ProviderSubscriptionForm?,
+ providerOperationState: ProviderOperationState,
+ onSelectSubscriptionProviderVariant: (String, String) -> Unit,
+ onUpdateSubscriptionProviderSetting: (String, String?) -> Unit,
+ onRetryProviderDiscovery: () -> Unit,
+ onReauthenticateProviderAccount: (String) -> Unit,
+ onRefreshExtensionPlugins: () -> Unit,
+ onEnableExtensionPlugin: (String, String, PluginAuthorizationToken) -> Unit,
+ onReauthorizeExtensionPlugin: (String, String, PluginAuthorizationToken) -> Unit,
+ onDisableExtensionPlugin: (String) -> Unit,
+ onRevokeExtensionPlugin: (String, String, String?) -> Unit,
+ onClearExtensionData: (String, String, String?) -> Unit,
+ onExportExtensionDiagnostics: (String) -> Unit,
+ onOpenExtensionSettings: (String) -> Unit,
+ onCloseExtensionSettings: () -> Unit,
+ onUpdateExtensionSetting: (String, String, ExtensionSettingEditToken, String?) -> Unit,
+ onDetailVisibilityChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues()
) {
@@ -164,39 +364,155 @@ private fun SettingScreen(
val defaultTitle = stringResource(string.ui_title_setting)
val playlistTitle = stringResource(string.feat_setting_playlist_management)
+ val playlistSourcePickerTitle =
+ stringResource(string.feat_setting_playlist_source_picker_title)
+ val playlistEditorTitle = stringResource(string.feat_setting_label_add_playlist)
+ val playlistEpgEditorTitle =
+ stringResource(string.feat_setting_playlist_add_epg_source)
+ val playlistReauthenticationTitle =
+ stringResource(string.feat_setting_provider_reauthenticate)
+ val playlistEpgTitle = stringResource(string.feat_setting_label_epg_playlists)
+ val playlistHiddenChannelsTitle =
+ stringResource(string.feat_setting_label_hidden_channels)
+ val playlistHiddenCategoriesTitle =
+ stringResource(string.feat_setting_label_hidden_playlist_groups)
+ val extensionPluginsTitle = stringResource(string.feat_setting_extension_plugins)
+ val extensionDetailsTitle = stringResource(string.feat_setting_extension_details)
+ val extensionAuthorizationTitle =
+ stringResource(string.feat_setting_extension_confirm_title)
+ val extensionSettingsTitle = stringResource(string.feat_setting_extension_settings)
val appearanceTitle = stringResource(string.feat_setting_appearance)
val optionalTitle = stringResource(string.feat_setting_optional_features)
val codecPackTitle = stringResource(string.feat_setting_codec_pack)
- val colorArgb by preferenceOf(PreferencesKeys.COLOR_ARGB)
-
val navigator = rememberListDetailPaneScaffoldNavigator()
+ val backNavigationBehavior = BackNavigationBehavior.PopUntilContentChange
val destination = navigator.currentDestination?.contentKey ?: SettingDestination.Default
+ val configurationPlaylist = (
+ destination as? SettingDestination.PlaylistConfiguration
+ )?.let { configurationDestination ->
+ playlists
+ ?.keys
+ ?.firstOrNull { playlist ->
+ playlistConfigurationReference(playlist.url) ==
+ configurationDestination.playlistReference
+ }
+ }
+ val configurationProviderAccount = configurationPlaylist?.let { playlist ->
+ providerAccountSummaries.firstOrNull { account ->
+ account.playlistUrl == playlist.url
+ }
+ }
+ val configurationProviderDisplayName = configurationProviderAccount?.let { account ->
+ providerDisplayName(account, providerDiscoveryState)
+ }
+ val configurationTitle = configurationPlaylist
+ ?.title
+ ?.withoutBidiControls()
+ ?.takeIf(String::isNotBlank)
+ ?: playlistTitle
+ val density = LocalDensity.current
+ val showPlaylistPaneHeader =
+ resolveAppNavigationMode(
+ with(density) {
+ LocalWindowInfo.current.containerSize.width.toDp()
+ }
+ ) == AppNavigationMode.SideRail
+ val playlistListPaneVisible =
+ navigator.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
+ val currentPlaylistEditorTitle = when {
+ destination !is SettingDestination.PlaylistEditor -> playlistEditorTitle
+ destination.reauthenticationPlaylistUrl != null ->
+ playlistReauthenticationTitle
+ destination.sourceKey == DataSource.EPG.subscriptionSelectionKey() ->
+ playlistEpgEditorTitle
+ else -> playlistEditorTitle
+ }
+
+ LaunchedEffect(navigator, subscriptionAccepted) {
+ subscriptionAccepted.collect {
+ if (
+ navigator.currentDestination?.contentKey
+ !is SettingDestination.PlaylistEditor
+ ) {
+ return@collect
+ }
+ navigator.returnToPlaylistManagement()
+ }
+ }
+
+ LaunchedEffect(destination) {
+ when (destination) {
+ is SettingDestination.ExtensionPluginSettings ->
+ onOpenExtensionSettings(destination.extensionId)
+ else -> onCloseExtensionSettings()
+ }
+ }
+
+ LaunchedEffect(destination, onDetailVisibilityChanged) {
+ onDetailVisibilityChanged(destination != SettingDestination.Default)
+ }
+ DisposableEffect(onDetailVisibilityChanged) {
+ onDispose { onDetailVisibilityChanged(false) }
+ }
EventHandler(Events.settingDestination) {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it)
}
- LifecycleResumeEffect(destination, defaultTitle, playlistTitle, appearanceTitle, optionalTitle, codecPackTitle) {
- Metadata.title = when (destination) {
+ LifecycleResumeEffect(
+ destination,
+ extensionPlugins,
+ defaultTitle,
+ playlistTitle,
+ playlistSourcePickerTitle,
+ playlistEditorTitle,
+ playlistEpgEditorTitle,
+ playlistReauthenticationTitle,
+ playlistEpgTitle,
+ playlistHiddenChannelsTitle,
+ playlistHiddenCategoriesTitle,
+ extensionPluginsTitle,
+ extensionDetailsTitle,
+ extensionAuthorizationTitle,
+ extensionSettingsTitle,
+ appearanceTitle,
+ optionalTitle,
+ codecPackTitle,
+ configurationTitle,
+ ) {
+ val title = when (destination) {
SettingDestination.Default -> defaultTitle
SettingDestination.Playlists -> playlistTitle
+ is SettingDestination.PlaylistConfiguration -> configurationTitle
+ SettingDestination.PlaylistSourcePicker -> playlistSourcePickerTitle
+ is SettingDestination.PlaylistEditor -> currentPlaylistEditorTitle
+ SettingDestination.PlaylistEpgSources -> playlistEpgTitle
+ SettingDestination.PlaylistHiddenChannels -> playlistHiddenChannelsTitle
+ SettingDestination.PlaylistHiddenCategories -> playlistHiddenCategoriesTitle
+ SettingDestination.ExtensionPlugins -> extensionPluginsTitle
+ is SettingDestination.ExtensionPluginDetails -> extensionDetailsTitle
+ is SettingDestination.ExtensionPluginAuthorization ->
+ extensionAuthorizationTitle
+ is SettingDestination.ExtensionPluginSettings -> extensionSettingsTitle
SettingDestination.Appearance -> appearanceTitle
SettingDestination.Optional -> optionalTitle
SettingDestination.CodecPack -> codecPackTitle
}
- .title()
- .let(::AnnotatedString)
+ Metadata.title = (
+ if (destination.usesLocalizedStaticTitle()) title.title() else title
+ ).let(::AnnotatedString)
+ Metadata.actions = emptyList()
Metadata.color = Color.Unspecified
Metadata.contentColor = Color.Unspecified
if (destination != SettingDestination.Default) {
Metadata.fob = Fob(
destination = Destination.Setting,
- icon = Icons.Rounded.ChangeCircle,
- iconTextId = string.feat_setting_back_home
+ icon = Icons.AutoMirrored.Rounded.ArrowBack,
+ iconTextId = string.ui_cd_top_bar_on_back_pressed,
) {
coroutineScope.launch {
- navigator.navigateBack()
+ navigator.navigateBack(backNavigationBehavior)
}
}
}
@@ -216,10 +532,17 @@ private fun SettingScreen(
versionCode = versionCode,
codecPackEnabled = codecPackState.enabled,
navigateToPlaylistManagement = {
+ if (destination != SettingDestination.Playlists) {
+ coroutineScope.launch {
+ navigator.returnToPlaylistManagement()
+ }
+ }
+ },
+ navigateToExtensionPlugins = {
coroutineScope.launch {
navigator.navigateTo(
pane = ListDetailPaneScaffoldRole.Detail,
- contentKey = SettingDestination.Playlists
+ contentKey = SettingDestination.ExtensionPlugins,
)
}
},
@@ -253,28 +576,422 @@ private fun SettingScreen(
detailPane = {
when (destination) {
SettingDestination.Playlists -> {
- SubscriptionsFragment(
- backingUpOrRestoring = backingUpOrRestoring,
- hiddenChannels = hiddenChannels,
- hiddenCategoriesWithPlaylists = hiddenCategoriesWithPlaylists,
- onUnhideChannel = onUnhideChannel,
- onUnhidePlaylistCategory = onUnhidePlaylistCategory,
- onClipboard = onClipboard,
- onSubscribe = onSubscribe,
- backup = backup,
- restore = restore,
- epgs = epgs,
- onDeleteEpgPlaylist = onDeleteEpgPlaylist,
+ PlaylistDetailPane(
+ showHeader = showPlaylistPaneHeader,
+ title = playlistTitle,
+ onBack = if (
+ showPlaylistPaneHeader && !playlistListPaneVisible
+ ) {
+ {
+ coroutineScope.launch {
+ navigator.navigateBack(backNavigationBehavior)
+ }
+ }
+ } else {
+ null
+ },
+ ) {
+ PlaylistManagementOverviewScreen(
+ backingUpOrRestoring = backingUpOrRestoring,
+ playlists = playlists,
+ playlistSubscriptionInProgress =
+ playlistSubscriptionInProgress,
+ playlistSubscriptionState =
+ playlistSubscriptionState,
+ onCancelPlaylistSubscription =
+ onCancelPlaylistSubscription,
+ onDismissPlaylistSubscription =
+ onDismissPlaylistSubscription,
+ onRetryPlaylistSubscription = { source ->
+ onDismissPlaylistSubscription()
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey =
+ SettingDestination.PlaylistEditor(
+ sourceKey =
+ source.subscriptionSelectionKey(),
+ ),
+ )
+ }
+ },
+ epgCount = epgs.size,
+ hiddenChannelCount = hiddenChannels.size,
+ hiddenCategoryCount = hiddenCategoriesWithPlaylists.size,
+ providerDiscoveryState = providerDiscoveryState,
+ providerAccountSummaries = providerAccountSummaries,
+ providerOperationState = providerOperationState,
+ onAddPlaylist = {
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.PlaylistSourcePicker,
+ )
+ }
+ },
+ onOpenPlaylistConfiguration = { playlist ->
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey =
+ SettingDestination.PlaylistConfiguration(
+ playlistReference =
+ playlistConfigurationReference(
+ playlist.url
+ ),
+ ),
+ )
+ }
+ },
+ onOpenEpgSources = {
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.PlaylistEpgSources,
+ )
+ }
+ },
+ onOpenHiddenChannels = {
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.PlaylistHiddenChannels,
+ )
+ }
+ },
+ onOpenHiddenCategories = {
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.PlaylistHiddenCategories,
+ )
+ }
+ },
+ onReauthenticateProviderAccount = { account ->
+ onReauthenticateProviderAccount(account.playlistUrl)
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.PlaylistEditor(
+ sourceKey = providerSourceSelectionKey(
+ providerId = account.providerId.value,
+ providerKind = account.providerKind.value,
+ ),
+ providerId = account.providerId.value,
+ providerKind = account.providerKind.value,
+ reauthenticationPlaylistUrl = account.playlistUrl,
+ ),
+ )
+ }
+ },
+ onBackup = backup,
+ onRestore = restore,
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ is SettingDestination.PlaylistConfiguration -> {
+ PlaylistDetailPane(
+ showHeader = showPlaylistPaneHeader,
+ title = configurationTitle,
+ onBack = {
+ coroutineScope.launch {
+ navigator.navigateBack(backNavigationBehavior)
+ }
+ },
+ ) {
+ PlaylistConfigurationRoute(
+ viewModel = hiltViewModel(
+ key = "playlist-configuration:" +
+ destination.playlistReference,
+ ),
+ playlistReference = destination.playlistReference,
+ providerDisplayNameOverride =
+ configurationProviderDisplayName,
+ onPlaylistRemoved = {
+ coroutineScope.launch {
+ navigator.returnToPlaylistManagement()
+ }
+ },
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ SettingDestination.PlaylistSourcePicker -> {
+ PlaylistDetailPane(
+ showHeader = showPlaylistPaneHeader,
+ title = playlistSourcePickerTitle,
+ onBack = {
+ coroutineScope.launch {
+ navigator.navigateBack(backNavigationBehavior)
+ }
+ },
+ ) {
+ SubscriptionSourcePickerScreen(
+ dataOperationInProgress =
+ backingUpOrRestoring != BackingUpAndRestoringState.NONE,
+ subscriptionSubmissionBlocked =
+ playlistSubscriptionInProgress ||
+ playlistSubscriptionState.phase !=
+ PlaylistSubscriptionPhase.IDLE,
+ providerDiscoveryState = providerDiscoveryState,
+ providerOperationState = providerOperationState,
+ onSelectSubscriptionProviderVariant =
+ onSelectSubscriptionProviderVariant,
+ onRetryProviderDiscovery = onRetryProviderDiscovery,
+ onOpenEditor = { sourceKey ->
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.PlaylistEditor(
+ sourceKey = sourceKey,
+ ),
+ )
+ }
+ },
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ is SettingDestination.PlaylistEditor -> {
+ PlaylistDetailPane(
+ showHeader = showPlaylistPaneHeader,
+ title = currentPlaylistEditorTitle,
+ onBack = {
+ coroutineScope.launch {
+ navigator.navigateBack(backNavigationBehavior)
+ }
+ },
+ ) {
+ SubscriptionEditorScreen(
+ dataOperationInProgress =
+ backingUpOrRestoring != BackingUpAndRestoringState.NONE,
+ subscriptionSubmissionBlocked =
+ playlistSubscriptionInProgress ||
+ playlistSubscriptionState.phase !=
+ PlaylistSubscriptionPhase.IDLE,
+ sourceKey = destination.sourceKey,
+ draftKey = destination.draftKey,
+ providerId = destination.providerId,
+ providerKind = destination.providerKind,
+ reauthenticationPlaylistUrl =
+ destination.reauthenticationPlaylistUrl,
+ onClipboard = onClipboard,
+ onBeginSubscriptionDraft = onBeginSubscriptionDraft,
+ onSubscribe = onSubscribe,
+ providerDiscoveryState = providerDiscoveryState,
+ providerSubscriptionForm = providerSubscriptionForm,
+ providerOperationState = providerOperationState,
+ onSelectSubscriptionProviderVariant =
+ onSelectSubscriptionProviderVariant,
+ onUpdateSubscriptionProviderSetting =
+ onUpdateSubscriptionProviderSetting,
+ onRetryProviderDiscovery = onRetryProviderDiscovery,
+ onRetryProviderReauthentication =
+ onReauthenticateProviderAccount,
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ SettingDestination.PlaylistEpgSources -> {
+ PlaylistDetailPane(
+ showHeader = showPlaylistPaneHeader,
+ title = playlistEpgTitle,
+ onBack = {
+ coroutineScope.launch {
+ navigator.navigateBack(backNavigationBehavior)
+ }
+ },
+ ) {
+ EpgSourceListScreen(
+ epgs = epgs,
+ onAddEpgSource = {
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.PlaylistEditor(
+ sourceKey =
+ DataSource.EPG.subscriptionSelectionKey(),
+ ),
+ )
+ }
+ },
+ onDeleteEpgPlaylist = onDeleteEpgPlaylist,
+ enabled = backingUpOrRestoring ==
+ BackingUpAndRestoringState.NONE &&
+ !providerOperationState.isBusy,
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ SettingDestination.PlaylistHiddenChannels -> {
+ PlaylistDetailPane(
+ showHeader = showPlaylistPaneHeader,
+ title = playlistHiddenChannelsTitle,
+ onBack = {
+ coroutineScope.launch {
+ navigator.navigateBack(backNavigationBehavior)
+ }
+ },
+ ) {
+ HiddenChannelListScreen(
+ hiddenChannels = hiddenChannels,
+ onUnhideChannel = onUnhideChannel,
+ enabled = backingUpOrRestoring ==
+ BackingUpAndRestoringState.NONE &&
+ !providerOperationState.isBusy,
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ SettingDestination.PlaylistHiddenCategories -> {
+ PlaylistDetailPane(
+ showHeader = showPlaylistPaneHeader,
+ title = playlistHiddenCategoriesTitle,
+ onBack = {
+ coroutineScope.launch {
+ navigator.navigateBack(backNavigationBehavior)
+ }
+ },
+ ) {
+ HiddenCategoryListScreen(
+ hiddenCategoriesWithPlaylists =
+ hiddenCategoriesWithPlaylists,
+ onUnhidePlaylistCategory = onUnhidePlaylistCategory,
+ enabled = backingUpOrRestoring ==
+ BackingUpAndRestoringState.NONE &&
+ !providerOperationState.isBusy,
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ SettingDestination.ExtensionPlugins -> {
+ ExtensionPluginListScreen(
+ state = extensionPluginDiscoveryState,
+ operationState = extensionPluginOperationState,
+ onRefresh = onRefreshExtensionPlugins,
+ onOpenDetails = { packageName, serviceName ->
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.ExtensionPluginDetails(
+ packageName = packageName,
+ serviceName = serviceName,
+ ),
+ )
+ }
+ },
contentPadding = contentPadding,
- modifier = Modifier.fillMaxSize()
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ is SettingDestination.ExtensionPluginDetails -> {
+ val detailState = resolveExtensionPluginDetailContentState(
+ discoveryState = extensionPluginDiscoveryState,
+ packageName = destination.packageName,
+ serviceName = destination.serviceName,
+ )
+ ExtensionPluginDetailScreen(
+ state = detailState,
+ operationState = extensionPluginOperationState,
+ onRetryDiscovery = onRefreshExtensionPlugins,
+ onOpenAuthorization = { reauthorize ->
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey =
+ SettingDestination.ExtensionPluginAuthorization(
+ packageName = destination.packageName,
+ serviceName = destination.serviceName,
+ reauthorize = reauthorize,
+ ),
+ )
+ }
+ },
+ onOpenSettings = { extensionId ->
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.ExtensionPluginSettings(
+ extensionId = extensionId,
+ ),
+ )
+ }
+ },
+ onDisable = onDisableExtensionPlugin,
+ onRevoke = onRevokeExtensionPlugin,
+ onClearData = onClearExtensionData,
+ onExportDiagnostics = onExportExtensionDiagnostics,
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ is SettingDestination.ExtensionPluginAuthorization -> {
+ val authorizationState =
+ resolveExtensionPluginDetailContentState(
+ discoveryState = extensionPluginDiscoveryState,
+ packageName = destination.packageName,
+ serviceName = destination.serviceName,
+ )
+ ExtensionPluginAuthorizationScreen(
+ state = authorizationState,
+ operationState = extensionPluginOperationState,
+ onRetryDiscovery = onRefreshExtensionPlugins,
+ reauthorize = destination.reauthorize,
+ onAuthorize = { packageName, serviceName, token, reauthorize ->
+ if (reauthorize) {
+ onReauthorizeExtensionPlugin(packageName, serviceName, token)
+ } else {
+ onEnableExtensionPlugin(packageName, serviceName, token)
+ }
+ coroutineScope.launch {
+ navigator.navigateBack(backNavigationBehavior)
+ }
+ },
+ onCancel = {
+ coroutineScope.launch {
+ navigator.navigateBack(backNavigationBehavior)
+ }
+ },
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ is SettingDestination.ExtensionPluginSettings -> {
+ ExtensionSettingsScreen(
+ state = extensionSettingsState,
+ extensionId = destination.extensionId,
+ onRetry = {
+ onOpenExtensionSettings(destination.extensionId)
+ },
+ onUpdate = onUpdateExtensionSetting,
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
)
}
SettingDestination.Appearance -> {
AppearanceFragment(
colorSchemes = colorSchemes,
- colorArgb = colorArgb,
openColorScheme = openColorScheme,
+ onSelectTheme = onSelectTheme,
restoreSchemes = restoreSchemes,
contentPadding = contentPadding,
modifier = Modifier.fillMaxSize()
@@ -307,9 +1024,93 @@ private fun SettingScreen(
.hazeSource(LocalHazeState.current)
.testTag("feature:setting")
)
- BackHandler(navigator.canNavigateBack()) {
+ BackHandler(navigator.canNavigateBack(backNavigationBehavior)) {
coroutineScope.launch {
- navigator.navigateBack()
+ navigator.navigateBack(backNavigationBehavior)
}
}
}
+
+@Composable
+private fun PlaylistDetailPane(
+ showHeader: Boolean,
+ title: String,
+ modifier: Modifier = Modifier,
+ onBack: (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+) {
+ Column(modifier = modifier.fillMaxSize()) {
+ if (showHeader) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 64.dp)
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (onBack != null) {
+ IconButton(
+ onClick = onBack,
+ modifier = Modifier.sizeIn(
+ minWidth = 48.dp,
+ minHeight = 48.dp,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(
+ string.ui_cd_top_bar_on_back_pressed
+ ),
+ )
+ }
+ }
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 8.dp)
+ .semantics { heading() },
+ )
+ }
+ HorizontalDivider()
+ }
+ Box(modifier = Modifier.weight(1f)) {
+ content()
+ }
+ }
+}
+
+private fun SettingDestination.usesLocalizedStaticTitle(): Boolean = when (this) {
+ SettingDestination.Default,
+ SettingDestination.Appearance,
+ SettingDestination.Optional,
+ SettingDestination.CodecPack -> true
+ SettingDestination.Playlists,
+ is SettingDestination.PlaylistConfiguration,
+ SettingDestination.PlaylistSourcePicker,
+ is SettingDestination.PlaylistEditor,
+ SettingDestination.PlaylistEpgSources,
+ SettingDestination.PlaylistHiddenChannels,
+ SettingDestination.PlaylistHiddenCategories,
+ SettingDestination.ExtensionPlugins,
+ is SettingDestination.ExtensionPluginDetails,
+ is SettingDestination.ExtensionPluginAuthorization,
+ is SettingDestination.ExtensionPluginSettings -> false
+}
+
+private suspend fun ThreePaneScaffoldNavigator
+ .returnToPlaylistManagement() {
+ while (
+ currentDestination?.contentKey != SettingDestination.Playlists &&
+ canNavigateBack(BackNavigationBehavior.PopLatest)
+ ) {
+ navigateBack(BackNavigationBehavior.PopLatest)
+ }
+ if (currentDestination?.contentKey != SettingDestination.Playlists) {
+ navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.Playlists,
+ )
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/CanvasBottomSheet.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/CanvasBottomSheet.kt
index 427883057..838424e9d 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/CanvasBottomSheet.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/CanvasBottomSheet.kt
@@ -40,9 +40,11 @@ import com.m3u.data.database.model.ColorScheme
import com.m3u.i18n.R.string
import androidx.compose.material3.Icon
import com.m3u.smartphone.ui.material.ktx.createScheme
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
import com.m3u.smartphone.ui.material.model.LocalSpacing
import com.m3u.core.foundation.ui.SugarColors
import com.m3u.smartphone.ui.material.components.FontFamilies
+import com.m3u.smartphone.ui.material.ktx.contrastingContentColor
@OptIn(ExperimentalStdlibApi::class)
@Composable
@@ -54,6 +56,7 @@ internal fun CanvasBottomSheet(
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
+ val bidiFormatter = rememberUiBidiFormatter()
val argb = colorScheme?.argb
val isDark = colorScheme?.isDark
val isTemp = colorScheme?.name == ColorScheme.NAME_TEMP
@@ -86,13 +89,18 @@ internal fun CanvasBottomSheet(
val green by remember { derivedStateOf { color.green } }
val blue by remember { derivedStateOf { color.blue } }
- val colorText by remember {
- derivedStateOf { "#${color.toArgb().toHexString(HexFormat.UpperCase)}" }
+ val colorText by remember(bidiFormatter) {
+ derivedStateOf {
+ bidiFormatter.ltr(
+ "#${color.toArgb().toHexString(HexFormat.UpperCase)}"
+ )
+ }
}
Card(
colors = CardDefaults.cardColors(
containerColor = color,
+ contentColor = contrastingContentColor(color),
)
) {
Box(
@@ -150,7 +158,7 @@ internal fun CanvasBottomSheet(
)
},
label = {
- Text(stringResource(string.feat_setting_canvas_apply).uppercase())
+ Text(stringResource(string.feat_setting_canvas_apply))
},
modifier = Modifier.weight(1f)
)
@@ -176,7 +184,7 @@ internal fun CanvasBottomSheet(
)
},
label = {
- Text(stringResource(string.feat_setting_canvas_reset).uppercase())
+ Text(stringResource(string.feat_setting_canvas_reset))
},
modifier = Modifier.weight(1f)
)
@@ -195,7 +203,7 @@ internal fun CanvasBottomSheet(
true -> Icons.Rounded.DarkMode
false -> Icons.Rounded.LightMode
},
- contentDescription = "",
+ contentDescription = null,
tint = when (currentIsDark) {
true -> SugarColors.Tee.color
false -> SugarColors.Yellow.color
@@ -211,7 +219,7 @@ internal fun CanvasBottomSheet(
true -> string.feat_setting_canvas_dark
false -> string.feat_setting_canvas_light
}
- ).uppercase()
+ )
)
}
},
@@ -223,4 +231,4 @@ internal fun CanvasBottomSheet(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/DataSourceSelection.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/DataSourceSelection.kt
deleted file mode 100644
index 5158c5e84..000000000
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/DataSourceSelection.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.m3u.smartphone.ui.business.setting.components
-
-import androidx.compose.foundation.border
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.wrapContentSize
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.CheckCircle
-import androidx.compose.material.icons.rounded.KeyboardArrowDown
-import androidx.compose.material.icons.rounded.KeyboardArrowUp
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.LocalContentColor
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import com.m3u.data.database.model.DataSource
-import com.m3u.smartphone.ui.material.components.ClickableSelection
-import androidx.compose.material3.Icon
-import com.m3u.smartphone.ui.material.components.SelectionsDefaults
-
-@Composable
-internal fun DataSourceSelection(
- selectedState: MutableState,
- supported: List,
- modifier: Modifier = Modifier
-) {
- var expanded by remember { mutableStateOf(false) }
- Box(
- modifier = Modifier
- .fillMaxSize()
- .wrapContentSize(Alignment.TopStart)
- .border(2.dp, LocalContentColor.current.copy(0.38f), SelectionsDefaults.Shape)
- .then(modifier)
- ) {
- ClickableSelection(
- onClick = { expanded = true },
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(stringResource(selectedState.value.resId))
- Icon(
- imageVector = if (expanded) {
- Icons.Rounded.KeyboardArrowUp
- } else {
- Icons.Rounded.KeyboardArrowDown
- },
- contentDescription = null
- )
- }
- DropdownMenu(
- expanded = expanded,
- onDismissRequest = { expanded = false }
- ) {
- supported.forEach { current ->
- DropdownMenuItem(
- text = { Text(stringResource(current.resId)) },
- trailingIcon = {
- if (selectedState == current) {
- Icon(
- imageVector = Icons.Rounded.CheckCircle,
- contentDescription = null
- )
- }
- },
- enabled = current.supported,
- onClick = {
- selectedState.value = current
- expanded = false
- }
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/EpgPlaylistItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/EpgPlaylistItem.kt
index 0ddd8bfef..0a804bd82 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/EpgPlaylistItem.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/EpgPlaylistItem.kt
@@ -1,48 +1,125 @@
package com.m3u.smartphone.ui.business.setting.components
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
import com.m3u.data.database.model.Playlist
-import androidx.compose.material3.IconButton
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.safeSourceReference
@Composable
internal fun EpgPlaylistItem(
epgPlaylist: Playlist,
onDeleteEpgPlaylist: () -> Unit,
+ deleteContentDescription: String,
+ enabled: Boolean = true,
modifier: Modifier = Modifier
) {
- ListItem(
- headlineContent = {
+ val bidiFormatter = rememberUiBidiFormatter()
+ val displayTitle = bidiFormatter.natural(epgPlaylist.title)
+ val displayReference = epgPlaylist.url.safeSourceReference()
+ ?.let(bidiFormatter::ltr)
+ val stackAction =
+ LocalDensity.current.fontScale >= 1.5f ||
+ LocalConfiguration.current.screenWidthDp < 360
+ if (stackAction) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp),
+ ) {
Text(
- text = epgPlaylist.title,
- style = MaterialTheme.typography.titleSmall,
- maxLines = 1,
+ text = displayTitle,
+ style = MaterialTheme.typography.titleSmall.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
- },
- supportingContent = {
+ displayReference?.let { reference ->
+ Text(
+ text = reference,
+ style = MaterialTheme.typography.bodySmall.copy(
+ textDirection = TextDirection.Ltr,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ DeleteButton(
+ onClick = onDeleteEpgPlaylist,
+ enabled = enabled,
+ contentDescription = deleteContentDescription,
+ modifier = Modifier.align(Alignment.End),
+ )
+ }
+ return
+ }
+ ListItem(
+ headlineContent = {
Text(
- text = epgPlaylist.url,
- style = MaterialTheme.typography.bodySmall,
+ text = displayTitle,
+ style = MaterialTheme.typography.titleSmall.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
)
},
- trailingContent = {
- IconButton(
- onClick = onDeleteEpgPlaylist
- ) {
- Icon(
- imageVector = Icons.Rounded.Delete,
- contentDescription = "delete epg"
+ supportingContent = displayReference?.let { reference ->
+ {
+ Text(
+ text = reference,
+ style = MaterialTheme.typography.bodySmall.copy(
+ textDirection = TextDirection.Ltr,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
)
}
},
+ trailingContent = {
+ DeleteButton(
+ onClick = onDeleteEpgPlaylist,
+ enabled = enabled,
+ contentDescription = deleteContentDescription,
+ )
+ },
modifier = modifier
)
-}
\ No newline at end of file
+}
+
+@Composable
+private fun DeleteButton(
+ onClick: () -> Unit,
+ enabled: Boolean,
+ contentDescription: String,
+ modifier: Modifier = Modifier,
+) {
+ IconButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier.size(48.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Delete,
+ contentDescription = contentDescription,
+ )
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/HiddenChannelItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/HiddenChannelItem.kt
index 3fb2ec234..29d1b7009 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/HiddenChannelItem.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/HiddenChannelItem.kt
@@ -1,49 +1,133 @@
package com.m3u.smartphone.ui.business.setting.components
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
import com.m3u.data.database.model.Channel
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.safeSourceReference
@Composable
internal fun HiddenChannelItem(
channel: Channel,
- onHidden: () -> Unit,
+ onShow: () -> Unit,
+ showLabel: String,
+ showContentDescription: String,
+ enabled: Boolean = true,
modifier: Modifier = Modifier
) {
+ val bidiFormatter = rememberUiBidiFormatter()
+ val displayTitle = bidiFormatter.natural(channel.title)
+ val displayReference = channel.url.safeSourceReference()
+ ?.let(bidiFormatter::ltr)
+ val stackAction =
+ LocalDensity.current.fontScale >= 1.5f ||
+ LocalConfiguration.current.screenWidthDp < 360
+ if (stackAction) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp),
+ ) {
+ Text(
+ text = displayTitle,
+ style = MaterialTheme.typography.titleSmall.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ displayReference?.let { reference ->
+ Text(
+ text = reference,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.Ltr,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ ShowButton(
+ label = showLabel,
+ contentDescription = showContentDescription,
+ onClick = onShow,
+ enabled = enabled,
+ modifier = Modifier.align(Alignment.End),
+ )
+ }
+ return
+ }
+
ListItem(
headlineContent = {
- val text = channel.title
Text(
- text = text,
- style = MaterialTheme.typography.titleSmall,
- maxLines = 1,
+ text = displayTitle,
+ style = MaterialTheme.typography.titleSmall.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold
)
},
- supportingContent = {
- val text = channel.url
- Text(
- text = text,
- style = MaterialTheme.typography.bodyMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ supportingContent = displayReference?.let { reference ->
+ {
+ Text(
+ text = reference,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.Ltr,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
},
- modifier = Modifier
- .clickable(
- enabled = true,
- onClickLabel = null,
- role = Role.Button,
- onClick = onHidden
+ trailingContent = {
+ ShowButton(
+ label = showLabel,
+ contentDescription = showContentDescription,
+ onClick = onShow,
+ enabled = enabled,
)
- .then(modifier)
+ },
+ modifier = modifier,
)
-}
\ No newline at end of file
+}
+
+@Composable
+private fun ShowButton(
+ label: String,
+ contentDescription: String,
+ onClick: () -> Unit,
+ enabled: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ TextButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier
+ .heightIn(min = 48.dp)
+ .semantics {
+ this.contentDescription = contentDescription
+ },
+ ) {
+ Text(label)
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/HiddenPlaylistItem.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/HiddenPlaylistItem.kt
index b2515115e..1598282e7 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/HiddenPlaylistItem.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/HiddenPlaylistItem.kt
@@ -1,48 +1,128 @@
package com.m3u.smartphone.ui.business.setting.components
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
import com.m3u.data.database.model.Playlist
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
@Composable
internal fun HiddenPlaylistGroupItem(
playlist: Playlist,
group: String,
- onHidden: () -> Unit,
+ onShow: () -> Unit,
+ showLabel: String,
+ showContentDescription: String,
+ enabled: Boolean = true,
modifier: Modifier = Modifier
) {
+ val bidiFormatter = rememberUiBidiFormatter()
+ val displayGroup = bidiFormatter.natural(group)
+ val displayPlaylistTitle = bidiFormatter.natural(playlist.title)
+ val stackAction =
+ LocalDensity.current.fontScale >= 1.5f ||
+ LocalConfiguration.current.screenWidthDp < 360
+ if (stackAction) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp),
+ ) {
+ Text(
+ text = displayGroup,
+ style = MaterialTheme.typography.titleSmall.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = displayPlaylistTitle,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ ShowButton(
+ label = showLabel,
+ contentDescription = showContentDescription,
+ onClick = onShow,
+ enabled = enabled,
+ modifier = Modifier.align(Alignment.End),
+ )
+ }
+ return
+ }
+
ListItem(
headlineContent = {
Text(
- text = group,
- style = MaterialTheme.typography.titleSmall,
- maxLines = 1,
+ text = displayGroup,
+ style = MaterialTheme.typography.titleSmall.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold
)
},
supportingContent = {
Text(
- text = playlist.title,
- style = MaterialTheme.typography.bodyMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
+ text = displayPlaylistTitle,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
)
},
- modifier = Modifier
- .clickable(
- enabled = true,
- onClickLabel = null,
- role = Role.Button,
- onClick = onHidden
+ trailingContent = {
+ ShowButton(
+ label = showLabel,
+ contentDescription = showContentDescription,
+ onClick = onShow,
+ enabled = enabled,
)
- .then(modifier)
+ },
+ modifier = modifier,
)
-}
\ No newline at end of file
+}
+
+@Composable
+private fun ShowButton(
+ label: String,
+ contentDescription: String,
+ onClick: () -> Unit,
+ enabled: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ TextButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier
+ .heightIn(min = 48.dp)
+ .semantics {
+ this.contentDescription = contentDescription
+ },
+ ) {
+ Text(label)
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/LocalStorageButton.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/LocalStorageButton.kt
index 01adf94d9..170536317 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/LocalStorageButton.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/LocalStorageButton.kt
@@ -3,9 +3,16 @@ package com.m3u.smartphone.ui.business.setting.components
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.OpenInNew
+import androidx.compose.material.icons.rounded.FolderOpen
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
@@ -14,16 +21,23 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
import com.m3u.core.foundation.util.readFileName
import com.m3u.i18n.R.string
-import androidx.compose.material3.Icon
-import com.m3u.smartphone.ui.material.components.ToggleableSelection
+import com.m3u.smartphone.ui.material.ktx.safeDisplayText
+import com.m3u.smartphone.ui.material.model.LocalSpacing
@Composable
internal fun LocalStorageButton(
titleState: MutableState,
uriState: MutableState,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
) {
val context = LocalContext.current
val uri by uriState
@@ -31,48 +45,72 @@ internal fun LocalStorageButton(
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { result ->
- if (result == null) {
- titleState.value = ""
- uriState.value = Uri.EMPTY
- } else {
- try {
- val filename = result.readFileName(context.contentResolver)
- ?: "Playlist_${System.currentTimeMillis()}"
- val title = filename
- .split(".")
- .dropLast(1)
- .joinToString(separator = "", prefix = "", postfix = "")
- titleState.value = title
- } catch (ignored: Exception) {
+ if (result != null) {
+ runCatching {
+ result.readFileName(context.contentResolver)
+ }.getOrNull()?.takeIf(String::isNotBlank)?.let { filename ->
+ val safeFilename = filename.safeDisplayText()
+ titleState.value = safeFilename
+ .substringBeforeLast(delimiter = ".", missingDelimiterValue = safeFilename)
+ .ifBlank { safeFilename }
}
uriState.value = result
}
- uriState.value = result ?: Uri.EMPTY
}
- val icon = Icons.AutoMirrored.Rounded.OpenInNew
- val text = if (selected) remember(uri) {
- uri.readFileName(context.contentResolver).orEmpty()
- } else stringResource(string.feat_setting_label_select_from_local_storage)
+ val pickerLabel = stringResource(string.feat_setting_label_select_from_local_storage)
+ val selectedFileName = remember(uri) {
+ runCatching {
+ uri.readFileName(context.contentResolver)
+ }.getOrNull()
+ }
+ val safeSelectedFileName = selectedFileName
+ ?.safeDisplayText()
+ ?.takeIf { selected && it.isNotBlank() }
+ val text = safeSelectedFileName ?: pickerLabel
+ val spacing = LocalSpacing.current
- ToggleableSelection(
- checked = false,
- color = MaterialTheme.colorScheme.surfaceVariant,
- onChanged = {
+ OutlinedButton(
+ onClick = {
launcher.launch(
arrayOf(
"text/*",
- "video/*",
- "audio/*",
- "application/*",
+ "audio/x-mpegurl",
+ "application/x-mpegurl",
+ "application/vnd.apple.mpegurl",
+ "application/octet-stream",
)
)
},
+ enabled = enabled,
modifier = modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 56.dp)
+ .semantics {
+ contentDescription = pickerLabel
+ safeSelectedFileName?.let { stateDescription = it }
+ },
+ contentPadding = PaddingValues(
+ horizontal = spacing.medium,
+ vertical = spacing.small,
+ ),
) {
- Text(text.uppercase())
Icon(
- imageVector = icon,
- contentDescription = null
+ imageVector = Icons.Rounded.FolderOpen,
+ contentDescription = null,
+ )
+ Spacer(Modifier.size(spacing.small))
+ Text(
+ text = text,
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.labelLarge.copy(
+ textDirection = if (selected) {
+ TextDirection.ContentOrLtr
+ } else {
+ TextDirection.Unspecified
+ },
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
)
}
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/LocalStorageSwitch.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/LocalStorageSwitch.kt
index 31a0ff765..8f0edabee 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/LocalStorageSwitch.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/LocalStorageSwitch.kt
@@ -1,12 +1,24 @@
package com.m3u.smartphone.ui.business.setting.components
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
import com.m3u.i18n.R.string
-import com.m3u.smartphone.ui.material.components.ToggleableSelection
+import com.m3u.smartphone.ui.material.model.LocalSpacing
@Composable
internal fun LocalStorageSwitch(
@@ -15,15 +27,30 @@ internal fun LocalStorageSwitch(
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
- ToggleableSelection(
- checked = checked,
- onChanged = onChanged,
- modifier = modifier,
- enabled = enabled
+ val spacing = LocalSpacing.current
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 56.dp)
+ .clip(MaterialTheme.shapes.large)
+ .toggleable(
+ value = checked,
+ enabled = enabled,
+ role = Role.Switch,
+ onValueChange = onChanged,
+ )
+ .padding(
+ horizontal = spacing.medium,
+ vertical = spacing.small,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(spacing.medium),
) {
Text(
- text = stringResource(string.feat_setting_local_storage).uppercase(),
- modifier = Modifier.weight(1f)
+ text = stringResource(string.feat_setting_local_storage),
+ style = MaterialTheme.typography.bodyLarge,
+ color = LocalContentColor.current.copy(alpha = if (enabled) 1f else 0.38f),
+ modifier = Modifier.weight(1f),
)
Switch(checked = checked, onCheckedChange = null, enabled = enabled)
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/RemoteControlSubscribeSwitch.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/RemoteControlSubscribeSwitch.kt
index bedf0fc0e..8f01a5ced 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/RemoteControlSubscribeSwitch.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/RemoteControlSubscribeSwitch.kt
@@ -2,11 +2,10 @@ package com.m3u.smartphone.ui.business.setting.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
@@ -15,10 +14,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.material.model.LocalSpacing
@@ -34,24 +31,26 @@ internal fun RemoteControlSubscribeSwitch(
Row(
modifier = modifier
.fillMaxWidth()
- .height(56.dp)
- .clip(RoundedCornerShape(25))
+ .defaultMinSize(minHeight = 56.dp)
+ .clip(MaterialTheme.shapes.large)
.toggleable(
value = checked,
enabled = enabled,
onValueChange = { onChanged() },
- role = Role.Checkbox,
+ role = Role.Switch,
)
- .padding(horizontal = spacing.medium),
+ .padding(
+ horizontal = spacing.medium,
+ vertical = spacing.small,
+ ),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.medium)
) {
Text(
- text = stringResource(string.feat_setting_subscribe_for_tv).uppercase(),
- style = MaterialTheme.typography.titleSmall,
+ text = stringResource(string.feat_setting_subscribe_for_tv),
+ style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
- fontWeight = FontWeight.SemiBold,
- color = LocalContentColor.current.copy(0.38f).takeUnless { enabled } ?: Color.Unspecified
+ color = LocalContentColor.current.copy(alpha = if (enabled) 1f else 0.38f),
)
Switch(checked = checked, onCheckedChange = null, enabled = enabled)
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/AppearanceFragment.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/AppearanceFragment.kt
index 2f493dcaf..ec6c30050 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/AppearanceFragment.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/AppearanceFragment.kt
@@ -3,6 +3,8 @@ package com.m3u.smartphone.ui.business.setting.fragments
import android.annotation.SuppressLint
import android.os.Build
import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -34,10 +36,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import com.m3u.core.foundation.architecture.preferences.ClipMode
import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
+import com.m3u.core.foundation.architecture.preferences.ThemePreference
+import com.m3u.core.foundation.architecture.preferences.ThemePreset
+import com.m3u.core.foundation.architecture.preferences.ThemeStyle
import com.m3u.core.foundation.architecture.preferences.mutablePreferenceOf
+import com.m3u.core.foundation.architecture.preferences.themePreferencesOf
import com.m3u.core.foundation.ui.thenIf
import com.m3u.core.foundation.util.basic.title
import com.m3u.data.database.model.ColorScheme
@@ -56,27 +63,71 @@ import com.m3u.smartphone.ui.material.model.LocalSpacing
@Composable
internal fun AppearanceFragment(
colorSchemes: List,
- colorArgb: Int,
openColorScheme: (ColorScheme) -> Unit,
+ onSelectTheme: (ThemePreference) -> Unit,
restoreSchemes: () -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues()
) {
val spacing = LocalSpacing.current
- var isDarkMode by mutablePreferenceOf(PreferencesKeys.DARK_MODE)
- var useDynamicColors by mutablePreferenceOf(PreferencesKeys.USE_DYNAMIC_COLORS)
- var argb by mutablePreferenceOf(PreferencesKeys.COLOR_ARGB)
+ val themePreferences by themePreferencesOf()
+ val dynamicColorsPreference =
+ mutablePreferenceOf(PreferencesKeys.USE_DYNAMIC_COLORS)
+ val followSystemThemePreference =
+ mutablePreferenceOf(PreferencesKeys.FOLLOW_SYSTEM_THEME)
+ val selectedTheme = themePreferences.selection
+ val isDarkMode = selectedTheme.isDark
+ val useDynamicColors = themePreferences.useDynamicColors
+ val themeStyle = selectedTheme.style
+ val themePresetId = selectedTheme.presetId
+ val colorArgb = selectedTheme.argb
var clipMode by mutablePreferenceOf(PreferencesKeys.CLIP_MODE)
var compactDimension by mutablePreferenceOf(PreferencesKeys.COMPACT_DIMENSION)
var noPictureMode by mutablePreferenceOf(PreferencesKeys.NO_PICTURE_MODE)
- var followSystemTheme by mutablePreferenceOf(PreferencesKeys.FOLLOW_SYSTEM_THEME)
+ val followSystemTheme = themePreferences.followSystemTheme
var godMode by mutablePreferenceOf(PreferencesKeys.GOD_MODE)
val colorScheme = MaterialTheme.colorScheme
val leftContentDescription = stringResource(string.ui_theme_card_left)
val rightContentDescription = stringResource(string.ui_theme_card_right)
+ val editColorHint = stringResource(string.feat_setting_appearance_hint_edit_color)
+ val useDynamicColorsAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ val dynamicColorsActive = areDynamicColorsActive(
+ requested = useDynamicColors,
+ supported = useDynamicColorsAvailable,
+ )
+ val systemUsesDarkTheme = isSystemInDarkTheme()
+ val themeOptions = buildList {
+ selectThemeSchemeVariants(
+ schemes = colorSchemes,
+ followSystemTheme = followSystemTheme,
+ systemUsesDarkTheme = systemUsesDarkTheme,
+ ).forEach { stored ->
+ add(
+ AppearanceThemeOption(
+ presetId = ThemePreset.MATERIAL,
+ argb = stored.argb,
+ isDark = if (followSystemTheme) {
+ systemUsesDarkTheme
+ } else {
+ stored.isDark
+ },
+ style = ThemeStyle.MATERIAL,
+ colorScheme = stored,
+ )
+ )
+ }
+ add(
+ AppearanceThemeOption.warmEditorial(
+ isDark = followSystemTheme && systemUsesDarkTheme,
+ )
+ )
+ if (!followSystemTheme) {
+ add(AppearanceThemeOption.warmEditorial(isDark = true))
+ }
+ }
LazyColumn(
verticalArrangement = Arrangement.spacedBy(spacing.small),
@@ -102,7 +153,7 @@ internal fun AppearanceFragment(
MessageItem(
containerColor = colorScheme.primary,
contentColor = colorScheme.onPrimary,
- left = true,
+ alignedToStart = true,
contentDescription = leftContentDescription,
modifier = Modifier.sizeIn(maxWidth = maxWidth * 0.8f)
)
@@ -115,7 +166,7 @@ internal fun AppearanceFragment(
MessageItem(
containerColor = colorScheme.secondary,
contentColor = colorScheme.onSecondary,
- left = false,
+ alignedToStart = false,
contentDescription = rightContentDescription,
modifier = Modifier.sizeIn(maxWidth = maxWidth * 0.8f)
)
@@ -128,6 +179,8 @@ internal fun AppearanceFragment(
state = lazyListState,
modifier = Modifier
.fillMaxWidth()
+ .testTag("appearance-theme-selection-list")
+ .selectableGroup()
.thenIf(!lazyListState.isAtTop) {
Modifier.blurEdges(
colorScheme.surface, edges = listOf(Edge.Start, Edge.End)
@@ -138,21 +191,27 @@ internal fun AppearanceFragment(
contentPadding = PaddingValues(spacing.medium)
) {
items(
- items = colorSchemes,
- key = { "${it.argb}_${it.isDark}" }
- ) { colorScheme ->
+ items = themeOptions,
+ key = AppearanceThemeOption::key,
+ ) { option ->
val selected =
- !useDynamicColors && colorArgb == colorScheme.argb && isDarkMode == colorScheme.isDark
+ !dynamicColorsActive &&
+ themePresetId == option.presetId &&
+ colorArgb == option.argb &&
+ themeStyle == option.style &&
+ (followSystemTheme || isDarkMode == option.isDark)
ThemeSelection(
- argb = colorScheme.argb,
- isDark = colorScheme.isDark,
+ argb = option.argb,
+ isDark = option.isDark,
+ themeStyle = option.style,
selected = selected,
- onClick = {
- useDynamicColors = false
- argb = colorScheme.argb
- isDarkMode = colorScheme.isDark
+ themeName = option.localizedName(),
+ onClick = { onSelectTheme(option.toPreference()) },
+ onLongClick = option.colorScheme?.let { stored ->
+ { openColorScheme(stored) }
},
- onLongClick = { openColorScheme(colorScheme) },
+ onLongClickLabel = editColorHint
+ .takeIf { option.colorScheme != null },
)
}
// item {
@@ -217,18 +276,20 @@ internal fun AppearanceFragment(
title = string.feat_setting_follow_system_theme,
icon = Icons.Rounded.DarkMode,
checked = followSystemTheme,
- onChanged = { followSystemTheme = !followSystemTheme },
+ onChanged = {
+ followSystemThemePreference.value = !followSystemTheme
+ },
)
}
item {
- val useDynamicColorsAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
-
SwitchSharedPreference(
title = string.feat_setting_use_dynamic_colors,
content = string.feat_setting_use_dynamic_colors_unavailable.takeUnless { useDynamicColorsAvailable },
icon = Icons.Rounded.ColorLens,
- checked = useDynamicColors,
- onChanged = { useDynamicColors = !useDynamicColors },
+ checked = useDynamicColors && useDynamicColorsAvailable,
+ onChanged = {
+ dynamicColorsPreference.value = !useDynamicColors
+ },
enabled = useDynamicColorsAvailable
)
}
@@ -250,3 +311,69 @@ internal fun AppearanceFragment(
}
}
}
+
+private data class AppearanceThemeOption(
+ val presetId: String,
+ val argb: Int,
+ val isDark: Boolean,
+ val style: Int,
+ val colorScheme: ColorScheme?,
+) {
+ val key: String
+ get() = "$presetId:$argb:$isDark:${colorScheme?.name.orEmpty()}"
+
+ fun toPreference(): ThemePreference = ThemePreference(
+ presetId = presetId,
+ argb = argb,
+ isDark = isDark,
+ style = style,
+ )
+
+ companion object {
+ fun warmEditorial(isDark: Boolean) = AppearanceThemeOption(
+ presetId = ThemePreset.WARM_EDITORIAL,
+ argb = ThemePreset.WARM_EDITORIAL_SEED,
+ isDark = isDark,
+ style = ThemeStyle.WARM_EDITORIAL,
+ colorScheme = null,
+ )
+ }
+}
+
+internal fun selectThemeSchemeVariants(
+ schemes: List,
+ followSystemTheme: Boolean,
+ systemUsesDarkTheme: Boolean,
+): List {
+ if (!followSystemTheme) return schemes
+
+ val addedSeeds = mutableSetOf()
+ return buildList {
+ schemes.forEach { scheme ->
+ if (addedSeeds.add(scheme.argb)) {
+ add(
+ schemes.firstOrNull { candidate ->
+ candidate.argb == scheme.argb &&
+ candidate.isDark == systemUsesDarkTheme
+ } ?: scheme
+ )
+ }
+ }
+ }
+}
+
+internal fun areDynamicColorsActive(
+ requested: Boolean,
+ supported: Boolean,
+): Boolean = requested && supported
+
+@Composable
+private fun AppearanceThemeOption.localizedName(): String = when {
+ presetId == ThemePreset.WARM_EDITORIAL && isDark ->
+ stringResource(string.feat_setting_theme_ink)
+
+ presetId == ThemePreset.WARM_EDITORIAL ->
+ stringResource(string.feat_setting_theme_parchment)
+
+ else -> colorScheme?.name.orEmpty().title()
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionCapabilityPresentation.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionCapabilityPresentation.kt
new file mode 100644
index 000000000..221e8469d
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionCapabilityPresentation.kt
@@ -0,0 +1,32 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import androidx.annotation.StringRes
+import com.m3u.extension.api.ExtensionCapabilityIds
+import com.m3u.i18n.R.string
+
+@StringRes
+internal fun extensionCapabilityNameResource(capabilityId: String): Int? = when (capabilityId) {
+ ExtensionCapabilityIds.Network.id ->
+ string.feat_setting_extension_capability_name_network
+ ExtensionCapabilityIds.CredentialRead.id ->
+ string.feat_setting_extension_capability_name_credential_read
+ ExtensionCapabilityIds.CredentialWrite.id ->
+ string.feat_setting_extension_capability_name_credential_write
+ ExtensionCapabilityIds.SubscriptionRead.id ->
+ string.feat_setting_extension_capability_name_subscription_read
+ ExtensionCapabilityIds.SubscriptionWrite.id ->
+ string.feat_setting_extension_capability_name_subscription_write
+ ExtensionCapabilityIds.PlaybackResolve.id ->
+ string.feat_setting_extension_capability_name_playback_resolve
+ ExtensionCapabilityIds.EpgRead.id ->
+ string.feat_setting_extension_capability_name_epg_read
+ ExtensionCapabilityIds.MetadataWrite.id ->
+ string.feat_setting_extension_capability_name_metadata_write
+ ExtensionCapabilityIds.SettingsContribute.id ->
+ string.feat_setting_extension_capability_name_settings_contribute
+ ExtensionCapabilityIds.SearchRead.id ->
+ string.feat_setting_extension_capability_name_search_read
+ ExtensionCapabilityIds.BackgroundTask.id ->
+ string.feat_setting_extension_capability_name_background_task
+ else -> null
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionNetworkAccessPresentation.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionNetworkAccessPresentation.kt
new file mode 100644
index 000000000..82a9fbf5a
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionNetworkAccessPresentation.kt
@@ -0,0 +1,45 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.data.repository.extension.ExtensionNetworkOriginState
+import com.m3u.data.repository.extension.ExtensionSettingNetworkOrigin
+import com.m3u.data.repository.plugin.InstalledPlugin
+
+internal data class ExtensionNetworkAccessCounts(
+ val approved: Int,
+ val total: Int,
+)
+
+internal fun InstalledPlugin.visibleSettingNetworkOrigins(): List =
+ networkAccess.settingOrigins.filter { origin ->
+ origin.state != ExtensionNetworkOriginState.NOT_CONFIGURED
+ }
+
+internal fun InstalledPlugin.networkAccessCounts(): ExtensionNetworkAccessCounts {
+ val visibleSettingOrigins = visibleSettingNetworkOrigins()
+ return ExtensionNetworkAccessCounts(
+ approved = networkAccess.fixedOrigins.count { origin ->
+ origin.state == ExtensionNetworkOriginState.APPROVED
+ } + visibleSettingOrigins.count { origin ->
+ origin.state == ExtensionNetworkOriginState.APPROVED
+ },
+ total = networkAccess.fixedOrigins.size + visibleSettingOrigins.size,
+ )
+}
+
+internal val InstalledPlugin.hasSettingNetworkOriginWarning: Boolean
+ get() = networkAccess.settingOrigins.any { origin ->
+ origin.state.requiresUserAttention
+ }
+
+internal val ExtensionNetworkOriginState.requiresUserAttention: Boolean
+ get() = when (this) {
+ ExtensionNetworkOriginState.INVALID,
+ ExtensionNetworkOriginState.REQUIRES_APPROVAL,
+ ExtensionNetworkOriginState.SUSPENDED,
+ ExtensionNetworkOriginState.UNVERIFIED,
+ -> true
+
+ ExtensionNetworkOriginState.NOT_CONFIGURED,
+ ExtensionNetworkOriginState.APPROVED,
+ -> false
+ }
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginActionAvailability.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginActionAvailability.kt
new file mode 100644
index 000000000..372e2f476
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginActionAvailability.kt
@@ -0,0 +1,55 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.data.repository.plugin.InstalledPlugin
+import com.m3u.extension.api.ExtensionState
+
+internal data class ExtensionPluginActionAvailability(
+ val settings: Boolean,
+ val disable: Boolean,
+ val enable: Boolean,
+ val revoke: Boolean,
+ val reauthorize: Boolean,
+ val exportDiagnostics: Boolean,
+ val clearData: Boolean,
+)
+
+internal fun InstalledPlugin.actionAvailability() = extensionPluginActionAvailability(
+ enabled = enabled,
+ state = state,
+ hasExtensionId = extensionId != null,
+ installed = installed,
+ signatureChanged = signatureChanged,
+ hasInspectionError = inspectionError != null,
+ hasAuthorizationToken = authorizationToken != null,
+ trusted = trusted,
+ canClearData = canClearData,
+)
+
+internal fun extensionPluginActionAvailability(
+ enabled: Boolean,
+ state: ExtensionState,
+ hasExtensionId: Boolean,
+ installed: Boolean,
+ signatureChanged: Boolean,
+ hasInspectionError: Boolean,
+ hasAuthorizationToken: Boolean,
+ trusted: Boolean,
+ canClearData: Boolean,
+) = ExtensionPluginActionAvailability(
+ settings = enabled &&
+ state == ExtensionState.ENABLED &&
+ hasExtensionId,
+ disable = enabled && hasExtensionId,
+ enable = !enabled &&
+ state == ExtensionState.DISABLED &&
+ installed &&
+ !signatureChanged &&
+ !hasInspectionError &&
+ hasAuthorizationToken,
+ revoke = trusted || signatureChanged,
+ reauthorize = installed &&
+ (trusted || signatureChanged) &&
+ hasAuthorizationToken,
+ exportDiagnostics = installed && hasExtensionId,
+ clearData = canClearData,
+)
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginDetailPresentation.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginDetailPresentation.kt
new file mode 100644
index 000000000..01453a4bc
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginDetailPresentation.kt
@@ -0,0 +1,59 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.business.setting.ExtensionPluginDiscoveryState
+import com.m3u.data.repository.plugin.InstalledPlugin
+
+internal sealed interface ExtensionPluginDetailContentState {
+ data object Loading : ExtensionPluginDetailContentState
+
+ data object Missing : ExtensionPluginDetailContentState
+
+ data object Failure : ExtensionPluginDetailContentState
+
+ data class Content(
+ val plugin: InstalledPlugin,
+ val discoveryStatus: ExtensionPluginDiscoveryStatus,
+ ) : ExtensionPluginDetailContentState
+}
+
+internal enum class ExtensionPluginDiscoveryStatus {
+ READY,
+ REFRESHING,
+ REFRESH_FAILED,
+}
+
+internal fun resolveExtensionPluginDetailContentState(
+ discoveryState: ExtensionPluginDiscoveryState,
+ packageName: String,
+ serviceName: String,
+): ExtensionPluginDetailContentState {
+ val plugin = discoveryState.plugins.singleOrNull { candidate ->
+ candidate.packageName == packageName &&
+ candidate.serviceName == serviceName
+ }
+ return when (discoveryState) {
+ is ExtensionPluginDiscoveryState.Loading -> plugin?.let {
+ ExtensionPluginDetailContentState.Content(
+ plugin = it,
+ discoveryStatus = ExtensionPluginDiscoveryStatus.REFRESHING,
+ )
+ } ?: ExtensionPluginDetailContentState.Loading
+
+ is ExtensionPluginDiscoveryState.Error -> plugin?.let {
+ ExtensionPluginDetailContentState.Content(
+ plugin = it,
+ discoveryStatus = ExtensionPluginDiscoveryStatus.REFRESH_FAILED,
+ )
+ } ?: ExtensionPluginDetailContentState.Failure
+
+ is ExtensionPluginDiscoveryState.Content -> plugin?.let {
+ ExtensionPluginDetailContentState.Content(
+ plugin = it,
+ discoveryStatus = ExtensionPluginDiscoveryStatus.READY,
+ )
+ } ?: ExtensionPluginDetailContentState.Missing
+
+ ExtensionPluginDiscoveryState.Empty ->
+ ExtensionPluginDetailContentState.Missing
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginManagementScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginManagementScreen.kt
new file mode 100644
index 000000000..abdada7e2
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginManagementScreen.kt
@@ -0,0 +1,2608 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.rounded.BugReport
+import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material.icons.rounded.DeleteOutline
+import androidx.compose.material.icons.rounded.ExpandLess
+import androidx.compose.material.icons.rounded.ExpandMore
+import androidx.compose.material.icons.rounded.Extension
+import androidx.compose.material.icons.rounded.PowerSettingsNew
+import androidx.compose.material.icons.rounded.Public
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material.icons.rounded.Security
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.error
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil.compose.SubcomposeAsyncImage
+import com.m3u.business.setting.ExtensionPluginDiscoveryState
+import com.m3u.business.setting.ExtensionPluginOperation
+import com.m3u.business.setting.ExtensionPluginOperationState
+import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
+import com.m3u.core.foundation.architecture.preferences.mutablePreferenceOf
+import com.m3u.data.repository.extension.ExtensionNetworkOriginState
+import com.m3u.data.repository.extension.ExtensionSettingNetworkOrigin
+import com.m3u.data.repository.plugin.InstalledPlugin
+import com.m3u.data.repository.plugin.PluginAuthorizationToken
+import com.m3u.data.repository.plugin.PluginFixedNetworkOrigin
+import com.m3u.extension.api.ExtensionState
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.material.ktx.plus
+import com.m3u.smartphone.ui.material.ktx.UiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
+import java.text.NumberFormat
+
+private val ExtensionPageMaxWidth = 640.dp
+private const val MINIMUM_COMPACT_EXTENSION_ACTION_WIDTH_DP = 128f
+
+@Composable
+internal fun ExtensionPluginListScreen(
+ state: ExtensionPluginDiscoveryState,
+ operationState: ExtensionPluginOperationState,
+ onRefresh: () -> Unit,
+ onOpenDetails: (packageName: String, serviceName: String) -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ var externalExtensionsEnabled by mutablePreferenceOf(PreferencesKeys.EXTERNAL_EXTENSIONS)
+ val plugins = state.plugins
+ val operationRunning = operationState is ExtensionPluginOperationState.Running
+ val refreshing = (operationState as? ExtensionPluginOperationState.Running)
+ ?.operation == ExtensionPluginOperation.Refresh
+ val enabledState = stringResource(string.feat_setting_extension_state_enabled)
+ val disabledState = stringResource(string.feat_setting_extension_state_disabled)
+ val loadingDescription = stringResource(string.feat_setting_extension_loading)
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .testTag("extension-list"),
+ contentPadding = contentPadding + PaddingValues(horizontal = 16.dp, vertical = 12.dp),
+ ) {
+ item(key = "external-extension-toggle") {
+ ExtensionPageContent {
+ ListItem(
+ headlineContent = {
+ Text(stringResource(string.feat_setting_external_extensions))
+ },
+ supportingContent = {
+ Text(
+ stringResource(
+ string.feat_setting_external_extensions_description
+ )
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Rounded.Extension,
+ contentDescription = null,
+ )
+ },
+ trailingContent = {
+ Switch(
+ checked = externalExtensionsEnabled,
+ onCheckedChange = null,
+ enabled = !operationRunning,
+ modifier = Modifier.clearAndSetSemantics {},
+ )
+ },
+ colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("extension-feature-toggle")
+ .semantics(mergeDescendants = true) {
+ stateDescription = if (externalExtensionsEnabled) {
+ enabledState
+ } else {
+ disabledState
+ }
+ }
+ .toggleable(
+ value = externalExtensionsEnabled,
+ enabled = !operationRunning,
+ role = Role.Switch,
+ onValueChange = { enabled ->
+ externalExtensionsEnabled = enabled
+ },
+ ),
+ )
+ HorizontalDivider()
+ }
+ }
+
+ item(key = "extension-list-heading") {
+ ExtensionPageContent {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, top = 20.dp, bottom = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(string.feat_setting_extension_on_device),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 12.dp)
+ .semantics { heading() },
+ )
+ IconButton(
+ onClick = onRefresh,
+ enabled = externalExtensionsEnabled && !operationRunning,
+ modifier = Modifier.testTag("extension-refresh"),
+ ) {
+ if (refreshing) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(24.dp)
+ .semantics {
+ contentDescription = loadingDescription
+ },
+ strokeWidth = 2.dp,
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Rounded.Refresh,
+ contentDescription = stringResource(string.ui_action_refresh),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (externalExtensionsEnabled && state is ExtensionPluginDiscoveryState.Error) {
+ item(key = "extensions-error") {
+ ExtensionPageContent {
+ ExtensionWarning(
+ message = stringResource(
+ string.feat_setting_extension_operation_failed
+ ),
+ modifier = Modifier.testTag("extension-list-error"),
+ )
+ }
+ }
+ }
+
+ when {
+ !externalExtensionsEnabled -> {
+ item(key = "extensions-disabled") {
+ ExtensionPageContent {
+ ExtensionEmptyState(
+ text = stringResource(
+ string.feat_setting_extension_enable_external_hint
+ ),
+ )
+ }
+ }
+ }
+
+ state is ExtensionPluginDiscoveryState.Loading && plugins.isEmpty() -> {
+ item(key = "extensions-loading") {
+ ExtensionPageContent {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp)
+ .testTag("extension-list-loading"),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.semantics {
+ contentDescription = loadingDescription
+ },
+ )
+ }
+ }
+ }
+ }
+
+ plugins.isEmpty() && state !is ExtensionPluginDiscoveryState.Error -> {
+ item(key = "extensions-empty") {
+ ExtensionPageContent {
+ ExtensionEmptyState(
+ text = stringResource(string.feat_setting_extension_no_plugins),
+ )
+ }
+ }
+ }
+
+ else -> {
+ items(
+ items = plugins,
+ key = { plugin -> plugin.stableKey },
+ ) { plugin ->
+ ExtensionPageContent {
+ ExtensionPluginListItem(
+ plugin = plugin,
+ onClick = {
+ onOpenDetails(plugin.packageName, plugin.serviceName)
+ },
+ )
+ HorizontalDivider(modifier = Modifier.padding(start = 72.dp))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionPluginListItem(
+ plugin: InstalledPlugin,
+ onClick: () -> Unit,
+) {
+ val bidiFormatter = rememberUiBidiFormatter()
+ val hasWarning = plugin.hasVisibleWarning
+ val stateLabel = extensionStateLabel(plugin.state)
+ val warningDescription = extensionWarningMessages(
+ plugin = plugin,
+ unapprovedNetworkOrigins = plugin.networkOrigins - plugin.approvedNetworkOrigins,
+ bidiFormatter = bidiFormatter,
+ ).joinToString(separator = "\n")
+ val supportingText = buildList {
+ plugin.developer
+ ?.takeIf(String::isNotBlank)
+ ?.let(bidiFormatter::natural)
+ ?.let(::add)
+ plugin.version
+ ?.takeIf(String::isNotBlank)
+ ?.let { version -> bidiFormatter.ltr("v$version") }
+ ?.let(::add)
+ }.joinToString(separator = " · ")
+
+ ListItem(
+ headlineContent = {
+ Text(
+ plugin.displayName
+ ?.takeIf(String::isNotBlank)
+ ?.let(bidiFormatter::natural)
+ ?: bidiFormatter.ltr(plugin.packageName),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ supportingContent = {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ if (supportingText.isNotEmpty()) {
+ Text(
+ text = supportingText,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Text(
+ text = stateLabel,
+ style = MaterialTheme.typography.labelMedium,
+ color = if (hasWarning) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+ },
+ leadingContent = {
+ ExtensionApplicationIcon(
+ plugin = plugin,
+ size = 44.dp,
+ fallbackIconSize = 22.dp,
+ warningBadgeSize = 18.dp,
+ )
+ },
+ trailingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ contentDescription = null,
+ )
+ },
+ colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(MaterialTheme.shapes.large)
+ .clickable(
+ role = Role.Button,
+ onClick = onClick,
+ )
+ .semantics(mergeDescendants = true) {
+ stateDescription = stateLabel
+ if (warningDescription.isNotEmpty()) {
+ error(warningDescription)
+ }
+ }
+ .testTag("extension-plugin-list-item:${plugin.stableKey}"),
+ )
+}
+
+@Composable
+internal fun ExtensionPluginDetailScreen(
+ state: ExtensionPluginDetailContentState,
+ operationState: ExtensionPluginOperationState,
+ onRetryDiscovery: () -> Unit,
+ onOpenAuthorization: (reauthorize: Boolean) -> Unit,
+ onOpenSettings: (extensionId: String) -> Unit,
+ onDisable: (extensionId: String) -> Unit,
+ onRevoke: (packageName: String, serviceName: String, extensionId: String?) -> Unit,
+ onClearData: (packageName: String, serviceName: String, extensionId: String?) -> Unit,
+ onExportDiagnostics: (extensionId: String) -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ val contentState = state as? ExtensionPluginDetailContentState.Content
+ if (contentState == null) {
+ ExtensionPluginLookupScreen(
+ state = state,
+ onRetry = onRetryDiscovery,
+ retryEnabled =
+ operationState !is ExtensionPluginOperationState.Running,
+ modifier = modifier,
+ contentPadding = contentPadding,
+ )
+ return
+ }
+
+ val plugin = contentState.plugin
+ val bidiFormatter = rememberUiBidiFormatter()
+ val actions = plugin.actionAvailability()
+ val unapprovedNetworkOrigins = plugin.networkAccess.fixedOrigins
+ .filter { origin ->
+ origin.state == ExtensionNetworkOriginState.REQUIRES_APPROVAL
+ }
+ .mapTo(linkedSetOf(), PluginFixedNetworkOrigin::origin)
+ val networkAccessCounts = plugin.networkAccessCounts()
+ val reauthorizationIsPrimary = actions.reauthorize &&
+ (plugin.signatureChanged || unapprovedNetworkOrigins.isNotEmpty())
+ val runningOperation = (
+ operationState as? ExtensionPluginOperationState.Running
+ )?.operation
+ val operationForThisPlugin = runningOperation?.takeIf { operation ->
+ operation.targets(plugin)
+ }
+ val discoveryRefreshing =
+ contentState.discoveryStatus == ExtensionPluginDiscoveryStatus.REFRESHING
+ val operationRunning = runningOperation != null || discoveryRefreshing
+ val operationDescription = when {
+ operationForThisPlugin != null ->
+ extensionOperationDescription(operationForThisPlugin)
+ runningOperation != null -> stringResource(
+ string.feat_setting_extension_operation_other_in_progress
+ )
+ discoveryRefreshing -> extensionOperationDescription(
+ ExtensionPluginOperation.Refresh
+ )
+ else -> null
+ }
+ val extensionId = plugin.extensionId
+ var pendingRevoke by remember { mutableStateOf(false) }
+ var pendingClear by remember { mutableStateOf(false) }
+ var capabilitiesExpanded by rememberSaveable(plugin.stableKey) {
+ mutableStateOf(false)
+ }
+ var networkOriginsExpanded by rememberSaveable(plugin.stableKey) {
+ mutableStateOf(false)
+ }
+ var technicalIdentityExpanded by rememberSaveable(plugin.stableKey) {
+ mutableStateOf(false)
+ }
+
+ Column(modifier = modifier.fillMaxSize()) {
+ operationDescription?.let { description ->
+ ExtensionOperationStatus(description = description)
+ }
+ LazyColumn(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .testTag("extension-plugin-detail:${plugin.stableKey}"),
+ contentPadding =
+ contentPadding + PaddingValues(horizontal = 16.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ item(key = "identity") {
+ ExtensionPageContent {
+ ExtensionIdentityHeader(
+ plugin = plugin,
+ bidiFormatter = bidiFormatter,
+ )
+ }
+ }
+
+ if (
+ contentState.discoveryStatus ==
+ ExtensionPluginDiscoveryStatus.REFRESH_FAILED
+ ) {
+ item(key = "discovery-failure") {
+ ExtensionPageContent {
+ ExtensionDiscoveryFailureNotice(
+ onRetry = onRetryDiscovery,
+ enabled = !operationRunning,
+ )
+ }
+ }
+ }
+
+ if (plugin.hasVisibleWarning) {
+ item(key = "warnings") {
+ ExtensionPageContent {
+ ExtensionWarningSummary(
+ plugin = plugin,
+ unapprovedNetworkOrigins = unapprovedNetworkOrigins,
+ bidiFormatter = bidiFormatter,
+ )
+ }
+ }
+ }
+
+ if (actions.hasControlActions) {
+ item(key = "actions") {
+ ExtensionPageContent {
+ ExtensionPluginActions(
+ plugin = plugin,
+ reauthorizationIsPrimary = reauthorizationIsPrimary,
+ onOpenAuthorization = onOpenAuthorization,
+ onOpenSettings = onOpenSettings,
+ onDisable = onDisable,
+ enabled = !operationRunning,
+ )
+ }
+ }
+ }
+
+ if (
+ plugin.capabilityPermissions.isNotEmpty() ||
+ networkAccessCounts.total > 0
+ ) {
+ item(key = "access") {
+ ExtensionPageContent {
+ ExtensionSection(
+ title = stringResource(string.feat_setting_extension_access),
+ ) {
+ ExtensionAccessOverview(
+ plugin = plugin,
+ capabilitiesExpanded = capabilitiesExpanded,
+ onCapabilitiesExpandedChange = {
+ capabilitiesExpanded = it
+ },
+ networkOriginsExpanded = networkOriginsExpanded,
+ onNetworkOriginsExpandedChange = {
+ networkOriginsExpanded = it
+ },
+ bidiFormatter = bidiFormatter,
+ )
+ }
+ }
+ }
+ }
+
+ item(key = "support-and-details") {
+ ExtensionPageContent {
+ ExtensionSection(
+ title = stringResource(
+ string.feat_setting_extension_support_and_details
+ ),
+ ) {
+ Surface(
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ ) {
+ Column {
+ if (actions.exportDiagnostics && extensionId != null) {
+ ExtensionActionRow(
+ label = stringResource(
+ string.feat_setting_extension_export_diagnostics
+ ),
+ icon = Icons.Rounded.BugReport,
+ onClick = { onExportDiagnostics(extensionId) },
+ enabled = !operationRunning,
+ testTag = plugin.actionTestTag(
+ "export-diagnostics"
+ ),
+ showLeadingContainer = true,
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+ ExtensionDisclosureHeader(
+ title = stringResource(
+ string.feat_setting_extension_identity
+ ),
+ summary = stringResource(
+ string.feat_setting_extension_identity_summary
+ ),
+ icon = Icons.Rounded.Extension,
+ expanded = technicalIdentityExpanded,
+ onExpandedChange = {
+ technicalIdentityExpanded = it
+ },
+ testTag =
+ "extension-technical-identity-disclosure",
+ titleIsHeading = true,
+ )
+ AnimatedVisibility(
+ visible = technicalIdentityExpanded
+ ) {
+ ExtensionTechnicalIdentity(
+ plugin = plugin,
+ bidiFormatter = bidiFormatter,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (actions.clearData || actions.revoke) {
+ item(key = "data-actions") {
+ ExtensionPageContent {
+ ExtensionSection(
+ title = stringResource(
+ string.feat_setting_extension_data_and_trust
+ ),
+ ) {
+ Surface(
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ ) {
+ ExtensionPluginDataActions(
+ plugin = plugin,
+ onClearData = { pendingClear = true },
+ onRevoke = { pendingRevoke = true },
+ enabled = !operationRunning,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (pendingRevoke) {
+ ExtensionDataRemovalDialog(
+ plugin = plugin,
+ title = stringResource(string.feat_setting_extension_forget_title),
+ body = stringResource(string.feat_setting_extension_forget_body),
+ confirmLabel = stringResource(string.feat_setting_extension_revoke),
+ dialogTag = "extension-revoke-dialog",
+ confirmTag = "extension-revoke-confirm",
+ onDismiss = { pendingRevoke = false },
+ onConfirm = {
+ pendingRevoke = false
+ onRevoke(plugin.packageName, plugin.serviceName, plugin.extensionId)
+ },
+ )
+ }
+ if (pendingClear) {
+ ExtensionDataRemovalDialog(
+ plugin = plugin,
+ title = stringResource(string.feat_setting_extension_clear_data_title),
+ body = stringResource(string.feat_setting_extension_clear_data_body),
+ confirmLabel = stringResource(string.feat_setting_extension_clear_data),
+ dialogTag = "extension-clear-data-dialog",
+ confirmTag = "extension-clear-data-confirm",
+ onDismiss = { pendingClear = false },
+ onConfirm = {
+ pendingClear = false
+ onClearData(plugin.packageName, plugin.serviceName, plugin.extensionId)
+ },
+ )
+ }
+}
+
+@Composable
+private fun ExtensionPluginActions(
+ plugin: InstalledPlugin,
+ reauthorizationIsPrimary: Boolean,
+ onOpenAuthorization: (reauthorize: Boolean) -> Unit,
+ onOpenSettings: (extensionId: String) -> Unit,
+ onDisable: (extensionId: String) -> Unit,
+ enabled: Boolean,
+) {
+ val actions = plugin.actionAvailability()
+ val extensionId = plugin.extensionId
+ val settingsLabel = stringResource(string.feat_setting_extension_settings)
+ val reauthorizeLabel = stringResource(string.feat_setting_extension_reauthorize)
+ val disableLabel = stringResource(string.feat_setting_extension_disable)
+ val groupedActions = buildList {
+ if (actions.settings && extensionId != null) {
+ add(
+ ExtensionActionItem(
+ label = settingsLabel,
+ icon = Icons.Rounded.Settings,
+ onClick = { onOpenSettings(extensionId) },
+ enabled = enabled,
+ testTag = plugin.actionTestTag("settings"),
+ prominent = true,
+ )
+ )
+ }
+ if (actions.reauthorize && !reauthorizationIsPrimary) {
+ add(
+ ExtensionActionItem(
+ label = reauthorizeLabel,
+ icon = Icons.Rounded.Security,
+ onClick = { onOpenAuthorization(true) },
+ enabled = enabled,
+ testTag = plugin.actionTestTag("reauthorize"),
+ )
+ )
+ }
+ if (actions.disable && extensionId != null) {
+ add(
+ ExtensionActionItem(
+ label = disableLabel,
+ icon = Icons.Rounded.PowerSettingsNew,
+ onClick = { onDisable(extensionId) },
+ enabled = enabled,
+ testTag = plugin.actionTestTag("disable"),
+ )
+ )
+ }
+ }
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ when {
+ actions.enable -> {
+ Button(
+ onClick = { onOpenAuthorization(false) },
+ enabled = enabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp)
+ .testTag(plugin.actionTestTag("enable")),
+ ) {
+ Text(stringResource(string.feat_setting_extension_enable))
+ }
+ }
+
+ reauthorizationIsPrimary -> {
+ Button(
+ onClick = { onOpenAuthorization(true) },
+ enabled = enabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp)
+ .testTag(plugin.actionTestTag("reauthorize")),
+ ) {
+ Text(reauthorizeLabel)
+ }
+ }
+ }
+
+ if (groupedActions.isNotEmpty()) {
+ val fontScale = LocalDensity.current.fontScale
+ BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
+ ExtensionActionGroup(
+ actions = groupedActions,
+ stacked = shouldStackExtensionPluginActions(
+ availableWidthDp = maxWidth.value,
+ fontScale = fontScale,
+ actionCount = groupedActions.size,
+ ),
+ )
+ }
+ }
+ }
+}
+
+internal fun shouldStackExtensionPluginActions(
+ availableWidthDp: Float,
+ fontScale: Float,
+ actionCount: Int,
+): Boolean {
+ if (actionCount <= 1) return false
+ val minimumWidthPerAction =
+ MINIMUM_COMPACT_EXTENSION_ACTION_WIDTH_DP * fontScale.coerceAtLeast(1f)
+ return availableWidthDp / actionCount < minimumWidthPerAction
+}
+
+private data class ExtensionActionItem(
+ val label: String,
+ val icon: ImageVector,
+ val onClick: () -> Unit,
+ val enabled: Boolean,
+ val testTag: String,
+ val prominent: Boolean = false,
+)
+
+@Composable
+private fun ExtensionActionGroup(
+ actions: List,
+ stacked: Boolean,
+) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ ) {
+ if (stacked) {
+ Column {
+ actions.forEachIndexed { index, action ->
+ ExtensionActionRow(
+ label = action.label,
+ icon = action.icon,
+ onClick = action.onClick,
+ enabled = action.enabled,
+ testTag = action.testTag,
+ prominent = action.prominent,
+ )
+ if (index != actions.lastIndex) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+ }
+ }
+ } else {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ actions.forEachIndexed { index, action ->
+ ExtensionCompactAction(
+ action = action,
+ modifier = Modifier.weight(1f),
+ )
+ if (index != actions.lastIndex) {
+ VerticalDivider(
+ modifier = Modifier.height(64.dp),
+ color = MaterialTheme.colorScheme.outlineVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionCompactAction(
+ action: ExtensionActionItem,
+ modifier: Modifier = Modifier,
+) {
+ val contentColor = if (action.prominent) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ Column(
+ modifier = modifier
+ .heightIn(min = 104.dp)
+ .alpha(if (action.enabled) 1f else 0.38f)
+ .clickable(
+ enabled = action.enabled,
+ role = Role.Button,
+ onClick = action.onClick,
+ )
+ .semantics(mergeDescendants = true) {}
+ .testTag(action.testTag)
+ .padding(horizontal = 8.dp, vertical = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(
+ space = 8.dp,
+ alignment = Alignment.CenterVertically,
+ ),
+ ) {
+ Icon(
+ imageVector = action.icon,
+ contentDescription = null,
+ tint = contentColor,
+ modifier = Modifier.size(24.dp),
+ )
+ Text(
+ text = action.label,
+ style = MaterialTheme.typography.labelLarge,
+ color = contentColor,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+@Composable
+private fun ExtensionActionRow(
+ label: String,
+ icon: ImageVector,
+ onClick: () -> Unit,
+ enabled: Boolean,
+ testTag: String,
+ showsNavigation: Boolean = false,
+ destructive: Boolean = false,
+ prominent: Boolean = false,
+ showLeadingContainer: Boolean = false,
+) {
+ val contentColor = when {
+ !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ destructive -> MaterialTheme.colorScheme.error
+ prominent -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.onSurface
+ }
+ ListItem(
+ headlineContent = {
+ Text(
+ text = label,
+ color = contentColor,
+ )
+ },
+ leadingContent = {
+ if (showLeadingContainer) {
+ ExtensionLeadingIcon(
+ icon = icon,
+ contentColor = contentColor,
+ )
+ } else {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = contentColor,
+ )
+ }
+ },
+ trailingContent = if (showsNavigation) {
+ {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ contentDescription = null,
+ tint = contentColor,
+ )
+ }
+ } else {
+ null
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .clip(MaterialTheme.shapes.large)
+ .clickable(
+ enabled = enabled,
+ role = Role.Button,
+ onClick = onClick,
+ )
+ .semantics(mergeDescendants = true) {}
+ .testTag(testTag),
+ )
+}
+
+@Composable
+private fun ExtensionPluginDataActions(
+ plugin: InstalledPlugin,
+ onClearData: () -> Unit,
+ onRevoke: () -> Unit,
+ enabled: Boolean,
+) {
+ val actions = plugin.actionAvailability()
+ Column(modifier = Modifier.fillMaxWidth()) {
+ if (actions.clearData) {
+ ExtensionActionRow(
+ label = stringResource(string.feat_setting_extension_clear_data),
+ icon = Icons.Rounded.DeleteOutline,
+ onClick = onClearData,
+ enabled = enabled,
+ testTag = plugin.actionTestTag("clear-data"),
+ destructive = true,
+ )
+ }
+ if (actions.clearData && actions.revoke) {
+ HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
+ }
+ if (actions.revoke) {
+ ExtensionActionRow(
+ label = stringResource(string.feat_setting_extension_revoke),
+ icon = Icons.Rounded.Security,
+ onClick = onRevoke,
+ enabled = enabled,
+ testTag = plugin.actionTestTag("revoke"),
+ destructive = true,
+ )
+ }
+ }
+}
+
+@Composable
+internal fun ExtensionPluginAuthorizationScreen(
+ state: ExtensionPluginDetailContentState,
+ operationState: ExtensionPluginOperationState,
+ onRetryDiscovery: () -> Unit,
+ reauthorize: Boolean,
+ onAuthorize: (
+ packageName: String,
+ serviceName: String,
+ authorizationToken: PluginAuthorizationToken,
+ reauthorize: Boolean,
+ ) -> Unit,
+ onCancel: () -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ val contentState = state as? ExtensionPluginDetailContentState.Content
+ if (
+ contentState == null ||
+ contentState.discoveryStatus != ExtensionPluginDiscoveryStatus.READY
+ ) {
+ val lookupState = when {
+ contentState?.discoveryStatus ==
+ ExtensionPluginDiscoveryStatus.REFRESHING ->
+ ExtensionPluginDetailContentState.Loading
+ contentState?.discoveryStatus ==
+ ExtensionPluginDiscoveryStatus.REFRESH_FAILED ->
+ ExtensionPluginDetailContentState.Failure
+ else -> state
+ }
+ ExtensionPluginLookupScreen(
+ state = lookupState,
+ onRetry = onRetryDiscovery,
+ retryEnabled =
+ operationState !is ExtensionPluginOperationState.Running,
+ modifier = modifier,
+ contentPadding = contentPadding,
+ )
+ return
+ }
+
+ val plugin = contentState.plugin
+ val bidiFormatter = rememberUiBidiFormatter()
+ val confirmLabel = stringResource(
+ if (reauthorize) {
+ string.feat_setting_extension_reauthorize
+ } else {
+ string.feat_setting_extension_enable
+ }
+ )
+ val loadingDescription = stringResource(string.feat_setting_extension_loading)
+ val authorizationToken = plugin.authorizationToken
+ var submitted by rememberSaveable(plugin.stableKey, reauthorize) { mutableStateOf(false) }
+ val authorizationLoading = authorizationToken == null || submitted
+ var identityExpanded by rememberSaveable(plugin.stableKey, reauthorize) {
+ mutableStateOf(false)
+ }
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .testTag("extension-authorization"),
+ contentPadding = contentPadding + PaddingValues(horizontal = 16.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ item(key = "identity") {
+ ExtensionPageContent {
+ ExtensionAuthorizationIdentity(
+ plugin = plugin,
+ bidiFormatter = bidiFormatter,
+ )
+ }
+ }
+
+ plugin.previousCertificateSha256?.let { previousCertificate ->
+ item(key = "certificate-change") {
+ ExtensionPageContent {
+ ExtensionWarning(
+ stringResource(
+ string.feat_setting_extension_certificate_repin,
+ bidiFormatter.ltr(
+ previousCertificate.shortCertificateFingerprint()
+ ),
+ bidiFormatter.ltr(
+ plugin.certificateSha256.shortCertificateFingerprint()
+ ),
+ )
+ )
+ }
+ }
+ }
+
+ if (plugin.capabilityPermissions.isNotEmpty()) {
+ item(key = "capabilities") {
+ ExtensionPageContent {
+ ExtensionSection(
+ title = stringResource(
+ string.feat_setting_extension_requested_capabilities
+ ),
+ ) {
+ Surface(
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ ) {
+ ExtensionCapabilityList(
+ plugin = plugin,
+ bidiFormatter = bidiFormatter,
+ reviewingAuthorization = true,
+ showTopDivider = false,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (
+ plugin.networkAccess.fixedOrigins.isNotEmpty() ||
+ plugin.networkAccess.settingOrigins.isNotEmpty()
+ ) {
+ item(key = "origins") {
+ ExtensionPageContent {
+ ExtensionSection(
+ title = stringResource(string.feat_setting_extension_network_origins),
+ ) {
+ ExtensionAuthorizationNetworkOrigins(
+ plugin = plugin,
+ bidiFormatter = bidiFormatter,
+ )
+ }
+ }
+ }
+ }
+
+ item(key = "identity-details") {
+ ExtensionPageContent {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ ) {
+ Column {
+ ExtensionDisclosureHeader(
+ title = stringResource(string.feat_setting_extension_identity),
+ summary = stringResource(
+ string.feat_setting_extension_identity_summary
+ ),
+ icon = Icons.Rounded.Extension,
+ expanded = identityExpanded,
+ onExpandedChange = { identityExpanded = it },
+ testTag = "extension-authorization-identity-disclosure",
+ )
+ AnimatedVisibility(visible = identityExpanded) {
+ Column {
+ ExtensionTechnicalIdentity(
+ plugin = plugin,
+ bidiFormatter = bidiFormatter,
+ )
+ plugin.previousCertificateSha256?.let {
+ previousCertificate ->
+ ExtensionWarning(
+ message = stringResource(
+ string.feat_setting_extension_certificate_repin,
+ bidiFormatter.ltr(
+ previousCertificate
+ .chunked(16)
+ .joinToString(" ")
+ ),
+ bidiFormatter.ltr(
+ plugin.certificateSha256
+ .chunked(16)
+ .joinToString(" ")
+ ),
+ ),
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ item(key = "actions") {
+ ExtensionPageContent {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(
+ space = 8.dp,
+ alignment = Alignment.End,
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TextButton(
+ onClick = onCancel,
+ enabled = !submitted,
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag("extension-authorization-cancel"),
+ ) {
+ Text(stringResource(android.R.string.cancel))
+ }
+ Button(
+ onClick = {
+ val token = authorizationToken ?: return@Button
+ submitted = true
+ onAuthorize(
+ plugin.packageName,
+ plugin.serviceName,
+ token,
+ reauthorize,
+ )
+ },
+ enabled = authorizationToken != null && !submitted,
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag(
+ if (authorizationLoading) {
+ "extension-authorization-confirm-loading"
+ } else {
+ "extension-authorization-confirm"
+ }
+ )
+ .semantics {
+ if (authorizationLoading) {
+ contentDescription = confirmLabel
+ stateDescription = loadingDescription
+ liveRegion = LiveRegionMode.Polite
+ }
+ },
+ ) {
+ if (authorizationLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(18.dp)
+ .clearAndSetSemantics { },
+ strokeWidth = 2.dp,
+ )
+ } else {
+ Text(confirmLabel)
+ }
+ }
+ }
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .testTag("extension-authorization-bottom-safe-space"),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionApplicationIcon(
+ plugin: InstalledPlugin,
+ size: Dp,
+ fallbackIconSize: Dp,
+ warningBadgeSize: Dp,
+) {
+ val context = LocalContext.current
+ val applicationIcon = remember(
+ context,
+ plugin.packageName,
+ plugin.installed,
+ ) {
+ if (plugin.installed) {
+ runCatching {
+ context.packageManager.getApplicationIcon(plugin.packageName)
+ }.getOrNull()
+ } else {
+ null
+ }
+ }
+ val fallbackContainerColor = when (plugin.state) {
+ ExtensionState.ENABLED -> MaterialTheme.colorScheme.secondaryContainer
+ ExtensionState.DISABLED -> MaterialTheme.colorScheme.surfaceVariant
+ ExtensionState.INCOMPATIBLE,
+ ExtensionState.UNHEALTHY -> MaterialTheme.colorScheme.errorContainer
+ }
+ val fallbackContentColor = when (plugin.state) {
+ ExtensionState.ENABLED -> MaterialTheme.colorScheme.onSecondaryContainer
+ ExtensionState.DISABLED -> MaterialTheme.colorScheme.onSurfaceVariant
+ ExtensionState.INCOMPATIBLE,
+ ExtensionState.UNHEALTHY -> MaterialTheme.colorScheme.onErrorContainer
+ }
+
+ Box(modifier = Modifier.size(size)) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = fallbackContainerColor,
+ contentColor = fallbackContentColor,
+ ) {
+ if (applicationIcon == null) {
+ ExtensionFallbackIcon(iconSize = fallbackIconSize)
+ } else {
+ SubcomposeAsyncImage(
+ model = applicationIcon,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Fit,
+ loading = {
+ ExtensionFallbackIcon(iconSize = fallbackIconSize)
+ },
+ error = {
+ ExtensionFallbackIcon(iconSize = fallbackIconSize)
+ },
+ )
+ }
+ }
+ if (plugin.hasVisibleWarning) {
+ Surface(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .size(warningBadgeSize),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ shadowElevation = 2.dp,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = Icons.Rounded.Warning,
+ contentDescription = null,
+ modifier = Modifier.size(warningBadgeSize * 0.6f),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionFallbackIcon(
+ iconSize: Dp,
+) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Extension,
+ contentDescription = null,
+ modifier = Modifier.size(iconSize),
+ )
+ }
+}
+
+@Composable
+private fun ExtensionIdentityHeader(
+ plugin: InstalledPlugin,
+ bidiFormatter: UiBidiFormatter,
+) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ ExtensionApplicationIcon(
+ plugin = plugin,
+ size = 72.dp,
+ fallbackIconSize = 36.dp,
+ warningBadgeSize = 24.dp,
+ )
+ Text(
+ text = plugin.displayName
+ ?.takeIf(String::isNotBlank)
+ ?.let(bidiFormatter::natural)
+ ?: bidiFormatter.ltr(plugin.packageName),
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.semantics { heading() },
+ )
+ plugin.developer?.takeIf(String::isNotBlank)?.let { developer ->
+ Text(
+ text = bidiFormatter.natural(developer),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(
+ space = 8.dp,
+ alignment = Alignment.CenterHorizontally,
+ ),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ ExtensionStatePill(plugin)
+ plugin.version?.takeIf(String::isNotBlank)?.let { version ->
+ ExtensionMetadataPill(
+ text = bidiFormatter.ltr("v$version"),
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionStatePill(plugin: InstalledPlugin) {
+ val containerColor = when (plugin.state) {
+ ExtensionState.ENABLED -> MaterialTheme.colorScheme.secondaryContainer
+ ExtensionState.DISABLED -> MaterialTheme.colorScheme.surfaceVariant
+ ExtensionState.INCOMPATIBLE,
+ ExtensionState.UNHEALTHY -> MaterialTheme.colorScheme.errorContainer
+ }
+ val contentColor = when (plugin.state) {
+ ExtensionState.ENABLED -> MaterialTheme.colorScheme.onSecondaryContainer
+ ExtensionState.DISABLED -> MaterialTheme.colorScheme.onSurfaceVariant
+ ExtensionState.INCOMPATIBLE,
+ ExtensionState.UNHEALTHY -> MaterialTheme.colorScheme.onErrorContainer
+ }
+ ExtensionMetadataPill(
+ text = extensionStateLabel(plugin.state),
+ containerColor = containerColor,
+ contentColor = contentColor,
+ maxLines = Int.MAX_VALUE,
+ )
+}
+
+@Composable
+private fun ExtensionMetadataPill(
+ text: String,
+ containerColor: Color,
+ contentColor: Color,
+ maxLines: Int = 2,
+) {
+ Surface(
+ shape = CircleShape,
+ color = containerColor,
+ contentColor = contentColor,
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = maxLines,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .padding(horizontal = 10.dp, vertical = 5.dp),
+ )
+ }
+}
+
+@Composable
+private fun ExtensionAuthorizationIdentity(
+ plugin: InstalledPlugin,
+ bidiFormatter: UiBidiFormatter,
+) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ ExtensionApplicationIcon(
+ plugin = plugin,
+ size = 56.dp,
+ fallbackIconSize = 28.dp,
+ warningBadgeSize = 20.dp,
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ text = plugin.displayName
+ ?.takeIf(String::isNotBlank)
+ ?.let(bidiFormatter::natural)
+ ?: bidiFormatter.ltr(plugin.packageName),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.semantics { heading() },
+ )
+ plugin.developer?.takeIf(String::isNotBlank)?.let { developer ->
+ Text(
+ text = bidiFormatter.natural(developer),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ ExtensionStatePill(plugin)
+ plugin.version?.takeIf(String::isNotBlank)?.let { version ->
+ ExtensionMetadataPill(
+ text = bidiFormatter.ltr("v$version"),
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionAccessOverview(
+ plugin: InstalledPlugin,
+ capabilitiesExpanded: Boolean,
+ onCapabilitiesExpandedChange: (Boolean) -> Unit,
+ networkOriginsExpanded: Boolean,
+ onNetworkOriginsExpandedChange: (Boolean) -> Unit,
+ bidiFormatter: UiBidiFormatter,
+) {
+ val capabilityCount = plugin.capabilityPermissions.size
+ val grantedCapabilityCount = plugin.capabilityPermissions.count { it.granted }
+ val originCounts = plugin.networkAccessCounts()
+ val locale = LocalConfiguration.current.locales[0]
+ val numberFormat = remember(locale) {
+ NumberFormat.getIntegerInstance(locale)
+ }
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ ) {
+ Column {
+ if (capabilityCount > 0) {
+ ExtensionDisclosureHeader(
+ title = stringResource(string.feat_setting_extension_capabilities),
+ summary = bidiFormatter.natural(
+ stringResource(
+ string.feat_setting_extension_capability_summary,
+ numberFormat.format(grantedCapabilityCount),
+ numberFormat.format(capabilityCount),
+ )
+ ),
+ icon = Icons.Rounded.Security,
+ expanded = capabilitiesExpanded,
+ onExpandedChange = onCapabilitiesExpandedChange,
+ testTag = "extension-capabilities-disclosure",
+ )
+ AnimatedVisibility(visible = capabilitiesExpanded) {
+ ExtensionCapabilityList(
+ plugin = plugin,
+ bidiFormatter = bidiFormatter,
+ )
+ }
+ }
+ if (capabilityCount > 0 && originCounts.total > 0) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ if (originCounts.total > 0) {
+ ExtensionDisclosureHeader(
+ title = stringResource(string.feat_setting_extension_network_access),
+ summary = bidiFormatter.natural(
+ stringResource(
+ string.feat_setting_extension_network_origin_summary,
+ numberFormat.format(originCounts.approved),
+ numberFormat.format(originCounts.total),
+ )
+ ),
+ icon = Icons.Rounded.Public,
+ expanded = networkOriginsExpanded,
+ onExpandedChange = onNetworkOriginsExpandedChange,
+ testTag = "extension-network-origins-disclosure",
+ )
+ AnimatedVisibility(visible = networkOriginsExpanded) {
+ ExtensionNetworkOriginList(
+ plugin = plugin,
+ bidiFormatter = bidiFormatter,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionDisclosureHeader(
+ title: String,
+ summary: String,
+ icon: ImageVector,
+ expanded: Boolean,
+ onExpandedChange: (Boolean) -> Unit,
+ testTag: String,
+ titleIsHeading: Boolean = false,
+) {
+ val expansionState = stringResource(
+ if (expanded) {
+ string.ui_state_expanded
+ } else {
+ string.ui_state_collapsed
+ }
+ )
+ ListItem(
+ headlineContent = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = if (titleIsHeading) {
+ Modifier.semantics { heading() }
+ } else {
+ Modifier
+ },
+ )
+ },
+ supportingContent = {
+ Text(
+ text = summary,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ leadingContent = {
+ ExtensionLeadingIcon(icon = icon)
+ },
+ trailingContent = {
+ Box(
+ modifier = Modifier.size(48.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = if (expanded) {
+ Icons.Rounded.ExpandLess
+ } else {
+ Icons.Rounded.ExpandMore
+ },
+ contentDescription = null,
+ )
+ }
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 72.dp)
+ .clip(MaterialTheme.shapes.extraLarge)
+ .clickable(
+ role = Role.Button,
+ onClick = { onExpandedChange(!expanded) },
+ )
+ .semantics(mergeDescendants = true) {
+ stateDescription = expansionState
+ }
+ .testTag(testTag),
+ )
+}
+
+@Composable
+private fun ExtensionLeadingIcon(
+ icon: ImageVector,
+ contentColor: Color = MaterialTheme.colorScheme.onSurface,
+) {
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ contentColor = contentColor,
+ ) {
+ Box(
+ modifier = Modifier.size(48.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ExtensionTechnicalIdentity(
+ plugin: InstalledPlugin,
+ bidiFormatter: UiBidiFormatter,
+) {
+ val values = buildList {
+ plugin.version?.takeIf(String::isNotBlank)?.let { version ->
+ add(
+ Triple(
+ "version",
+ stringResource(string.feat_setting_extension_version),
+ bidiFormatter.standaloneTechnical(version),
+ )
+ )
+ }
+ add(
+ Triple(
+ "package",
+ stringResource(string.feat_setting_extension_package),
+ bidiFormatter.standaloneTechnical(plugin.packageName),
+ )
+ )
+ add(
+ Triple(
+ "service",
+ stringResource(string.feat_setting_extension_service),
+ bidiFormatter.standaloneTechnical(plugin.serviceName),
+ )
+ )
+ add(
+ Triple(
+ "certificate",
+ stringResource(string.feat_setting_extension_certificate_sha256),
+ bidiFormatter.standaloneTechnical(
+ plugin.certificateSha256.chunked(16).joinToString(" ")
+ ),
+ )
+ )
+ }
+ Column(modifier = Modifier.fillMaxWidth()) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ values.forEachIndexed { index, (key, label, value) ->
+ ListItem(
+ headlineContent = {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ supportingContent = {
+ SelectionContainer {
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.Ltr
+ ),
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.testTag(
+ "extension-technical-identity-value:$key"
+ ),
+ )
+ }
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent
+ ),
+ modifier = Modifier.semantics(mergeDescendants = true) {},
+ )
+ if (index != values.lastIndex) {
+ HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionCapabilityList(
+ plugin: InstalledPlugin,
+ bidiFormatter: UiBidiFormatter,
+ reviewingAuthorization: Boolean = false,
+ showTopDivider: Boolean = true,
+) {
+ if (plugin.capabilityPermissions.isEmpty()) {
+ Text("—", style = MaterialTheme.typography.bodyMedium)
+ return
+ }
+ Column(modifier = Modifier.fillMaxWidth()) {
+ if (showTopDivider) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ plugin.capabilityPermissions.forEachIndexed { index, permission ->
+ val nameResource = extensionCapabilityNameResource(permission.id)
+ val capabilityName = nameResource
+ ?.let { resource -> stringResource(resource) }
+ ?: bidiFormatter.ltr(permission.id)
+ val reason = permission.reason
+ .takeIf(String::isNotBlank)
+ ?.let(bidiFormatter::natural)
+ val requirement = stringResource(
+ if (permission.required) {
+ string.feat_setting_extension_capability_required
+ } else {
+ string.feat_setting_extension_capability_optional
+ }
+ )
+ val grantState = if (reviewingAuthorization) {
+ null
+ } else {
+ stringResource(
+ if (permission.granted) {
+ string.feat_setting_extension_capability_granted
+ } else {
+ string.feat_setting_extension_capability_not_granted
+ }
+ )
+ }
+ val permissionNeedsAttention = permission.required && !permission.granted
+ ListItem(
+ headlineContent = {
+ Text(
+ text = capabilityName,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ },
+ supportingContent = {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ reason?.let { explanation ->
+ Text(
+ text = explanation,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Text(
+ text = requirement,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ trailingContent = {
+ Icon(
+ imageVector = if (!reviewingAuthorization && permission.granted) {
+ Icons.Rounded.CheckCircle
+ } else {
+ Icons.Rounded.Security
+ },
+ contentDescription = null,
+ tint = if (reviewingAuthorization) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else if (permissionNeedsAttention) {
+ MaterialTheme.colorScheme.error
+ } else if (permission.granted) {
+ MaterialTheme.colorScheme.secondary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent
+ ),
+ modifier = Modifier.semantics(mergeDescendants = true) {
+ grantState?.let { state ->
+ stateDescription = state
+ }
+ },
+ )
+ if (index != plugin.capabilityPermissions.lastIndex) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionAuthorizationNetworkOrigins(
+ plugin: InstalledPlugin,
+ bidiFormatter: UiBidiFormatter,
+) {
+ val fixedOrigins = plugin.networkAccess.fixedOrigins.sortedBy { origin -> origin.origin }
+ val settingOrigins = plugin.networkAccess.settingOrigins.sortedWith(
+ compareBy(
+ { origin -> origin.label.orEmpty() },
+ ExtensionSettingNetworkOrigin::qualifiedKey,
+ )
+ )
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ ) {
+ Column {
+ if (fixedOrigins.isNotEmpty()) {
+ ExtensionNetworkOriginGroupLabel(
+ text = stringResource(
+ string.feat_setting_extension_network_origins_declared
+ )
+ )
+ }
+ fixedOrigins.forEachIndexed { index, origin ->
+ ListItem(
+ headlineContent = {
+ ExtensionTechnicalValue(
+ value = origin.origin,
+ bidiFormatter = bidiFormatter,
+ )
+ },
+ leadingContent = {
+ ExtensionLeadingIcon(icon = Icons.Rounded.Public)
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent
+ ),
+ )
+ if (index != fixedOrigins.lastIndex) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ }
+ if (fixedOrigins.isNotEmpty() && settingOrigins.isNotEmpty()) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ if (settingOrigins.isNotEmpty()) {
+ ExtensionNetworkOriginGroupLabel(
+ text = stringResource(
+ string.feat_setting_extension_network_origins_from_settings
+ )
+ )
+ Text(
+ text = stringResource(
+ string.feat_setting_extension_network_origin_settings_explanation
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ }
+ settingOrigins.forEachIndexed { index, origin ->
+ ExtensionAuthorizationSettingOriginRow(
+ origin = origin,
+ bidiFormatter = bidiFormatter,
+ )
+ if (index != settingOrigins.lastIndex) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionNetworkOriginList(
+ plugin: InstalledPlugin,
+ bidiFormatter: UiBidiFormatter,
+) {
+ val fixedOrigins = plugin.networkAccess.fixedOrigins.sortedBy { origin -> origin.origin }
+ val settingOrigins = plugin.visibleSettingNetworkOrigins().sortedWith(
+ compareBy(
+ { origin -> origin.label.orEmpty() },
+ ExtensionSettingNetworkOrigin::qualifiedKey,
+ )
+ )
+ Column(modifier = Modifier.fillMaxWidth()) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ if (fixedOrigins.isNotEmpty()) {
+ ExtensionNetworkOriginGroupLabel(
+ text = stringResource(
+ string.feat_setting_extension_network_origins_declared
+ )
+ )
+ }
+ fixedOrigins.forEachIndexed { index, origin ->
+ ExtensionFixedNetworkOriginRow(
+ origin = origin,
+ bidiFormatter = bidiFormatter,
+ )
+ if (index != fixedOrigins.lastIndex) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ }
+ if (fixedOrigins.isNotEmpty() && settingOrigins.isNotEmpty()) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ if (settingOrigins.isNotEmpty()) {
+ ExtensionNetworkOriginGroupLabel(
+ text = stringResource(
+ string.feat_setting_extension_network_origins_from_settings
+ )
+ )
+ }
+ settingOrigins.forEachIndexed { index, origin ->
+ ExtensionSettingNetworkOriginRow(
+ origin = origin,
+ bidiFormatter = bidiFormatter,
+ )
+ if (index != settingOrigins.lastIndex) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionNetworkOriginGroupLabel(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp)
+ .semantics { heading() },
+ )
+}
+
+@Composable
+private fun ExtensionAuthorizationSettingOriginRow(
+ origin: ExtensionSettingNetworkOrigin,
+ bidiFormatter: UiBidiFormatter,
+) {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = bidiFormatter.natural(
+ origin.label?.takeIf(String::isNotBlank)
+ ?: origin.qualifiedKey
+ ),
+ )
+ },
+ supportingContent = origin.currentOrigin?.let { currentOrigin ->
+ {
+ ExtensionTechnicalValue(
+ value = currentOrigin,
+ bidiFormatter = bidiFormatter,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ },
+ leadingContent = {
+ ExtensionLeadingIcon(icon = Icons.Rounded.Settings)
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ )
+}
+
+@Composable
+private fun ExtensionFixedNetworkOriginRow(
+ origin: PluginFixedNetworkOrigin,
+ bidiFormatter: UiBidiFormatter,
+) {
+ val stateLabel = extensionNetworkOriginStateLabel(origin.state)
+ ExtensionNetworkOriginRow(
+ headline = {
+ ExtensionTechnicalValue(
+ value = origin.origin,
+ bidiFormatter = bidiFormatter,
+ )
+ },
+ state = origin.state,
+ stateLabel = stateLabel,
+ testTag = "extension-network-fixed-origin:${origin.origin}",
+ )
+}
+
+@Composable
+private fun ExtensionSettingNetworkOriginRow(
+ origin: ExtensionSettingNetworkOrigin,
+ bidiFormatter: UiBidiFormatter,
+) {
+ val stateLabel = extensionNetworkOriginStateLabel(origin.state)
+ ExtensionNetworkOriginRow(
+ headline = {
+ Text(
+ text = bidiFormatter.natural(
+ origin.label?.takeIf(String::isNotBlank)
+ ?: origin.qualifiedKey
+ ),
+ )
+ },
+ origin = origin.currentOrigin,
+ bidiFormatter = bidiFormatter,
+ state = origin.state,
+ stateLabel = stateLabel,
+ testTag = "extension-network-setting-origin:${origin.qualifiedKey}",
+ )
+}
+
+@Composable
+private fun ExtensionNetworkOriginRow(
+ headline: @Composable () -> Unit,
+ state: ExtensionNetworkOriginState,
+ stateLabel: String,
+ origin: String? = null,
+ bidiFormatter: UiBidiFormatter? = null,
+ testTag: String,
+) {
+ val needsAttention = state.requiresUserAttention
+ ListItem(
+ headlineContent = headline,
+ supportingContent = {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ if (origin != null && bidiFormatter != null) {
+ ExtensionTechnicalValue(
+ value = origin,
+ bidiFormatter = bidiFormatter,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Text(
+ text = stateLabel,
+ color = if (needsAttention) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier.clearAndSetSemantics {},
+ )
+ }
+ },
+ trailingContent = {
+ Icon(
+ imageVector = when {
+ state == ExtensionNetworkOriginState.APPROVED ->
+ Icons.Rounded.CheckCircle
+ needsAttention -> Icons.Rounded.Warning
+ else -> Icons.Rounded.Public
+ },
+ contentDescription = null,
+ tint = when {
+ state == ExtensionNetworkOriginState.APPROVED ->
+ MaterialTheme.colorScheme.secondary
+ needsAttention -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {
+ stateDescription = stateLabel
+ }
+ .testTag(testTag),
+ )
+}
+
+@Composable
+private fun ExtensionTechnicalValue(
+ value: String,
+ bidiFormatter: UiBidiFormatter,
+ style: TextStyle = MaterialTheme.typography.bodyMedium,
+) {
+ SelectionContainer {
+ Text(
+ text = bidiFormatter.standaloneTechnical(value),
+ style = style.copy(textDirection = TextDirection.Ltr),
+ fontFamily = FontFamily.Monospace,
+ )
+ }
+}
+
+@Composable
+internal fun extensionNetworkOriginStateLabel(
+ state: ExtensionNetworkOriginState,
+): String = stringResource(
+ when (state) {
+ ExtensionNetworkOriginState.APPROVED ->
+ string.feat_setting_extension_network_origin_state_approved
+ ExtensionNetworkOriginState.REQUIRES_APPROVAL ->
+ string.feat_setting_extension_network_origin_state_approval_required
+ ExtensionNetworkOriginState.NOT_CONFIGURED ->
+ string.feat_setting_extension_network_origin_state_not_configured
+ ExtensionNetworkOriginState.INVALID ->
+ string.feat_setting_extension_network_origin_state_invalid
+ ExtensionNetworkOriginState.SUSPENDED ->
+ string.feat_setting_extension_network_origin_state_suspended
+ ExtensionNetworkOriginState.UNVERIFIED ->
+ string.feat_setting_extension_network_origin_state_unverified
+ }
+)
+
+@Composable
+private fun ExtensionWarningSummary(
+ plugin: InstalledPlugin,
+ unapprovedNetworkOrigins: Set,
+ bidiFormatter: UiBidiFormatter,
+) {
+ ExtensionWarningMessages(
+ extensionWarningMessages(
+ plugin = plugin,
+ unapprovedNetworkOrigins = unapprovedNetworkOrigins,
+ bidiFormatter = bidiFormatter,
+ )
+ )
+}
+
+@Composable
+private fun extensionWarningMessages(
+ plugin: InstalledPlugin,
+ unapprovedNetworkOrigins: Set,
+ bidiFormatter: UiBidiFormatter,
+): List = buildList {
+ if (plugin.trusted && unapprovedNetworkOrigins.isNotEmpty()) {
+ add(
+ stringResource(
+ string.feat_setting_extension_network_reauthorization_required,
+ unapprovedNetworkOrigins
+ .sorted()
+ .joinToString(transform = bidiFormatter::ltr),
+ )
+ )
+ }
+ if (plugin.hasSettingNetworkOriginWarning) {
+ add(
+ stringResource(
+ string.feat_setting_extension_network_origin_settings_attention
+ )
+ )
+ }
+ if (plugin.inspectionError != null) {
+ add(stringResource(string.feat_setting_extension_inspection_failed))
+ }
+ if (!plugin.installed) {
+ add(stringResource(string.feat_setting_extension_not_installed))
+ }
+ if (plugin.signatureChanged) {
+ add(stringResource(string.feat_setting_extension_signature_changed))
+ }
+ if (
+ plugin.state == ExtensionState.INCOMPATIBLE ||
+ plugin.state == ExtensionState.UNHEALTHY
+ ) {
+ add(extensionStateLabel(plugin.state))
+ }
+}
+
+@Composable
+private fun ExtensionWarningMessages(
+ messages: List,
+ modifier: Modifier = Modifier,
+) {
+ val description = messages.joinToString(separator = "\n")
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ shape = MaterialTheme.shapes.medium,
+ modifier = modifier
+ .fillMaxWidth()
+ .clearAndSetSemantics {
+ error(description)
+ liveRegion = LiveRegionMode.Polite
+ },
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Warning,
+ contentDescription = null,
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ messages.forEach { message ->
+ Text(text = message)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionWarning(
+ message: String,
+ modifier: Modifier = Modifier,
+) {
+ ExtensionWarningMessages(
+ messages = listOf(message),
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun ExtensionSection(
+ title: String,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.semantics { heading() },
+ )
+ content()
+ }
+}
+
+@Composable
+private fun ExtensionDataRemovalDialog(
+ plugin: InstalledPlugin,
+ title: String,
+ body: String,
+ confirmLabel: String,
+ dialogTag: String,
+ confirmTag: String,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.testTag(dialogTag),
+ title = { Text(title) },
+ text = {
+ ExtensionDataRemovalBody(
+ plugin = plugin,
+ body = body,
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag(confirmTag),
+ ) {
+ Text(
+ text = confirmLabel,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss,
+ modifier = Modifier.heightIn(min = 48.dp),
+ ) {
+ Text(stringResource(android.R.string.cancel))
+ }
+ },
+ )
+}
+
+@Composable
+private fun ExtensionDataRemovalBody(
+ plugin: InstalledPlugin,
+ body: String,
+) {
+ val bidiFormatter = rememberUiBidiFormatter()
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .testTag("extension-data-removal-target"),
+ ) {
+ plugin.displayName?.takeIf(String::isNotBlank)?.let { displayName ->
+ Text(
+ text = bidiFormatter.natural(displayName),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ Text(
+ text = bidiFormatter.ltr(plugin.packageName),
+ style = if (plugin.displayName.isNullOrBlank()) {
+ MaterialTheme.typography.titleMedium
+ } else {
+ MaterialTheme.typography.bodySmall
+ },
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.testTag("extension-data-removal-package"),
+ )
+ Text(body)
+ }
+}
+
+@Composable
+private fun ExtensionPluginLookupScreen(
+ state: ExtensionPluginDetailContentState,
+ onRetry: () -> Unit,
+ retryEnabled: Boolean,
+ modifier: Modifier,
+ contentPadding: PaddingValues,
+) {
+ when (state) {
+ ExtensionPluginDetailContentState.Loading -> {
+ val loadingDescription = stringResource(
+ string.feat_setting_extension_loading
+ )
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .testTag("extension-plugin-detail-loading"),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.semantics {
+ contentDescription = loadingDescription
+ },
+ )
+ }
+ }
+
+ ExtensionPluginDetailContentState.Failure -> {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .padding(16.dp)
+ .testTag("extension-plugin-failure"),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ ExtensionPageContent {
+ ExtensionDiscoveryFailureNotice(
+ onRetry = onRetry,
+ enabled = retryEnabled,
+ prominent = true,
+ )
+ }
+ }
+ }
+
+ ExtensionPluginDetailContentState.Missing -> {
+ ExtensionUnavailableScreen(
+ modifier = modifier,
+ contentPadding = contentPadding,
+ )
+ }
+
+ is ExtensionPluginDetailContentState.Content ->
+ error("Content state must be rendered by ExtensionPluginDetailScreen")
+ }
+}
+
+@Composable
+private fun ExtensionDiscoveryFailureNotice(
+ onRetry: () -> Unit,
+ enabled: Boolean,
+ prominent: Boolean = false,
+) {
+ val retryLabel = stringResource(string.ui_action_retry)
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("extension-plugin-discovery-failure"),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ ExtensionWarning(
+ message = stringResource(
+ string.feat_setting_extension_operation_failed
+ ),
+ )
+ if (prominent) {
+ Button(
+ onClick = onRetry,
+ enabled = enabled,
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag("extension-plugin-retry"),
+ ) {
+ Text(retryLabel)
+ }
+ } else {
+ TextButton(
+ onClick = onRetry,
+ enabled = enabled,
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag("extension-plugin-retry"),
+ ) {
+ Text(retryLabel)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionOperationStatus(description: String) {
+ ExtensionPageContent {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
+ .testTag("extension-operation-progress")
+ .semantics(mergeDescendants = true) {
+ liveRegion = LiveRegionMode.Polite
+ },
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clearAndSetSemantics { },
+ )
+ Text(
+ text = description,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun extensionOperationDescription(
+ operation: ExtensionPluginOperation,
+): String = stringResource(
+ when (operation) {
+ ExtensionPluginOperation.Refresh ->
+ string.feat_setting_extension_operation_refreshing
+ is ExtensionPluginOperation.Enable ->
+ string.feat_setting_extension_operation_enabling
+ is ExtensionPluginOperation.Reauthorize ->
+ string.feat_setting_extension_operation_reauthorizing
+ is ExtensionPluginOperation.Disable ->
+ string.feat_setting_extension_operation_disabling
+ is ExtensionPluginOperation.ClearData ->
+ string.feat_setting_extension_operation_clearing_data
+ is ExtensionPluginOperation.Revoke ->
+ string.feat_setting_extension_operation_revoking_trust
+ }
+)
+
+private fun ExtensionPluginOperation.targets(plugin: InstalledPlugin): Boolean =
+ when (this) {
+ ExtensionPluginOperation.Refresh -> true
+ is ExtensionPluginOperation.Enable ->
+ packageName == plugin.packageName &&
+ serviceName == plugin.serviceName
+ is ExtensionPluginOperation.Reauthorize ->
+ packageName == plugin.packageName &&
+ serviceName == plugin.serviceName
+ is ExtensionPluginOperation.Disable ->
+ extensionId == plugin.extensionId
+ is ExtensionPluginOperation.ClearData ->
+ packageName == plugin.packageName &&
+ serviceName == plugin.serviceName
+ is ExtensionPluginOperation.Revoke ->
+ packageName == plugin.packageName &&
+ serviceName == plugin.serviceName
+ }
+
+@Composable
+private fun ExtensionUnavailableScreen(
+ modifier: Modifier,
+ contentPadding: PaddingValues,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .padding(16.dp)
+ .testTag("extension-plugin-unavailable"),
+ contentAlignment = Alignment.TopStart,
+ ) {
+ Text(
+ text = stringResource(string.feat_setting_extension_not_installed),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+}
+
+@Composable
+private fun ExtensionEmptyState(text: String) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Extension,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun ExtensionPageContent(
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = ExtensionPageMaxWidth)
+ .fillMaxWidth(),
+ content = content,
+ )
+ }
+}
+
+private val InstalledPlugin.stableKey: String
+ get() = "$packageName/$serviceName"
+
+private val InstalledPlugin.hasVisibleWarning: Boolean
+ get() = signatureChanged ||
+ !installed ||
+ inspectionError != null ||
+ state == ExtensionState.INCOMPATIBLE ||
+ state == ExtensionState.UNHEALTHY ||
+ (
+ trusted &&
+ networkAccess.fixedOrigins.any { origin ->
+ origin.state == ExtensionNetworkOriginState.REQUIRES_APPROVAL
+ }
+ ) ||
+ hasSettingNetworkOriginWarning
+
+private val ExtensionPluginActionAvailability.hasControlActions: Boolean
+ get() = enable || settings || reauthorize || disable
+
+private fun InstalledPlugin.actionTestTag(action: String): String =
+ "extension-plugin-action-$action:$stableKey"
+
+private fun String.shortCertificateFingerprint(): String {
+ val prefix = take(16).chunked(8).joinToString(" ")
+ return if (length > 16) "$prefix…" else prefix
+}
+
+@Composable
+private fun extensionStateLabel(state: ExtensionState): String = stringResource(
+ when (state) {
+ ExtensionState.ENABLED -> string.feat_setting_extension_state_enabled
+ ExtensionState.DISABLED -> string.feat_setting_extension_state_disabled
+ ExtensionState.INCOMPATIBLE -> string.feat_setting_extension_state_incompatible
+ ExtensionState.UNHEALTHY -> string.feat_setting_extension_state_unhealthy
+ }
+)
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionSettingsScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionSettingsScreen.kt
new file mode 100644
index 000000000..39c1fa050
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionSettingsScreen.kt
@@ -0,0 +1,706 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.error
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.m3u.business.setting.ExtensionSettingInputError
+import com.m3u.business.setting.ExtensionSettingsState
+import com.m3u.business.setting.extensionSettingInputError
+import com.m3u.business.setting.normalizedExtensionSettingValue
+import com.m3u.data.repository.extension.ExtensionNetworkOriginState
+import com.m3u.data.repository.extension.ExtensionSettingEditToken
+import com.m3u.extension.api.ExtensionSettingField
+import com.m3u.extension.api.ExtensionSettingKeys
+import com.m3u.extension.api.ExtensionSettingType
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.material.ktx.plus
+import com.m3u.smartphone.ui.material.ktx.UiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.withoutBidiControls
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.contentOrNull
+
+@Composable
+internal fun ExtensionSettingsScreen(
+ state: ExtensionSettingsState,
+ extensionId: String,
+ onRetry: () -> Unit,
+ onUpdate: (
+ sectionId: String,
+ fieldKey: String,
+ editToken: ExtensionSettingEditToken,
+ rawValue: String?,
+ ) -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ val stateMatchesDestination = state.extensionId?.value == extensionId
+ val configuration = when {
+ state is ExtensionSettingsState.Content && stateMatchesDestination ->
+ state.configuration
+ state is ExtensionSettingsState.Unavailable && stateMatchesDestination -> {
+ ExtensionSettingsStatus(
+ text = stringResource(string.feat_setting_extension_settings_unavailable),
+ tag = "extension-settings-unavailable",
+ modifier = modifier,
+ contentPadding = contentPadding,
+ )
+ return
+ }
+ state is ExtensionSettingsState.Error && stateMatchesDestination -> {
+ ExtensionSettingsStatus(
+ text = stringResource(string.feat_setting_extension_operation_failed),
+ tag = "extension-settings-error",
+ modifier = modifier,
+ contentPadding = contentPadding,
+ onRetry = onRetry,
+ )
+ return
+ }
+ else -> {
+ ExtensionSettingsLoading(
+ modifier = modifier,
+ contentPadding = contentPadding,
+ )
+ return
+ }
+ }
+
+ val bidiFormatter = rememberUiBidiFormatter()
+ val draftValues = remember(configuration.extensionId) {
+ mutableStateMapOf().apply {
+ configuration.sections.forEach { section ->
+ section.schema.fields.forEach { field ->
+ val key = ExtensionSettingKeys.qualified(section.id, field.key)
+ if (field.type != ExtensionSettingType.SECRET) {
+ put(key, configuration.snapshot.values[key].primitiveContent())
+ }
+ }
+ }
+ }
+ }
+ val validationRequested = remember(configuration.extensionId) {
+ mutableStateMapOf()
+ }
+ val dirtyKeys = remember(configuration.extensionId) {
+ mutableStateMapOf()
+ }
+ LaunchedEffect(configuration) {
+ val activeKeys = mutableSetOf()
+ configuration.sections.forEach { section ->
+ section.schema.fields.forEach { field ->
+ val key = ExtensionSettingKeys.qualified(section.id, field.key)
+ activeKeys += key
+ if (dirtyKeys[key] != true) {
+ draftValues[key] = if (field.type == ExtensionSettingType.SECRET) {
+ ""
+ } else {
+ configuration.snapshot.values[key].primitiveContent()
+ }
+ }
+ }
+ }
+ draftValues.keys.retainAll(activeKeys)
+ validationRequested.keys.retainAll(activeKeys)
+ dirtyKeys.keys.retainAll(activeKeys)
+ }
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .imePadding()
+ .testTag("extension-settings"),
+ contentPadding = contentPadding + PaddingValues(horizontal = 16.dp, vertical = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ if (configuration.sections.isEmpty()) {
+ item(key = "empty") {
+ Text(
+ text = stringResource(string.feat_setting_extension_settings_empty),
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier
+ .widthIn(max = 640.dp)
+ .fillMaxWidth()
+ .testTag("extension-settings-empty"),
+ )
+ }
+ }
+ configuration.sections.forEach { section ->
+ item(key = "section:${section.id}") {
+ Text(
+ text = bidiFormatter.natural(section.title),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier
+ .widthIn(max = 640.dp)
+ .fillMaxWidth()
+ .semantics { heading() },
+ )
+ }
+ items(
+ items = section.schema.fields,
+ key = { field ->
+ ExtensionSettingKeys.qualified(section.id, field.key)
+ },
+ ) { field ->
+ val key = ExtensionSettingKeys.qualified(section.id, field.key)
+ val editToken = checkNotNull(
+ configuration.editToken(section.id, field.key)
+ )
+ ExtensionSettingControl(
+ field = field,
+ qualifiedKey = key,
+ bidiFormatter = bidiFormatter,
+ rawValue = draftValues[key].orEmpty(),
+ secretConfigured = key in configuration.snapshot.credentialHandles,
+ validationRequested = validationRequested[key] == true,
+ dirty = dirtyKeys[key] == true,
+ updating = key in state.updatingKeys,
+ networkOriginState = configuration.networkOriginState(
+ section.id,
+ field.key,
+ ),
+ onDraftChange = { value ->
+ draftValues[key] = value
+ dirtyKeys[key] = true
+ },
+ onValidationRequested = {
+ validationRequested[key] = true
+ },
+ onUpdate = { value ->
+ validationRequested[key] = false
+ dirtyKeys.remove(key)
+ draftValues[key] = if (
+ field.type == ExtensionSettingType.SECRET
+ ) {
+ ""
+ } else {
+ value.orEmpty()
+ }
+ onUpdate(section.id, field.key, editToken, value)
+ },
+ modifier = Modifier
+ .widthIn(max = 640.dp)
+ .fillMaxWidth(),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionSettingsLoading(
+ modifier: Modifier,
+ contentPadding: PaddingValues,
+) {
+ val loadingDescription = stringResource(string.feat_setting_extension_loading)
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .testTag("extension-settings-loading"),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.semantics {
+ contentDescription = loadingDescription
+ },
+ )
+ }
+}
+
+@Composable
+private fun ExtensionSettingsStatus(
+ text: String,
+ tag: String,
+ modifier: Modifier,
+ contentPadding: PaddingValues,
+ onRetry: (() -> Unit)? = null,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .padding(16.dp)
+ .testTag(tag),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = 640.dp)
+ .fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ onRetry?.let { retry ->
+ TextButton(
+ onClick = retry,
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag("extension-settings-retry"),
+ ) {
+ Text(stringResource(string.ui_action_refresh))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionSettingControl(
+ field: ExtensionSettingField,
+ qualifiedKey: String,
+ bidiFormatter: UiBidiFormatter,
+ rawValue: String,
+ secretConfigured: Boolean,
+ validationRequested: Boolean,
+ dirty: Boolean,
+ updating: Boolean,
+ networkOriginState: ExtensionNetworkOriginState?,
+ onDraftChange: (String) -> Unit,
+ onValidationRequested: () -> Unit,
+ onUpdate: (String?) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val requiredDescription = stringResource(string.feat_setting_provider_error_required)
+ val semanticFieldLabelText = extensionSettingSemanticText(
+ value = field.label,
+ bidiFormatter = bidiFormatter,
+ )
+ val semanticFieldDescriptionText = field.description
+ ?.takeIf(String::isNotBlank)
+ ?.let { description ->
+ extensionSettingSemanticText(
+ value = description,
+ bidiFormatter = bidiFormatter,
+ )
+ }
+ val semanticFieldLabel = if (field.required) {
+ stringResource(
+ string.feat_setting_extension_field_required_description,
+ semanticFieldLabelText,
+ requiredDescription,
+ )
+ } else {
+ semanticFieldLabelText
+ }
+ val semanticFieldDescription = listOfNotNull(
+ semanticFieldLabel,
+ semanticFieldDescriptionText,
+ ).joinToString(separator = "\n")
+ val inputError = field.extensionSettingInputError(
+ rawValue = rawValue,
+ secretConfigured = secretConfigured,
+ )
+ val visibleInputError = inputError.takeIf {
+ validationRequested || field.type == ExtensionSettingType.SINGLE_CHOICE
+ }
+ val inputErrorMessage = visibleInputError?.message()
+ val saveLabel = stringResource(string.feat_setting_extension_setting_save)
+ val clearLabel = stringResource(string.feat_setting_extension_setting_clear)
+ val saveDescription = stringResource(
+ string.feat_setting_extension_action_field_description,
+ saveLabel,
+ semanticFieldLabelText,
+ )
+ val clearDescription = stringResource(
+ string.feat_setting_extension_action_field_description,
+ clearLabel,
+ semanticFieldLabelText,
+ )
+ val networkOriginStateLabel = networkOriginState?.let {
+ extensionNetworkOriginStateLabel(it)
+ }
+ val focusManager = LocalFocusManager.current
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ when (field.type) {
+ ExtensionSettingType.BOOLEAN -> {
+ val checked = rawValue.toBooleanStrictOrNull() ?: false
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp)
+ .testTag("extension-setting:$qualifiedKey")
+ .toggleable(
+ value = checked,
+ enabled = !updating,
+ role = Role.Switch,
+ onValueChange = { value -> onUpdate(value.toString()) },
+ )
+ .semantics {
+ contentDescription = semanticFieldDescription
+ }
+ .padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ SettingLabel(
+ field = field,
+ bidiFormatter = bidiFormatter,
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 16.dp),
+ )
+ Switch(
+ checked = checked,
+ onCheckedChange = null,
+ enabled = !updating,
+ modifier = Modifier.clearAndSetSemantics {},
+ )
+ }
+ }
+
+ ExtensionSettingType.SINGLE_CHOICE -> {
+ SettingLabel(field, bidiFormatter)
+ FlowRow(
+ modifier = Modifier
+ .selectableGroup()
+ .semantics {
+ inputErrorMessage?.let { message ->
+ error(message)
+ }
+ },
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ field.choices.forEach { choice ->
+ val semanticChoiceLabel = extensionSettingSemanticText(
+ value = choice.label,
+ bidiFormatter = bidiFormatter,
+ )
+ val choiceDescription = if (field.required) {
+ stringResource(
+ string.feat_setting_extension_choice_field_required_description,
+ semanticChoiceLabel,
+ semanticFieldLabelText,
+ requiredDescription,
+ )
+ } else {
+ stringResource(
+ string.feat_setting_extension_choice_field_description,
+ semanticChoiceLabel,
+ semanticFieldLabelText,
+ )
+ }
+ val choiceControlDescription = listOfNotNull(
+ choiceDescription,
+ semanticFieldDescriptionText,
+ ).joinToString(separator = "\n")
+ FilterChip(
+ selected = rawValue == choice.value,
+ enabled = !updating,
+ onClick = { onUpdate(choice.value) },
+ label = {
+ Text(
+ text = bidiFormatter.natural(choice.label),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag(
+ "extension-setting-choice:$qualifiedKey:${choice.value}"
+ )
+ .semantics {
+ role = Role.RadioButton
+ contentDescription = choiceControlDescription
+ },
+ )
+ }
+ }
+ inputErrorMessage?.let { message ->
+ ExtensionSettingErrorText(message)
+ }
+ }
+
+ ExtensionSettingType.TEXT,
+ ExtensionSettingType.NUMBER,
+ ExtensionSettingType.SECRET -> {
+ val singleLineInput =
+ field.type != ExtensionSettingType.TEXT || field.networkOrigin
+ val textDirection = extensionSettingInputTextDirection(field)
+ fun commitInput(): Boolean {
+ if (updating) return false
+ onValidationRequested()
+ if (inputError != null) return false
+ onUpdate(
+ rawValue
+ .takeUnless { it.isEmpty() && !field.required }
+ ?.let(field::normalizedExtensionSettingValue)
+ )
+ return true
+ }
+ SettingLabel(field, bidiFormatter)
+ OutlinedTextField(
+ value = rawValue,
+ onValueChange = onDraftChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("extension-setting-field:$qualifiedKey")
+ .semantics {
+ contentDescription = semanticFieldDescription
+ inputErrorMessage?.let { message ->
+ error(message)
+ }
+ },
+ placeholder = if (field.type == ExtensionSettingType.SECRET && secretConfigured) {
+ { Text(stringResource(string.feat_setting_extension_secret_configured)) }
+ } else {
+ null
+ },
+ visualTransformation = if (field.type == ExtensionSettingType.SECRET) {
+ PasswordVisualTransformation()
+ } else {
+ VisualTransformation.None
+ },
+ textStyle = LocalTextStyle.current.copy(
+ textDirection = textDirection,
+ ),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = if (field.type == ExtensionSettingType.NUMBER) {
+ KeyboardType.Decimal
+ } else {
+ KeyboardType.Text
+ },
+ imeAction = if (singleLineInput) {
+ ImeAction.Done
+ } else {
+ ImeAction.Default
+ },
+ ),
+ keyboardActions = if (singleLineInput) {
+ KeyboardActions(
+ onDone = {
+ if (commitInput()) {
+ focusManager.clearFocus()
+ }
+ }
+ )
+ } else {
+ KeyboardActions.Default
+ },
+ readOnly = updating,
+ isError = inputErrorMessage != null,
+ supportingText = when {
+ inputErrorMessage != null -> {
+ { ExtensionSettingErrorText(inputErrorMessage) }
+ }
+ field.networkOrigin -> {
+ {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ networkOriginStateLabel?.let { stateLabel ->
+ Text(
+ text = stateLabel,
+ color = if (
+ networkOriginState.requiresUserAttention
+ ) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier
+ .testTag(
+ "extension-setting-origin-state:$qualifiedKey"
+ )
+ .semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ }
+ Text(
+ stringResource(
+ string.feat_setting_extension_network_origin_save_notice
+ )
+ )
+ }
+ }
+ }
+ else -> null
+ },
+ singleLine = singleLineInput,
+ minLines = 1,
+ maxLines = if (singleLineInput) 1 else 4,
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ AnimatedVisibility(
+ visible = shouldShowExtensionSettingSaveAction(
+ dirty = dirty,
+ networkOriginState = networkOriginState,
+ )
+ ) {
+ TextButton(
+ enabled = !updating &&
+ (
+ field.type != ExtensionSettingType.SECRET ||
+ rawValue.isNotEmpty()
+ ),
+ onClick = { commitInput() },
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag("extension-setting-save:$qualifiedKey")
+ .semantics {
+ contentDescription = saveDescription
+ },
+ ) {
+ Text(saveLabel)
+ }
+ }
+ if (rawValue.isNotEmpty() || secretConfigured) {
+ TextButton(
+ enabled = !updating,
+ onClick = { onUpdate(null) },
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag("extension-setting-clear:$qualifiedKey")
+ .semantics {
+ contentDescription = clearDescription
+ },
+ ) {
+ Text(clearLabel)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExtensionSettingInputError.message(): String = stringResource(
+ when (this) {
+ ExtensionSettingInputError.REQUIRED -> string.feat_setting_provider_error_required
+ ExtensionSettingInputError.TOO_LONG -> string.feat_setting_provider_error_too_long
+ ExtensionSettingInputError.INVALID_NUMBER -> string.feat_setting_provider_error_number
+ ExtensionSettingInputError.INVALID_BOOLEAN -> string.feat_setting_provider_error_boolean
+ ExtensionSettingInputError.INVALID_CHOICE -> string.feat_setting_provider_error_choice
+ ExtensionSettingInputError.INVALID_NETWORK_ORIGIN ->
+ string.feat_setting_extension_error_network_origin
+ }
+)
+
+@Composable
+private fun ExtensionSettingErrorText(message: String) {
+ Text(
+ text = message,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.semantics {
+ error(message)
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+}
+
+@Composable
+private fun SettingLabel(
+ field: ExtensionSettingField,
+ bidiFormatter: UiBidiFormatter,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = bidiFormatter.natural(
+ if (field.required) "${field.label} *" else field.label
+ ),
+ )
+ field.description?.takeIf(String::isNotBlank)?.let { description ->
+ Text(
+ text = bidiFormatter.natural(description),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+private fun Any?.primitiveContent(): String = when (this) {
+ is JsonPrimitive -> booleanOrNull?.toString() ?: contentOrNull.orEmpty()
+ else -> ""
+}
+
+internal fun extensionSettingSemanticText(
+ value: String,
+ bidiFormatter: UiBidiFormatter,
+): String = bidiFormatter.natural(value.withoutBidiControls())
+
+internal fun extensionSettingInputTextDirection(
+ field: ExtensionSettingField,
+): TextDirection = if (
+ field.type == ExtensionSettingType.NUMBER ||
+ field.networkOrigin
+) {
+ TextDirection.Ltr
+} else {
+ TextDirection.ContentOrLtr
+}
+
+internal fun shouldShowExtensionSettingSaveAction(
+ dirty: Boolean,
+ networkOriginState: ExtensionNetworkOriginState?,
+): Boolean = dirty ||
+ networkOriginState == ExtensionNetworkOriginState.REQUIRES_APPROVAL
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/OptionalFragment.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/OptionalFragment.kt
index 19e160bb4..0d12e028c 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/OptionalFragment.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/OptionalFragment.kt
@@ -23,11 +23,10 @@ import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material.icons.rounded.Unarchive
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import com.m3u.core.foundation.architecture.preferences.ConnectTimeout
import com.m3u.core.foundation.architecture.preferences.PlaylistStrategy
@@ -37,6 +36,7 @@ import com.m3u.core.foundation.architecture.preferences.UnseensMilliseconds
import com.m3u.core.foundation.architecture.preferences.mutablePreferenceOf
import com.m3u.core.foundation.util.basic.title
import com.m3u.i18n.R.string
+import com.m3u.i18n.R.plurals
import com.m3u.smartphone.ui.business.setting.components.SwitchSharedPreference
import com.m3u.smartphone.ui.material.components.TextPreference
import com.m3u.smartphone.ui.material.ktx.plus
@@ -194,7 +194,11 @@ internal fun OptionalFragment(
TextPreference(
title = stringResource(string.feat_setting_connect_timeout).title(),
icon = Icons.Rounded.Timer,
- trailing = "${connectTimeout / 1000}s",
+ trailing = pluralStringResource(
+ plurals.ui_duration_seconds,
+ (connectTimeout / 1000).toInt(),
+ connectTimeout / 1000,
+ ),
onClick = {
connectTimeout = when (connectTimeout) {
ConnectTimeout.LONG -> ConnectTimeout.SHORT
@@ -206,14 +210,15 @@ internal fun OptionalFragment(
}
item {
var unseensMilliseconds by mutablePreferenceOf(PreferencesKeys.UNSEENS_MILLISECONDS)
- val unseensMillisecondsText by remember {
- derivedStateOf {
- val duration = unseensMilliseconds.toDuration(DurationUnit.MILLISECONDS)
- if (unseensMilliseconds > UnseensMilliseconds.DAYS_30) "Never"
- else duration
- .toString()
- .title()
- }
+ val unseensMillisecondsText = if (
+ unseensMilliseconds > UnseensMilliseconds.DAYS_30
+ ) {
+ stringResource(string.ui_never)
+ } else {
+ val days = unseensMilliseconds.toDuration(DurationUnit.MILLISECONDS)
+ .toLong(DurationUnit.DAYS)
+ .toInt()
+ pluralStringResource(plurals.ui_duration_days, days, days)
}
TextPreference(
title = stringResource(string.feat_setting_unseen_limit).title(),
@@ -260,4 +265,4 @@ internal fun OptionalFragment(
)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/PlaylistManagementScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/PlaylistManagementScreen.kt
new file mode 100644
index 000000000..7414e3f95
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/PlaylistManagementScreen.kt
@@ -0,0 +1,1218 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowForward
+import androidx.compose.material.icons.automirrored.rounded.List
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.Backup
+import androidx.compose.material.icons.rounded.Cloud
+import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material.icons.rounded.DateRange
+import androidx.compose.material.icons.rounded.ErrorOutline
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Extension
+import androidx.compose.material.icons.rounded.Link
+import androidx.compose.material.icons.rounded.Restore
+import androidx.compose.material.icons.rounded.VisibilityOff
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.m3u.business.setting.BackingUpAndRestoringState
+import com.m3u.business.setting.PlaylistSubscriptionPhase
+import com.m3u.business.setting.PlaylistSubscriptionState
+import com.m3u.business.setting.ProviderDiscoveryState
+import com.m3u.business.setting.ProviderOperationState
+import com.m3u.data.database.model.Channel
+import com.m3u.data.database.model.DataSource
+import com.m3u.data.database.model.Playlist
+import com.m3u.data.repository.provider.ProviderAccountSummary
+import com.m3u.data.worker.playlistWorkTag
+import com.m3u.i18n.R.plurals
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.business.configuration.providerDisplayName
+import com.m3u.smartphone.ui.business.setting.components.EpgPlaylistItem
+import com.m3u.smartphone.ui.business.setting.components.HiddenChannelItem
+import com.m3u.smartphone.ui.business.setting.components.HiddenPlaylistGroupItem
+import com.m3u.smartphone.ui.material.ktx.UiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.plus
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.safeDisplayText
+import java.text.Collator
+import java.util.Locale
+
+private val PlaylistPageMaxWidth = 720.dp
+
+internal fun playlistTitleComparator(locale: Locale): Comparator {
+ val collator = Collator.getInstance(locale).apply {
+ strength = Collator.SECONDARY
+ }
+ return Comparator { left, right ->
+ collator.compare(left.safeDisplayText(), right.safeDisplayText())
+ }
+}
+
+internal fun playlistTitleInLocalizedSentence(
+ title: String,
+ bidiFormatter: UiBidiFormatter,
+): String = bidiFormatter.natural(title.safeDisplayText())
+
+@Composable
+internal fun PlaylistManagementOverviewScreen(
+ backingUpOrRestoring: BackingUpAndRestoringState,
+ playlists: Map?,
+ playlistSubscriptionInProgress: Boolean,
+ playlistSubscriptionState: PlaylistSubscriptionState,
+ epgCount: Int,
+ hiddenChannelCount: Int,
+ hiddenCategoryCount: Int,
+ providerDiscoveryState: ProviderDiscoveryState,
+ providerAccountSummaries: List,
+ providerOperationState: ProviderOperationState,
+ onAddPlaylist: () -> Unit,
+ onCancelPlaylistSubscription: () -> Unit,
+ onDismissPlaylistSubscription: () -> Unit,
+ onRetryPlaylistSubscription: (DataSource) -> Unit,
+ onOpenPlaylistConfiguration: (Playlist) -> Unit,
+ onOpenEpgSources: () -> Unit,
+ onOpenHiddenChannels: () -> Unit,
+ onOpenHiddenCategories: () -> Unit,
+ onReauthenticateProviderAccount: (ProviderAccountSummary) -> Unit,
+ onBackup: () -> Unit,
+ onRestore: () -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ val locales = LocalConfiguration.current.locales
+ val currentLocale = if (locales.isEmpty) Locale.getDefault() else locales[0]
+ val titleComparator = remember(currentLocale) {
+ playlistTitleComparator(currentLocale)
+ }
+ val orderedPlaylists = playlists?.entries.orEmpty().sortedWith(
+ compareBy(titleComparator) { entry -> entry.key.title }
+ )
+ val accountsRequiringAttention = providerAccountSummaries.filter { account ->
+ account.requiresReauthentication
+ }
+ val operationInProgress = providerOperationState.isBusy
+ val backupInProgress =
+ backingUpOrRestoring == BackingUpAndRestoringState.BACKING_UP ||
+ backingUpOrRestoring == BackingUpAndRestoringState.BOTH
+ val restoreInProgress =
+ backingUpOrRestoring == BackingUpAndRestoringState.RESTORING ||
+ backingUpOrRestoring == BackingUpAndRestoringState.BOTH
+ val dataOperationInProgress =
+ backingUpOrRestoring != BackingUpAndRestoringState.NONE
+ val subscriptionInProgress =
+ playlistSubscriptionState.isInProgress ||
+ playlistSubscriptionInProgress
+ val subscriptionStatusVisible =
+ playlistSubscriptionState.phase != PlaylistSubscriptionPhase.IDLE
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .testTag("playlist-management-overview"),
+ contentPadding = contentPadding + PaddingValues(vertical = 16.dp),
+ ) {
+ if (accountsRequiringAttention.isNotEmpty()) {
+ item(key = "attention-heading") {
+ PlaylistPageContent {
+ PlaylistSectionHeading(
+ text = stringResource(string.feat_setting_playlist_needs_attention),
+ )
+ }
+ }
+ items(
+ items = accountsRequiringAttention,
+ key = { account -> account.playlistUrl },
+ ) { account ->
+ PlaylistPageContent {
+ ProviderReauthenticationCard(
+ account = account,
+ inProgress = providerOperationState.isReauthenticating(
+ account.playlistUrl
+ ),
+ enabled = !operationInProgress && !dataOperationInProgress,
+ onReauthenticate = {
+ onReauthenticateProviderAccount(account)
+ },
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ }
+ }
+ }
+
+ item(key = "add-playlist") {
+ PlaylistPageContent {
+ PlaylistDestinationRow(
+ headline = stringResource(string.feat_setting_label_add_playlist),
+ supporting = stringResource(
+ string.feat_setting_playlist_add_description
+ ),
+ icon = Icons.Rounded.Add,
+ onClick = onAddPlaylist,
+ emphasized = true,
+ enabled =
+ !dataOperationInProgress &&
+ !operationInProgress &&
+ !subscriptionInProgress &&
+ !subscriptionStatusVisible,
+ modifier = Modifier.testTag("playlist-add-action"),
+ )
+ }
+ }
+
+ item(key = "playlists-heading") {
+ PlaylistPageContent {
+ PlaylistSectionHeading(
+ text = stringResource(string.feat_setting_playlist_your_playlists),
+ )
+ }
+ }
+ if (playlists == null) {
+ item(key = "playlists-loading") {
+ PlaylistPageContent {
+ PlaylistLoadingState()
+ }
+ }
+ } else {
+ if (playlistSubscriptionState.phase != PlaylistSubscriptionPhase.IDLE) {
+ item(key = "playlist-subscription-status") {
+ PlaylistPageContent {
+ PlaylistSubscriptionStatusCard(
+ state = playlistSubscriptionState,
+ retryEnabled =
+ !dataOperationInProgress &&
+ !operationInProgress &&
+ !playlistSubscriptionInProgress,
+ onCancel = onCancelPlaylistSubscription,
+ onDismiss = onDismissPlaylistSubscription,
+ onRetry = onRetryPlaylistSubscription,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ }
+ }
+ } else if (playlistSubscriptionInProgress) {
+ item(key = "playlist-update-in-progress") {
+ PlaylistPageContent {
+ PlaylistLoadingState(
+ text = stringResource(
+ string.feat_setting_playlist_update_in_progress
+ ),
+ modifier = Modifier.testTag(
+ "playlist-management-update-in-progress"
+ ),
+ )
+ }
+ }
+ }
+ if (orderedPlaylists.isEmpty() && !subscriptionInProgress) {
+ item(key = "playlists-empty") {
+ PlaylistPageContent {
+ PlaylistInlineEmptyState(
+ text = stringResource(
+ string.feat_setting_playlist_no_playlists
+ ),
+ )
+ }
+ }
+ } else {
+ itemsIndexed(
+ items = orderedPlaylists,
+ key = { _, entry -> entry.key.url },
+ ) { index, (playlist, channelCount) ->
+ val providerAccount = providerAccountSummaries.firstOrNull { account ->
+ account.playlistUrl == playlist.url
+ }
+ val providerName = providerAccount?.let { account ->
+ providerDisplayName(account, providerDiscoveryState)
+ }
+ PlaylistPageContent {
+ PlaylistSubscriptionRow(
+ playlist = playlist,
+ channelCount = channelCount,
+ providerDisplayName = providerName,
+ enabled = !dataOperationInProgress && !operationInProgress,
+ onClick = { onOpenPlaylistConfiguration(playlist) },
+ modifier = Modifier.testTag(
+ "playlist-management-item:${playlistWorkTag(playlist.url)}"
+ ),
+ )
+ if (index != orderedPlaylists.lastIndex) {
+ PlaylistInsetDivider()
+ }
+ }
+ }
+ }
+ }
+
+ item(key = "content-heading") {
+ PlaylistPageContent {
+ PlaylistSectionHeading(
+ text = stringResource(
+ string.feat_setting_playlist_content_and_guide
+ ),
+ )
+ }
+ }
+ item(key = "epg-sources") {
+ PlaylistPageContent {
+ PlaylistDestinationRow(
+ headline = stringResource(string.feat_setting_label_epg_playlists),
+ supporting = listOf(
+ stringResource(string.feat_setting_playlist_manage_epg_description),
+ pluralStringResource(
+ plurals.feat_setting_playlist_epg_source_count,
+ epgCount,
+ epgCount,
+ ),
+ ),
+ icon = Icons.Rounded.DateRange,
+ onClick = onOpenEpgSources,
+ modifier = Modifier.testTag("playlist-overview-epg-sources"),
+ )
+ PlaylistInsetDivider()
+ }
+ }
+ item(key = "hidden-channels") {
+ PlaylistPageContent {
+ PlaylistDestinationRow(
+ headline = stringResource(string.feat_setting_label_hidden_channels),
+ supporting = listOf(
+ stringResource(
+ string.feat_setting_playlist_restore_hidden_channels_description
+ ),
+ pluralStringResource(
+ plurals.feat_setting_playlist_hidden_channel_count,
+ hiddenChannelCount,
+ hiddenChannelCount,
+ ),
+ ),
+ icon = Icons.Rounded.VisibilityOff,
+ onClick = onOpenHiddenChannels,
+ modifier = Modifier.testTag("playlist-overview-hidden-channels"),
+ )
+ PlaylistInsetDivider()
+ }
+ }
+ item(key = "hidden-categories") {
+ PlaylistPageContent {
+ PlaylistDestinationRow(
+ headline = stringResource(
+ string.feat_setting_label_hidden_playlist_groups
+ ),
+ supporting = listOf(
+ stringResource(
+ string.feat_setting_playlist_restore_hidden_categories_description
+ ),
+ pluralStringResource(
+ plurals.feat_setting_playlist_hidden_category_count,
+ hiddenCategoryCount,
+ hiddenCategoryCount,
+ ),
+ ),
+ icon = Icons.AutoMirrored.Rounded.List,
+ onClick = onOpenHiddenCategories,
+ modifier = Modifier.testTag("playlist-overview-hidden-categories"),
+ )
+ }
+ }
+
+ item(key = "data-heading") {
+ PlaylistPageContent {
+ PlaylistSectionHeading(
+ text = stringResource(
+ string.feat_setting_playlist_data_and_recovery
+ ),
+ )
+ }
+ }
+ item(key = "backup") {
+ PlaylistPageContent {
+ PlaylistDataActionRow(
+ headline = stringResource(string.feat_setting_label_backup),
+ supporting = stringResource(
+ string.feat_setting_playlist_backup_description
+ ),
+ icon = Icons.Rounded.Backup,
+ loading = backupInProgress,
+ enabled = !dataOperationInProgress && !operationInProgress,
+ onClick = onBackup,
+ modifier = Modifier.testTag("playlist-backup-action"),
+ )
+ PlaylistInsetDivider()
+ }
+ }
+ item(key = "restore") {
+ PlaylistPageContent {
+ PlaylistDataActionRow(
+ headline = stringResource(string.feat_setting_label_restore),
+ supporting = stringResource(
+ string.feat_setting_playlist_restore_description
+ ),
+ icon = Icons.Rounded.Restore,
+ loading = restoreInProgress,
+ enabled = !dataOperationInProgress && !operationInProgress,
+ onClick = onRestore,
+ modifier = Modifier.testTag("playlist-restore-action"),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlaylistSubscriptionStatusCard(
+ state: PlaylistSubscriptionState,
+ retryEnabled: Boolean,
+ onCancel: () -> Unit,
+ onDismiss: () -> Unit,
+ onRetry: (DataSource) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (state.phase == PlaylistSubscriptionPhase.IDLE) return
+
+ val bidiFormatter = rememberUiBidiFormatter()
+ val statusText = when (state.phase) {
+ PlaylistSubscriptionPhase.IDLE -> return
+ PlaylistSubscriptionPhase.ENQUEUED ->
+ stringResource(string.feat_setting_subscription_status_queued)
+ PlaylistSubscriptionPhase.RUNNING ->
+ stringResource(string.feat_setting_subscription_status_running)
+ PlaylistSubscriptionPhase.SUCCEEDED ->
+ stringResource(string.feat_setting_subscription_status_succeeded)
+ PlaylistSubscriptionPhase.FAILED ->
+ stringResource(string.feat_setting_subscription_status_failed)
+ PlaylistSubscriptionPhase.CANCELLED ->
+ stringResource(string.feat_setting_subscription_status_cancelled)
+ }
+ val sourceText = state.source?.let { source -> stringResource(source.resId) }
+ val title = state.title
+ ?.safeDisplayText()
+ ?.takeIf(String::isNotBlank)
+ ?.let(bidiFormatter::natural)
+ val isInProgress = state.isInProgress
+ val containerColor = when (state.phase) {
+ PlaylistSubscriptionPhase.ENQUEUED,
+ PlaylistSubscriptionPhase.RUNNING ->
+ MaterialTheme.colorScheme.tertiaryContainer
+ PlaylistSubscriptionPhase.SUCCEEDED ->
+ MaterialTheme.colorScheme.primaryContainer
+ PlaylistSubscriptionPhase.FAILED ->
+ MaterialTheme.colorScheme.errorContainer
+ PlaylistSubscriptionPhase.CANCELLED,
+ PlaylistSubscriptionPhase.IDLE ->
+ MaterialTheme.colorScheme.surfaceContainerHigh
+ }
+ val contentColor = when (state.phase) {
+ PlaylistSubscriptionPhase.ENQUEUED,
+ PlaylistSubscriptionPhase.RUNNING ->
+ MaterialTheme.colorScheme.onTertiaryContainer
+ PlaylistSubscriptionPhase.SUCCEEDED ->
+ MaterialTheme.colorScheme.onPrimaryContainer
+ PlaylistSubscriptionPhase.FAILED ->
+ MaterialTheme.colorScheme.onErrorContainer
+ PlaylistSubscriptionPhase.CANCELLED,
+ PlaylistSubscriptionPhase.IDLE ->
+ MaterialTheme.colorScheme.onSurface
+ }
+
+ Surface(
+ color = containerColor,
+ contentColor = contentColor,
+ shape = MaterialTheme.shapes.extraLarge,
+ modifier = modifier
+ .fillMaxWidth()
+ .testTag("playlist-subscription-status")
+ .semantics {
+ liveRegion = LiveRegionMode.Polite
+ stateDescription = statusText
+ },
+ ) {
+ Column(
+ modifier = Modifier.padding(
+ start = 16.dp,
+ end = 8.dp,
+ top = 16.dp,
+ bottom = 8.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ when {
+ isInProgress -> CircularProgressIndicator(
+ modifier = Modifier
+ .size(24.dp)
+ .clearAndSetSemantics {},
+ color = contentColor,
+ strokeWidth = 2.dp,
+ )
+ state.phase == PlaylistSubscriptionPhase.SUCCEEDED -> Icon(
+ imageVector = Icons.Rounded.CheckCircle,
+ contentDescription = null,
+ )
+ state.phase == PlaylistSubscriptionPhase.FAILED -> Icon(
+ imageVector = Icons.Rounded.ErrorOutline,
+ contentDescription = null,
+ )
+ else -> Icon(
+ imageVector = Icons.Rounded.Info,
+ contentDescription = null,
+ )
+ }
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ title?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ sourceText?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ Text(
+ text = statusText,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(
+ space = 8.dp,
+ alignment = Alignment.End,
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ state.source
+ ?.takeIf {
+ state.phase == PlaylistSubscriptionPhase.FAILED
+ }
+ ?.let { failedSource ->
+ TextButton(
+ onClick = { onRetry(failedSource) },
+ enabled = retryEnabled,
+ modifier = Modifier.testTag(
+ "playlist-subscription-retry"
+ ),
+ ) {
+ Text(stringResource(string.ui_action_retry))
+ }
+ }
+ TextButton(
+ onClick = if (isInProgress) onCancel else onDismiss,
+ modifier = Modifier.testTag(
+ if (isInProgress) {
+ "playlist-subscription-cancel"
+ } else {
+ "playlist-subscription-dismiss"
+ }
+ ),
+ ) {
+ Text(
+ stringResource(
+ if (isInProgress) {
+ android.R.string.cancel
+ } else {
+ android.R.string.ok
+ }
+ )
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlaylistSubscriptionRow(
+ playlist: Playlist,
+ channelCount: Int,
+ providerDisplayName: String?,
+ enabled: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val bidiFormatter = rememberUiBidiFormatter()
+ val title = bidiFormatter.natural(playlist.title)
+ val source = providerDisplayName
+ ?.safeDisplayText()
+ ?.takeIf(String::isNotBlank)
+ ?.let(bidiFormatter::natural)
+ ?: stringResource(playlist.source.resId)
+ val count = pluralStringResource(
+ plurals.feat_setting_playlist_channel_count,
+ channelCount,
+ channelCount,
+ )
+ val sourceIcon = when (playlist.source) {
+ DataSource.M3U -> Icons.Rounded.Link
+ DataSource.Xtream -> Icons.Rounded.Cloud
+ DataSource.Emby, DataSource.Jellyfin, DataSource.Provider ->
+ Icons.Rounded.Extension
+ else -> Icons.AutoMirrored.Rounded.List
+ }
+
+ ListItem(
+ headlineContent = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ supportingContent = {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = source,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = count,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ },
+ leadingContent = {
+ Icon(
+ imageVector = sourceIcon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ },
+ trailingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
+ contentDescription = null,
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .alpha(if (enabled) 1f else 0.38f)
+ .clickable(
+ enabled = enabled,
+ role = Role.Button,
+ onClick = onClick,
+ ),
+ )
+}
+
+@Composable
+private fun PlaylistInlineEmptyState(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.List,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ ),
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(MaterialTheme.shapes.extraLarge),
+ )
+}
+
+@Composable
+private fun PlaylistLoadingState(
+ modifier: Modifier = Modifier,
+ text: String? = null,
+) {
+ val stateText = text ?: stringResource(string.ui_state_loading)
+ ListItem(
+ headlineContent = {
+ Text(
+ text = stateText,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ leadingContent = {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(24.dp)
+ .clearAndSetSemantics {},
+ strokeWidth = 2.dp,
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ ),
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(MaterialTheme.shapes.extraLarge)
+ .semantics {
+ liveRegion = LiveRegionMode.Polite
+ stateDescription = stateText
+ },
+ )
+}
+
+@Composable
+internal fun EpgSourceListScreen(
+ epgs: List,
+ onAddEpgSource: () -> Unit,
+ onDeleteEpgPlaylist: (String) -> Unit,
+ enabled: Boolean = true,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ var pendingDeletion by remember { mutableStateOf(null) }
+ val bidiFormatter = rememberUiBidiFormatter()
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .testTag("playlist-list:epg-sources"),
+ contentPadding = contentPadding + PaddingValues(vertical = 16.dp),
+ ) {
+ item(key = "add-epg-source") {
+ PlaylistPageContent {
+ PlaylistDestinationRow(
+ headline = stringResource(
+ string.feat_setting_playlist_add_epg_source
+ ),
+ supporting = stringResource(
+ string.feat_setting_playlist_add_epg_source_description
+ ),
+ icon = Icons.Rounded.Add,
+ onClick = onAddEpgSource,
+ emphasized = true,
+ enabled = enabled,
+ modifier = Modifier.testTag("playlist-add-epg-action"),
+ )
+ }
+ }
+ if (epgs.isEmpty()) {
+ item(key = "empty") {
+ PlaylistEmptyState(
+ text = stringResource(
+ string.feat_setting_playlist_epg_sources_empty
+ ),
+ icon = Icons.Rounded.DateRange,
+ )
+ }
+ } else {
+ itemsIndexed(
+ items = epgs,
+ key = { _, playlist -> playlist.url },
+ ) { index, playlist ->
+ val titleInAction = playlistTitleInLocalizedSentence(
+ title = playlist.title,
+ bidiFormatter = bidiFormatter,
+ )
+ val deleteDescription = stringResource(
+ string.feat_setting_playlist_delete_epg_action_description,
+ titleInAction,
+ )
+ PlaylistPageContent {
+ EpgPlaylistItem(
+ epgPlaylist = playlist,
+ onDeleteEpgPlaylist = { pendingDeletion = playlist },
+ deleteContentDescription = deleteDescription,
+ enabled = enabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(
+ "playlist-epg:${playlistWorkTag(playlist.url)}"
+ ),
+ )
+ if (index != epgs.lastIndex) {
+ PlaylistInsetDivider(startPadding = 16.dp)
+ }
+ }
+ }
+ }
+ }
+
+ pendingDeletion?.let { playlist ->
+ val title = playlistTitleInLocalizedSentence(
+ title = playlist.title,
+ bidiFormatter = bidiFormatter,
+ )
+ AlertDialog(
+ onDismissRequest = { pendingDeletion = null },
+ title = {
+ Text(stringResource(string.feat_setting_playlist_delete_epg_title))
+ },
+ text = {
+ Text(
+ stringResource(
+ string.feat_setting_playlist_delete_epg_message,
+ title,
+ )
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ pendingDeletion = null
+ onDeleteEpgPlaylist(playlist.url)
+ },
+ modifier = Modifier.testTag("playlist-delete-epg-confirm"),
+ enabled = enabled,
+ ) {
+ Text(stringResource(string.ui_action_delete_epg))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { pendingDeletion = null }) {
+ Text(stringResource(android.R.string.cancel))
+ }
+ },
+ modifier = Modifier.testTag("playlist-delete-epg-dialog"),
+ )
+ }
+}
+
+@Composable
+internal fun HiddenChannelListScreen(
+ hiddenChannels: List,
+ onUnhideChannel: (Int) -> Unit,
+ enabled: Boolean = true,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ val bidiFormatter = rememberUiBidiFormatter()
+ PlaylistManagementList(
+ empty = hiddenChannels.isEmpty(),
+ emptyText = stringResource(string.feat_setting_playlist_hidden_channels_empty),
+ emptyIcon = Icons.Rounded.VisibilityOff,
+ testTag = "playlist-list:hidden-channels",
+ modifier = modifier,
+ contentPadding = contentPadding,
+ ) {
+ itemsIndexed(
+ items = hiddenChannels,
+ key = { _, channel -> channel.id },
+ ) { index, channel ->
+ val titleInAction = bidiFormatter.natural(channel.title)
+ PlaylistPageContent {
+ HiddenChannelItem(
+ channel = channel,
+ onShow = { onUnhideChannel(channel.id) },
+ showLabel = stringResource(string.feat_setting_playlist_show_action),
+ showContentDescription = stringResource(
+ string.feat_setting_playlist_show_channel_action_description,
+ titleInAction,
+ ),
+ enabled = enabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("playlist-hidden-channel:${channel.id}"),
+ )
+ if (index != hiddenChannels.lastIndex) {
+ PlaylistInsetDivider(startPadding = 16.dp)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun HiddenCategoryListScreen(
+ hiddenCategoriesWithPlaylists: List>,
+ onUnhidePlaylistCategory: (playlistUrl: String, category: String) -> Unit,
+ enabled: Boolean = true,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ val bidiFormatter = rememberUiBidiFormatter()
+ PlaylistManagementList(
+ empty = hiddenCategoriesWithPlaylists.isEmpty(),
+ emptyText = stringResource(string.feat_setting_playlist_hidden_categories_empty),
+ emptyIcon = Icons.AutoMirrored.Rounded.List,
+ testTag = "playlist-list:hidden-categories",
+ modifier = modifier,
+ contentPadding = contentPadding,
+ ) {
+ itemsIndexed(
+ items = hiddenCategoriesWithPlaylists,
+ key = { _, (playlist, category) ->
+ "${playlist.url}\u0000$category"
+ },
+ ) { index, (playlist, category) ->
+ val categoryInAction = bidiFormatter.natural(category)
+ val categoryKey = playlistWorkTag("${playlist.url}\u0000$category")
+ PlaylistPageContent {
+ HiddenPlaylistGroupItem(
+ playlist = playlist,
+ group = category,
+ onShow = {
+ onUnhidePlaylistCategory(playlist.url, category)
+ },
+ showLabel = stringResource(string.feat_setting_playlist_show_action),
+ showContentDescription = stringResource(
+ string.feat_setting_playlist_show_category_action_description,
+ categoryInAction,
+ ),
+ enabled = enabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("playlist-hidden-category:$categoryKey"),
+ )
+ if (index != hiddenCategoriesWithPlaylists.lastIndex) {
+ PlaylistInsetDivider(startPadding = 16.dp)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlaylistDestinationRow(
+ headline: String,
+ supporting: String,
+ icon: ImageVector,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ emphasized: Boolean = false,
+ enabled: Boolean = true,
+) {
+ PlaylistDestinationRow(
+ headline = headline,
+ supporting = listOf(supporting),
+ icon = icon,
+ onClick = onClick,
+ modifier = modifier,
+ emphasized = emphasized,
+ enabled = enabled,
+ )
+}
+
+@Composable
+private fun PlaylistDestinationRow(
+ headline: String,
+ supporting: List,
+ icon: ImageVector,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ emphasized: Boolean = false,
+ enabled: Boolean = true,
+) {
+ val shape = MaterialTheme.shapes.extraLarge
+ ListItem(
+ headlineContent = {
+ Text(
+ text = headline,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ },
+ supportingContent = {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ supporting.forEach { line ->
+ Text(
+ text = line,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ },
+ leadingContent = {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ },
+ trailingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
+ contentDescription = null,
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = if (emphasized) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.surface
+ },
+ headlineColor = if (emphasized) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ supportingColor = if (emphasized) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ leadingIconColor = if (emphasized) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ trailingIconColor = if (emphasized) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ ),
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .clip(shape)
+ .alpha(if (enabled) 1f else 0.38f)
+ .clickable(
+ enabled = enabled,
+ role = Role.Button,
+ onClick = onClick,
+ ),
+ )
+}
+
+@Composable
+private fun PlaylistDataActionRow(
+ headline: String,
+ supporting: String,
+ icon: ImageVector,
+ loading: Boolean,
+ enabled: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val loadingDescription = stringResource(string.ui_state_loading)
+ ListItem(
+ headlineContent = {
+ Text(
+ text = headline,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ },
+ supportingContent = {
+ Text(
+ text = supporting,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ leadingContent = {
+ Icon(imageVector = icon, contentDescription = null)
+ },
+ trailingContent = {
+ if (loading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(24.dp)
+ .clearAndSetSemantics { },
+ strokeWidth = 2.dp,
+ )
+ }
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .alpha(if (enabled || loading) 1f else 0.38f)
+ .clickable(
+ enabled = enabled,
+ role = Role.Button,
+ onClick = onClick,
+ )
+ .semantics {
+ role = Role.Button
+ if (loading) {
+ stateDescription = loadingDescription
+ liveRegion = LiveRegionMode.Polite
+ }
+ },
+ )
+}
+
+@Composable
+private fun PlaylistSectionHeading(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 8.dp)
+ .semantics { heading() },
+ )
+}
+
+@Composable
+private fun PlaylistInsetDivider(
+ startPadding: Dp = 56.dp,
+) {
+ HorizontalDivider(
+ modifier = Modifier
+ .widthIn(max = PlaylistPageMaxWidth)
+ .fillMaxWidth()
+ .padding(start = startPadding)
+ )
+}
+
+@Composable
+private fun PlaylistPageContent(
+ content: @Composable () -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = PlaylistPageMaxWidth)
+ .fillMaxWidth(),
+ ) {
+ content()
+ }
+ }
+}
+
+@Composable
+private fun PlaylistManagementList(
+ empty: Boolean,
+ emptyText: String,
+ emptyIcon: ImageVector,
+ testTag: String,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+ content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit,
+) {
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .testTag(testTag),
+ contentPadding = contentPadding + PaddingValues(vertical = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (empty) {
+ item(key = "empty") {
+ PlaylistEmptyState(text = emptyText, icon = emptyIcon)
+ }
+ } else {
+ content()
+ }
+ }
+}
+
+@Composable
+private fun PlaylistEmptyState(
+ text: String,
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = PlaylistPageMaxWidth)
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 48.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(40.dp),
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ProviderDiscoveryUiPolicy.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ProviderDiscoveryUiPolicy.kt
new file mode 100644
index 000000000..a3482eaaa
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/ProviderDiscoveryUiPolicy.kt
@@ -0,0 +1,28 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.business.setting.ProviderDiscoveryState
+
+internal enum class ProviderDiscoveryNotice {
+ NONE,
+ LOADING,
+ EMPTY,
+ FAILED,
+}
+
+/**
+ * Keeps provider discovery recoverable even while an ordinary source is selected.
+ *
+ * A selected provider renders its more specific availability state inside the form.
+ */
+internal fun providerDiscoveryNotice(
+ state: ProviderDiscoveryState,
+ providerSelected: Boolean,
+): ProviderDiscoveryNotice {
+ if (providerSelected) return ProviderDiscoveryNotice.NONE
+ return when (state) {
+ ProviderDiscoveryState.Loading -> ProviderDiscoveryNotice.LOADING
+ ProviderDiscoveryState.Empty -> ProviderDiscoveryNotice.EMPTY
+ is ProviderDiscoveryState.Failed -> ProviderDiscoveryNotice.FAILED
+ is ProviderDiscoveryState.Ready -> ProviderDiscoveryNotice.NONE
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionEditorScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionEditorScreen.kt
new file mode 100644
index 000000000..6666b735b
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionEditorScreen.kt
@@ -0,0 +1,1520 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.widget.Toast
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Cloud
+import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material.icons.rounded.ContentPaste
+import androidx.compose.material.icons.rounded.DateRange
+import androidx.compose.material.icons.rounded.Extension
+import androidx.compose.material.icons.rounded.Link
+import androidx.compose.material.icons.rounded.RadioButtonUnchecked
+import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.error
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.accompanist.permissions.PermissionStatus
+import com.m3u.business.setting.ProviderDiscoveryState
+import com.m3u.business.setting.ProviderOperationState
+import com.m3u.business.setting.ProviderSettingFieldError
+import com.m3u.business.setting.ProviderSubscriptionSource
+import com.m3u.business.setting.ProviderSubscriptionForm
+import com.m3u.business.setting.ProviderSubscriptionFormField
+import com.m3u.business.setting.SettingProperties
+import com.m3u.business.setting.subscriptionSources
+import com.m3u.business.setting.supports
+import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
+import com.m3u.core.foundation.architecture.preferences.preferenceOf
+import com.m3u.data.database.model.DataSource
+import com.m3u.data.repository.provider.ProviderAccountSummary
+import com.m3u.data.repository.provider.SubscriptionProviderExecutionKind
+import com.m3u.data.worker.abandonPersistedUriPermissionLease
+import com.m3u.data.worker.beginPersistedUriPermissionLease
+import com.m3u.extension.api.ExtensionSettingType
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.benchmark.DebugBenchmarkSettings
+import com.m3u.smartphone.ui.business.setting.components.LocalStorageButton
+import com.m3u.smartphone.ui.business.setting.components.LocalStorageSwitch
+import com.m3u.smartphone.ui.business.setting.components.RemoteControlSubscribeSwitch
+import com.m3u.smartphone.ui.common.helper.LocalHelper
+import com.m3u.smartphone.ui.material.ktx.plus
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.UiBidiFormatter
+import com.m3u.smartphone.ui.material.model.LocalSpacing
+import kotlinx.coroutines.launch
+
+@Composable
+context(properties: SettingProperties)
+internal fun SubscriptionEditorScreen(
+ dataOperationInProgress: Boolean,
+ subscriptionSubmissionBlocked: Boolean,
+ sourceKey: String,
+ draftKey: String,
+ providerId: String?,
+ providerKind: String?,
+ reauthenticationPlaylistUrl: String?,
+ onClipboard: (String) -> Unit,
+ onBeginSubscriptionDraft: (String, DataSource) -> Unit,
+ onSubscribe: () -> Unit,
+ providerDiscoveryState: ProviderDiscoveryState,
+ providerSubscriptionForm: ProviderSubscriptionForm?,
+ providerOperationState: ProviderOperationState,
+ onSelectSubscriptionProviderVariant: (String, String) -> Unit,
+ onUpdateSubscriptionProviderSetting: (String, String?) -> Unit,
+ onRetryProviderDiscovery: () -> Unit,
+ onRetryProviderReauthentication: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ val spacing = LocalSpacing.current
+ val clipboard = LocalClipboard.current
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+ val helper = LocalHelper.current
+ val remoteControl by preferenceOf(PreferencesKeys.REMOTE_CONTROL)
+ val operationInProgress =
+ providerOperationState.isBusy ||
+ dataOperationInProgress ||
+ subscriptionSubmissionBlocked
+ val loadingStateDescription = stringResource(string.ui_state_loading)
+ val fileAccessFailure = stringResource(string.feat_setting_playlist_file_access_failed)
+ var submissionAttempted by rememberSaveable(draftKey) {
+ mutableStateOf(false)
+ }
+ var draftReady by remember(draftKey) {
+ mutableStateOf(reauthenticationPlaylistUrl != null)
+ }
+ val bidiFormatter = rememberUiBidiFormatter()
+ val ordinarySource = ordinarySubscriptionSourceOrNull(sourceKey)
+ val providerSources = providerDiscoveryState.subscriptionSources()
+ val providerSource = providerSources.firstOrNull { source ->
+ source.subscriptionSelectionKey() == sourceKey
+ }
+ val expectedProviderId = providerId ?: providerSource?.providerId?.value
+ val expectedProviderKind = providerKind ?: providerSource?.providerKind?.value
+ val discoveredProvider =
+ (providerDiscoveryState as? ProviderDiscoveryState.Ready)
+ ?.providers
+ .orEmpty()
+ .firstOrNull { provider ->
+ provider.descriptor.providerId.value == expectedProviderId
+ }
+ val discoveredVariant = discoveredProvider
+ ?.descriptor
+ ?.variants
+ ?.firstOrNull { variant -> variant.kind.value == expectedProviderKind }
+ val matchingProviderForm = providerSubscriptionForm?.takeIf { form ->
+ expectedProviderId != null &&
+ expectedProviderKind != null &&
+ form.providerId.value == expectedProviderId &&
+ form.providerKind.value == expectedProviderKind &&
+ form.reauthenticationPlaylistUrl == reauthenticationPlaylistUrl
+ }
+ val isReauthentication = reauthenticationPlaylistUrl != null
+ val editorSource = when {
+ ordinarySource != null -> ordinarySource
+ sourceKey.startsWith(PROVIDER_SOURCE_PREFIX) -> DataSource.Provider
+ else -> properties.selectedState.value
+ }
+ val providerSubmissionInProgress = providerOperationState.submission?.let { submission ->
+ editorSource == DataSource.Provider &&
+ submission.providerId.value == expectedProviderId &&
+ submission.providerKind.value == expectedProviderKind &&
+ submission.reauthenticationPlaylistUrl == reauthenticationPlaylistUrl
+ } == true
+ val sourceLabel = if (editorSource == DataSource.Provider) {
+ discoveredVariant?.displayName
+ ?.let(bidiFormatter::natural)
+ ?.takeIf(String::isNotBlank)
+ ?: stringResource(DataSource.Provider.resId)
+ } else {
+ stringResource(editorSource.resId)
+ }
+ val sourceSupportingName = when (editorSource) {
+ DataSource.M3U -> stringResource(
+ string.feat_setting_playlist_source_m3u_description
+ )
+ DataSource.EPG -> stringResource(
+ string.feat_setting_playlist_source_epg_description
+ )
+ DataSource.Xtream -> stringResource(
+ string.feat_setting_playlist_source_xtream_description
+ )
+ DataSource.Provider -> discoveredProvider?.descriptor?.displayName
+ ?.let(bidiFormatter::natural)
+ ?.takeUnless { name -> name.isBlank() || name == sourceLabel }
+ else -> null
+ }
+ val externalProviderIdentity = expectedProviderId
+ ?.takeIf {
+ editorSource == DataSource.Provider &&
+ (
+ providerSource?.executionKind ==
+ SubscriptionProviderExecutionKind.EXTERNAL ||
+ discoveredProvider?.executionKind ==
+ SubscriptionProviderExecutionKind.EXTERNAL
+ )
+ }
+ ?.let(bidiFormatter::ltr)
+ val sourceSupporting = externalProviderIdentity?.let { stableProviderId ->
+ stringResource(
+ string.feat_setting_provider_choice_with_identifier,
+ sourceSupportingName ?: sourceLabel,
+ stableProviderId,
+ )
+ } ?: sourceSupportingName
+ val sourceSupportingContentDescription =
+ externalProviderIdentity?.let { stableProviderId ->
+ stringResource(
+ string.feat_setting_provider_choice_with_identifier_description,
+ sourceSupportingName ?: sourceLabel,
+ stableProviderId,
+ )
+ }
+ val sourceIcon = when (editorSource) {
+ DataSource.M3U -> Icons.Rounded.Link
+ DataSource.EPG -> Icons.Rounded.DateRange
+ DataSource.Xtream -> Icons.Rounded.Cloud
+ DataSource.Provider -> Icons.Rounded.Extension
+ else -> Icons.Rounded.Link
+ }
+ val showsLocalStorageOption = editorSource == DataSource.M3U
+ val showsRemoteTvOption =
+ remoteControl && editorSource in REMOTE_TV_SUBSCRIPTION_SOURCES
+ val localFileReady =
+ !properties.localStorageState.value || properties.uriState.value != Uri.EMPTY
+ val editorInputReady = properties.titleState.value.isNotBlank() && when (editorSource) {
+ DataSource.M3U -> if (properties.localStorageState.value) {
+ localFileReady
+ } else {
+ properties.urlState.value.isNotBlank()
+ }
+ DataSource.EPG -> properties.epgState.value.isNotBlank()
+ DataSource.Xtream ->
+ properties.basicUrlState.value.isNotBlank() &&
+ properties.usernameState.value.isNotBlank() &&
+ properties.passwordState.value.isNotBlank()
+ DataSource.Provider -> providerDiscoveryState.supports(matchingProviderForm)
+ else -> false
+ }
+
+ LaunchedEffect(
+ draftKey,
+ sourceKey,
+ reauthenticationPlaylistUrl,
+ providerSource?.providerId,
+ providerSource?.providerKind,
+ ) {
+ if (!isReauthentication) {
+ onBeginSubscriptionDraft(draftKey, editorSource)
+ if (providerSource != null) {
+ onSelectSubscriptionProviderVariant(
+ providerSource.providerId.value,
+ providerSource.providerKind.value,
+ )
+ }
+ } else if (ordinarySource != null) {
+ properties.selectedState.value = ordinarySource
+ }
+ draftReady = true
+ }
+ LaunchedEffect(editorSource) {
+ if (editorSource != DataSource.M3U) {
+ properties.localStorageState.value = false
+ }
+ if (editorSource !in REMOTE_TV_SUBSCRIPTION_SOURCES) {
+ properties.forTvState.value = false
+ }
+ }
+
+ if (!draftReady) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .fillMaxSize()
+ .padding(contentPadding),
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(32.dp)
+ .clearAndSetSemantics {
+ stateDescription = loadingStateDescription
+ },
+ strokeWidth = 3.dp,
+ )
+ }
+ return
+ }
+
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(spacing.small),
+ contentPadding = contentPadding + PaddingValues(vertical = spacing.medium),
+ modifier = modifier
+ .testTag("playlist-editor:$sourceKey")
+ .imePadding()
+ ) {
+ item(key = "source") {
+ SubscriptionEditorPageContent {
+ SubscriptionSourceSummary(
+ label = sourceLabel,
+ supporting = sourceSupporting,
+ supportingContentDescription =
+ sourceSupportingContentDescription,
+ icon = sourceIcon,
+ )
+ }
+ }
+
+ item(key = "form") {
+ SubscriptionEditorPageContent {
+ SubscriptionEditorSection {
+ when (editorSource) {
+ DataSource.M3U -> M3UInputContent(
+ enabled = !operationInProgress,
+ showErrors = submissionAttempted,
+ )
+ DataSource.EPG -> EPGInputContent(
+ enabled = !operationInProgress,
+ showErrors = submissionAttempted,
+ )
+ DataSource.Xtream -> XtreamInputContent(
+ enabled = !operationInProgress,
+ showErrors = submissionAttempted,
+ )
+ DataSource.Provider -> DynamicProviderInputContent(
+ discoveryState = providerDiscoveryState,
+ form = matchingProviderForm,
+ onUpdateField = onUpdateSubscriptionProviderSetting,
+ onRetry = {
+ reauthenticationPlaylistUrl?.let(
+ onRetryProviderReauthentication
+ ) ?: onRetryProviderDiscovery()
+ },
+ preparing = reauthenticationPlaylistUrl?.let(
+ providerOperationState::isReauthenticating
+ ) == true,
+ enabled = !operationInProgress,
+ showErrors = submissionAttempted,
+ )
+ else -> Unit
+ }
+ }
+ }
+ }
+
+ if (operationInProgress && !providerSubmissionInProgress) {
+ item(key = "maintenance") {
+ SubscriptionEditorPageContent {
+ PlaylistMaintenanceNotice()
+ }
+ }
+ }
+
+ if (showsLocalStorageOption || showsRemoteTvOption) {
+ item(key = "options") {
+ SubscriptionEditorPageContent {
+ SubscriptionEditorSection {
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.small)) {
+ if (showsLocalStorageOption) {
+ LocalStorageSwitch(
+ checked = properties.localStorageState.value,
+ onChanged = { properties.localStorageState.value = it },
+ enabled = !properties.forTvState.value &&
+ !operationInProgress,
+ )
+ }
+ if (showsRemoteTvOption) {
+ RemoteControlSubscribeSwitch(
+ checked = properties.forTvState.value,
+ onChanged = {
+ properties.forTvState.value =
+ !properties.forTvState.value
+ },
+ enabled = (
+ editorSource != DataSource.M3U ||
+ !properties.localStorageState.value
+ ) && !operationInProgress,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ item(key = "submit") {
+ @SuppressLint("InlinedApi")
+ val postNotificationPermission = rememberPermissionState(
+ Manifest.permission.POST_NOTIFICATIONS
+ )
+ SubscriptionEditorPageContent {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(spacing.small),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Button(
+ modifier = Modifier
+ .weight(1f)
+ .heightIn(min = 48.dp)
+ .testTag("subscription-submit-action")
+ .semantics {
+ if (providerSubmissionInProgress) {
+ liveRegion = LiveRegionMode.Polite
+ stateDescription = loadingStateDescription
+ }
+ },
+ enabled = !operationInProgress && (
+ editorSource != DataSource.Provider ||
+ editorInputReady
+ ),
+ onClick = {
+ submissionAttempted = true
+ if (
+ editorSource != DataSource.Provider &&
+ !editorInputReady
+ ) {
+ return@Button
+ }
+ if (
+ (
+ editorSource == DataSource.M3U ||
+ editorSource == DataSource.Xtream
+ ) &&
+ Build.VERSION.SDK_INT >=
+ Build.VERSION_CODES.TIRAMISU &&
+ postNotificationPermission.status
+ is PermissionStatus.Denied
+ ) {
+ postNotificationPermission.launchPermissionRequest()
+ }
+ val needsLocalFileGrant =
+ editorSource == DataSource.M3U &&
+ properties.localStorageState.value &&
+ editorInputReady
+ if (needsLocalFileGrant) {
+ val permissionTag =
+ beginPersistedUriPermissionLease(
+ helper.activityContext,
+ properties.uriState.value,
+ )
+ val accessPersisted = runCatching {
+ helper.activityContext.contentResolver
+ .takePersistableUriPermission(
+ properties.uriState.value,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION,
+ )
+ }.isSuccess
+ if (!accessPersisted) {
+ abandonPersistedUriPermissionLease(
+ helper.activityContext,
+ permissionTag,
+ )
+ Toast.makeText(
+ helper.activityContext,
+ fileAccessFailure,
+ Toast.LENGTH_LONG,
+ ).show()
+ return@Button
+ }
+ }
+ onSubscribe()
+ }
+ ) {
+ if (providerSubmissionInProgress) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(18.dp)
+ .testTag("provider-subscription-progress"),
+ color = LocalContentColor.current,
+ strokeWidth = 2.dp,
+ )
+ Spacer(Modifier.size(8.dp))
+ }
+ Text(
+ stringResource(
+ if (isReauthentication) {
+ string.feat_setting_provider_reauthenticate
+ } else if (providerSubmissionInProgress) {
+ string.feat_setting_label_subscribing
+ } else {
+ string.feat_setting_label_subscribe
+ }
+ )
+ )
+ }
+ when {
+ editorSource == DataSource.Xtream ||
+ (
+ editorSource == DataSource.M3U &&
+ !properties.localStorageState.value
+ ) -> {
+ FilledTonalIconButton(
+ enabled = !operationInProgress,
+ onClick = {
+ coroutineScope.launch {
+ val clipData = clipboard.getClipEntry()?.clipData
+ val text = if (clipData != null && clipData.itemCount > 0) {
+ clipData.getItemAt(0).coerceToText(context).toString()
+ } else {
+ ""
+ }
+ onClipboard(text)
+ }
+ },
+ modifier = Modifier.size(48.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.ContentPaste,
+ contentDescription = stringResource(
+ string.feat_setting_label_parse_from_clipboard
+ )
+ )
+ }
+ }
+
+ else -> Unit
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SubscriptionEditorPageContent(
+ content: @Composable () -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = 640.dp)
+ .fillMaxWidth(),
+ ) {
+ content()
+ }
+ }
+}
+
+@Composable
+private fun SubscriptionSourceSummary(
+ label: String,
+ supporting: String?,
+ supportingContentDescription: String?,
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Surface(
+ modifier = Modifier.size(48.dp),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.titleLarge.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ supporting?.let { text ->
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier = supportingContentDescription?.let { description ->
+ Modifier.clearAndSetSemantics {
+ contentDescription = description
+ }
+ } ?: Modifier,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SubscriptionEditorSection(
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ content()
+ }
+ }
+}
+
+@Composable
+internal fun ProviderReauthenticationCard(
+ account: ProviderAccountSummary,
+ inProgress: Boolean,
+ enabled: Boolean,
+ onReauthenticate: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val spacing = LocalSpacing.current
+ val bidiFormatter = rememberUiBidiFormatter()
+ val loadingStateDescription = stringResource(string.ui_state_loading)
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .testTag("provider-reauthentication"),
+ shape = MaterialTheme.shapes.extraLarge,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(spacing.medium),
+ verticalArrangement = Arrangement.spacedBy(spacing.small),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(spacing.small),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(Icons.Rounded.Warning, contentDescription = null)
+ Text(
+ text = stringResource(
+ string.feat_setting_provider_reauthentication_required,
+ bidiFormatter.natural(account.playlistTitle),
+ ),
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ Text(
+ text = stringResource(
+ string.feat_setting_provider_account_summary,
+ bidiFormatter.natural(account.serverName),
+ bidiFormatter.natural(account.username),
+ bidiFormatter.ltr(account.baseUrl),
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ if (account.requiresExtensionOwnerConfirmation) {
+ Text(
+ text = stringResource(
+ string.feat_setting_provider_owner_claim_notice
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ FilledTonalButton(
+ onClick = onReauthenticate,
+ enabled = enabled && !inProgress,
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag("provider-reauthenticate-action")
+ .semantics {
+ if (inProgress) {
+ liveRegion = LiveRegionMode.Polite
+ stateDescription = loadingStateDescription
+ }
+ },
+ ) {
+ if (inProgress) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(18.dp)
+ .testTag("provider-reauthentication-progress"),
+ color = LocalContentColor.current,
+ strokeWidth = 2.dp,
+ )
+ Spacer(Modifier.size(8.dp))
+ }
+ Text(stringResource(string.feat_setting_provider_reauthenticate))
+ }
+ }
+ }
+}
+
+@Composable
+context(properties: SettingProperties)
+private fun M3UInputContent(
+ enabled: Boolean,
+ showErrors: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val spacing = LocalSpacing.current
+ val context = LocalContext.current
+ val titleError = stringResource(string.feat_setting_error_empty_title)
+ .takeIf { showErrors && properties.titleState.value.isBlank() }
+ val urlError = stringResource(string.feat_setting_error_blank_url)
+ .takeIf {
+ showErrors &&
+ !properties.localStorageState.value &&
+ properties.urlState.value.isBlank()
+ }
+ val fileError = stringResource(string.feat_setting_error_unselected_file)
+ .takeIf {
+ showErrors &&
+ properties.localStorageState.value &&
+ properties.uriState.value == Uri.EMPTY
+ }
+ LaunchedEffect(Unit) {
+ properties.applyBenchmarkPlaylistPrefill(DebugBenchmarkSettings.from(context))
+ }
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(spacing.small)
+ ) {
+ PlaylistOutlinedTextField(
+ value = properties.titleState.value,
+ label = stringResource(string.feat_setting_placeholder_title),
+ onValueChange = { properties.titleState.value = it },
+ enabled = enabled,
+ errorMessage = titleError,
+ imeAction = ImeAction.Next,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Crossfade(
+ targetState = properties.localStorageState.value,
+ label = "url"
+ ) { localStorage ->
+ if (!localStorage) {
+ PlaylistOutlinedTextField(
+ value = properties.urlState.value,
+ label = stringResource(string.feat_setting_placeholder_url),
+ onValueChange = { properties.urlState.value = it },
+ enabled = enabled,
+ errorMessage = urlError,
+ keyboardType = KeyboardType.Uri,
+ textDirection = TextDirection.Ltr,
+ modifier = Modifier.fillMaxWidth()
+ )
+ } else {
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.extraSmall)) {
+ LocalStorageButton(
+ titleState = properties.titleState,
+ uriState = properties.uriState,
+ enabled = enabled,
+ )
+ fileError?.let { message ->
+ PlaylistFieldError(message)
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun SettingProperties.applyBenchmarkPlaylistPrefill(settings: DebugBenchmarkSettings) {
+ settings.getString(DebugBenchmarkSettings.PLAYLIST_TITLE)
+ ?.let { titleState.value = it }
+ settings.getString(DebugBenchmarkSettings.PLAYLIST_URL)
+ ?.let { urlState.value = it }
+}
+
+@Composable
+private fun PlaylistOutlinedTextField(
+ value: String,
+ label: String,
+ onValueChange: (String) -> Unit,
+ enabled: Boolean,
+ modifier: Modifier = Modifier,
+ errorMessage: String? = null,
+ keyboardType: KeyboardType = KeyboardType.Text,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ textDirection: TextDirection = TextDirection.ContentOrLtr,
+ imeAction: ImeAction = ImeAction.Done,
+) {
+ val focusManager = LocalFocusManager.current
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ enabled = enabled,
+ singleLine = true,
+ isError = errorMessage != null,
+ label = {
+ Text(
+ text = label,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ supportingText = errorMessage?.let { message ->
+ {
+ Text(text = message)
+ }
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = keyboardType,
+ imeAction = imeAction,
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = {
+ focusManager.moveFocus(FocusDirection.Down)
+ },
+ onDone = {
+ focusManager.clearFocus()
+ },
+ ),
+ visualTransformation = visualTransformation,
+ textStyle = LocalTextStyle.current.copy(textDirection = textDirection),
+ shape = MaterialTheme.shapes.large,
+ modifier = modifier.semantics {
+ errorMessage?.let { message -> error(message) }
+ },
+ )
+}
+
+@Composable
+private fun PlaylistFieldError(
+ message: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = modifier.semantics { error(message) },
+ )
+}
+
+@Composable
+private fun PlaylistMaintenanceNotice(
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ ) {
+ Row(
+ modifier = Modifier
+ .heightIn(min = 56.dp)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(20.dp)
+ .clearAndSetSemantics {},
+ strokeWidth = 2.dp,
+ )
+ Text(
+ text = stringResource(
+ string.feat_setting_playlist_maintenance_in_progress
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier
+ .weight(1f)
+ .semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ }
+ }
+}
+
+@Composable
+context(properties: SettingProperties)
+private fun EPGInputContent(
+ enabled: Boolean,
+ showErrors: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val spacing = LocalSpacing.current
+ val titleError = stringResource(string.feat_setting_error_empty_epg_title)
+ .takeIf { showErrors && properties.titleState.value.isBlank() }
+ val epgError = stringResource(string.feat_setting_error_empty_epg)
+ .takeIf { showErrors && properties.epgState.value.isBlank() }
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(spacing.small)
+ ) {
+ PlaylistOutlinedTextField(
+ value = properties.titleState.value,
+ label = stringResource(string.feat_setting_placeholder_epg_title),
+ onValueChange = { properties.titleState.value = it },
+ enabled = enabled,
+ errorMessage = titleError,
+ imeAction = ImeAction.Next,
+ modifier = Modifier.fillMaxWidth()
+ )
+ PlaylistOutlinedTextField(
+ value = properties.epgState.value,
+ label = stringResource(string.feat_setting_placeholder_epg),
+ onValueChange = { properties.epgState.value = it },
+ enabled = enabled,
+ errorMessage = epgError,
+ keyboardType = KeyboardType.Uri,
+ textDirection = TextDirection.Ltr,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+@Composable
+context(properties: SettingProperties)
+private fun XtreamInputContent(
+ enabled: Boolean,
+ showErrors: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val spacing = LocalSpacing.current
+ val titleError = stringResource(string.feat_setting_error_empty_title)
+ .takeIf { showErrors && properties.titleState.value.isBlank() }
+ val urlError = stringResource(string.feat_setting_error_blank_url)
+ .takeIf { showErrors && properties.basicUrlState.value.isBlank() }
+ val requiredError = stringResource(string.feat_setting_provider_error_required)
+ val usernameError = requiredError.takeIf {
+ showErrors && properties.usernameState.value.isBlank()
+ }
+ val passwordError = requiredError.takeIf {
+ showErrors && properties.passwordState.value.isBlank()
+ }
+
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(spacing.small)
+ ) {
+ PlaylistOutlinedTextField(
+ value = properties.titleState.value,
+ label = stringResource(string.feat_setting_placeholder_title),
+ onValueChange = { properties.titleState.value = it },
+ enabled = enabled,
+ errorMessage = titleError,
+ imeAction = ImeAction.Next,
+ modifier = Modifier.fillMaxWidth()
+ )
+ PlaylistOutlinedTextField(
+ value = properties.basicUrlState.value,
+ label = stringResource(string.feat_setting_placeholder_basic_url),
+ onValueChange = { properties.basicUrlState.value = it },
+ enabled = enabled,
+ errorMessage = urlError,
+ keyboardType = KeyboardType.Uri,
+ textDirection = TextDirection.Ltr,
+ imeAction = ImeAction.Next,
+ modifier = Modifier.fillMaxWidth()
+ )
+ PlaylistOutlinedTextField(
+ value = properties.usernameState.value,
+ label = stringResource(string.feat_setting_placeholder_username),
+ onValueChange = { properties.usernameState.value = it },
+ enabled = enabled,
+ errorMessage = usernameError,
+ imeAction = ImeAction.Next,
+ modifier = Modifier.fillMaxWidth()
+ )
+ PlaylistOutlinedTextField(
+ value = properties.passwordState.value,
+ label = stringResource(string.feat_setting_placeholder_password),
+ onValueChange = { properties.passwordState.value = it },
+ enabled = enabled,
+ errorMessage = passwordError,
+ keyboardType = KeyboardType.Password,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth()
+ )
+ Warning(stringResource(string.feat_setting_warning_xtream_takes_much_more_time))
+ }
+}
+
+@Composable
+context(properties: SettingProperties)
+private fun DynamicProviderInputContent(
+ discoveryState: ProviderDiscoveryState,
+ form: ProviderSubscriptionForm?,
+ onUpdateField: (String, String?) -> Unit,
+ onRetry: () -> Unit,
+ preparing: Boolean,
+ enabled: Boolean,
+ showErrors: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val spacing = LocalSpacing.current
+ val bidiFormatter = rememberUiBidiFormatter()
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(spacing.small),
+ ) {
+ PlaylistOutlinedTextField(
+ value = properties.titleState.value,
+ label = stringResource(string.feat_setting_placeholder_title),
+ onValueChange = { properties.titleState.value = it },
+ enabled = enabled,
+ errorMessage = stringResource(string.feat_setting_error_empty_title)
+ .takeIf { showErrors && properties.titleState.value.isBlank() },
+ imeAction = if (form?.fields?.isNotEmpty() == true) {
+ ImeAction.Next
+ } else {
+ ImeAction.Done
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ when {
+ discoveryState is ProviderDiscoveryState.Loading || preparing -> {
+ ProviderDiscoveryLoadingNotice()
+ }
+
+ form != null && !discoveryState.supports(form) -> {
+ ProviderDiscoveryRetryNotice(
+ message = stringResource(
+ string.feat_setting_provider_selected_unavailable
+ ),
+ onRetry = onRetry,
+ enabled = enabled,
+ testTag = "provider-selected-unavailable",
+ )
+ }
+
+ discoveryState is ProviderDiscoveryState.Empty -> {
+ ProviderDiscoveryRetryNotice(
+ message = stringResource(string.feat_setting_provider_discovery_empty),
+ onRetry = onRetry,
+ enabled = enabled,
+ testTag = "provider-discovery-empty",
+ )
+ }
+
+ discoveryState is ProviderDiscoveryState.Failed -> {
+ ProviderDiscoveryRetryNotice(
+ message = stringResource(string.feat_setting_provider_discovery_failed),
+ onRetry = onRetry,
+ enabled = enabled,
+ testTag = "provider-discovery-failed",
+ )
+ }
+
+ form == null -> {
+ ProviderDiscoveryRetryNotice(
+ message = stringResource(
+ string.feat_setting_provider_selected_unavailable
+ ),
+ onRetry = onRetry,
+ enabled = enabled,
+ testTag = "provider-selected-unavailable",
+ )
+ }
+ }
+ form?.fields?.forEachIndexed { index, field ->
+ ProviderFormField(
+ field = field,
+ bidiFormatter = bidiFormatter,
+ enabled = enabled,
+ isLast = index == form.fields.lastIndex,
+ onUpdate = { value -> onUpdateField(field.definition.key, value) },
+ )
+ }
+ }
+}
+
+@Composable
+internal fun ProviderDiscoveryLoadingNotice(
+ modifier: Modifier = Modifier,
+) {
+ val spacing = LocalSpacing.current
+ Row(
+ modifier = modifier.testTag("provider-discovery-loading"),
+ horizontalArrangement = Arrangement.spacedBy(spacing.small),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ Text(
+ text = stringResource(string.feat_setting_provider_discovery_loading),
+ modifier = Modifier.semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ }
+}
+
+@Composable
+internal fun ProviderDiscoveryRetryNotice(
+ message: String,
+ onRetry: () -> Unit,
+ enabled: Boolean,
+ testTag: String,
+ modifier: Modifier = Modifier,
+) {
+ val spacing = LocalSpacing.current
+ Column(
+ modifier = modifier.testTag(testTag),
+ verticalArrangement = Arrangement.spacedBy(spacing.small),
+ ) {
+ Text(
+ text = message,
+ modifier = Modifier.semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ FilledTonalButton(
+ onClick = onRetry,
+ enabled = enabled,
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .testTag("provider-discovery-retry"),
+ ) {
+ Text(stringResource(string.feat_setting_provider_discovery_retry))
+ }
+ }
+}
+
+@Composable
+private fun ProviderFormField(
+ field: ProviderSubscriptionFormField,
+ bidiFormatter: UiBidiFormatter,
+ enabled: Boolean,
+ isLast: Boolean,
+ onUpdate: (String?) -> Unit,
+) {
+ val definition = field.definition
+ val spacing = LocalSpacing.current
+ val focusManager = LocalFocusManager.current
+ val errorMessage = field.error?.let { stringResource(it.messageResource()) }
+ val requiredDescription =
+ stringResource(string.feat_setting_provider_error_required)
+ Column(
+ modifier = Modifier.testTag("provider-field:${definition.key}"),
+ verticalArrangement = Arrangement.spacedBy(spacing.extraSmall),
+ ) {
+ val displayLabel = bidiFormatter.natural(definition.label)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(spacing.small),
+ verticalArrangement = Arrangement.spacedBy(spacing.extraSmall),
+ ) {
+ Text(
+ text = displayLabel,
+ style = MaterialTheme.typography.labelLarge.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ )
+ if (definition.required) {
+ Text(
+ text = requiredDescription,
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ }
+ definition.description?.let { description ->
+ Text(
+ bidiFormatter.natural(
+ value = description,
+ maximumCharacters = MAX_PROVIDER_DESCRIPTION_LENGTH,
+ ),
+ style = MaterialTheme.typography.bodySmall.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ )
+ }
+ when (definition.type) {
+ ExtensionSettingType.TEXT,
+ ExtensionSettingType.NUMBER,
+ ExtensionSettingType.SECRET -> {
+ val textDirection = if (
+ definition.type == ExtensionSettingType.NUMBER ||
+ definition.type == ExtensionSettingType.SECRET ||
+ definition.networkOrigin ||
+ definition.key.contains("url", ignoreCase = true) ||
+ definition.key.contains("origin", ignoreCase = true) ||
+ definition.key.contains("address", ignoreCase = true)
+ ) {
+ TextDirection.Ltr
+ } else {
+ TextDirection.ContentOrLtr
+ }
+ OutlinedTextField(
+ value = field.value.orEmpty(),
+ onValueChange = onUpdate,
+ enabled = enabled,
+ isError = errorMessage != null,
+ singleLine = true,
+ minLines = 1,
+ maxLines = 1,
+ textStyle = LocalTextStyle.current.copy(
+ textDirection = textDirection,
+ ),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = when (definition.type) {
+ ExtensionSettingType.NUMBER -> KeyboardType.Decimal
+ ExtensionSettingType.SECRET -> KeyboardType.Password
+ else -> KeyboardType.Text
+ },
+ imeAction = when {
+ isLast -> ImeAction.Done
+ else -> ImeAction.Next
+ },
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = {
+ focusManager.moveFocus(FocusDirection.Down)
+ },
+ onDone = {
+ focusManager.clearFocus()
+ },
+ ),
+ visualTransformation =
+ if (definition.type == ExtensionSettingType.SECRET) {
+ PasswordVisualTransformation()
+ } else {
+ VisualTransformation.None
+ },
+ shape = MaterialTheme.shapes.large,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics {
+ contentDescription = displayLabel
+ if (definition.required) {
+ stateDescription = requiredDescription
+ }
+ if (errorMessage != null) {
+ error(errorMessage)
+ }
+ },
+ )
+ }
+
+ ExtensionSettingType.BOOLEAN -> FlowRow(
+ modifier = Modifier
+ .selectableGroup()
+ .providerChoiceGroupSemantics(
+ fieldLabel = displayLabel,
+ requiredDescription = requiredDescription.takeIf {
+ definition.required
+ },
+ errorMessage = errorMessage,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(spacing.small),
+ verticalArrangement = Arrangement.spacedBy(spacing.small),
+ ) {
+ ProviderResetChoice(
+ field = field,
+ fieldLabel = displayLabel,
+ enabled = enabled,
+ onUpdate = onUpdate,
+ )
+ ProviderChoiceButton(
+ fieldLabel = displayLabel,
+ selected = field.value == "true" && !field.isUsingDefault,
+ enabled = enabled,
+ onClick = { onUpdate("true") },
+ text = stringResource(string.feat_setting_provider_value_true),
+ )
+ ProviderChoiceButton(
+ fieldLabel = displayLabel,
+ selected = field.value == "false" && !field.isUsingDefault,
+ enabled = enabled,
+ onClick = { onUpdate("false") },
+ text = stringResource(string.feat_setting_provider_value_false),
+ )
+ }
+
+ ExtensionSettingType.SINGLE_CHOICE -> FlowRow(
+ modifier = Modifier
+ .selectableGroup()
+ .providerChoiceGroupSemantics(
+ fieldLabel = displayLabel,
+ requiredDescription = requiredDescription.takeIf {
+ definition.required
+ },
+ errorMessage = errorMessage,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(spacing.small),
+ verticalArrangement = Arrangement.spacedBy(spacing.small),
+ ) {
+ ProviderResetChoice(
+ field = field,
+ fieldLabel = displayLabel,
+ enabled = enabled,
+ onUpdate = onUpdate,
+ )
+ definition.choices.forEach { choice ->
+ ProviderChoiceButton(
+ fieldLabel = displayLabel,
+ selected = field.value == choice.value && !field.isUsingDefault,
+ enabled = enabled,
+ onClick = { onUpdate(choice.value) },
+ text = bidiFormatter.natural(choice.label),
+ )
+ }
+ }
+ }
+ if (field.isUsingDefault) {
+ Text(
+ text = stringResource(
+ string.feat_setting_provider_default_value,
+ bidiFormatter.natural(field.value.orEmpty()),
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ errorMessage?.let { message ->
+ Text(
+ text = message,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.clearAndSetSemantics {},
+ )
+ }
+ }
+}
+
+@Composable
+private fun ProviderResetChoice(
+ field: ProviderSubscriptionFormField,
+ fieldLabel: String,
+ enabled: Boolean,
+ onUpdate: (String?) -> Unit,
+) {
+ if (field.definition.defaultValue != null || !field.definition.required) {
+ ProviderChoiceButton(
+ fieldLabel = fieldLabel,
+ selected = field.isUsingDefault || field.value == null,
+ enabled = enabled,
+ onClick = { onUpdate(null) },
+ text = stringResource(
+ if (field.definition.defaultValue == null) {
+ string.feat_setting_provider_value_not_set
+ } else {
+ string.feat_setting_provider_value_default
+ }
+ ),
+ )
+ }
+}
+
+@Composable
+private fun ProviderChoiceButton(
+ fieldLabel: String,
+ selected: Boolean,
+ enabled: Boolean = true,
+ onClick: () -> Unit,
+ text: String,
+) {
+ val containerColor = if (selected) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceContainer
+ }
+ val contentColor = if (selected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ val choiceDescription = stringResource(
+ string.feat_setting_extension_choice_field_description,
+ text,
+ fieldLabel,
+ )
+ Surface(
+ selected = selected,
+ onClick = onClick,
+ enabled = enabled,
+ shape = CircleShape,
+ color = containerColor,
+ contentColor = contentColor,
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .alpha(if (enabled) 1f else 0.38f)
+ .semantics {
+ role = Role.RadioButton
+ contentDescription = choiceDescription
+ },
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = if (selected) {
+ Icons.Rounded.CheckCircle
+ } else {
+ Icons.Rounded.RadioButtonUnchecked
+ },
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Text(text)
+ }
+ }
+}
+
+private fun Modifier.providerChoiceGroupSemantics(
+ fieldLabel: String,
+ requiredDescription: String?,
+ errorMessage: String?,
+): Modifier = semantics {
+ contentDescription = fieldLabel
+ requiredDescription?.let { description ->
+ stateDescription = description
+ }
+ errorMessage?.let { message ->
+ error(message)
+ }
+}
+
+private fun ProviderSettingFieldError.messageResource(): Int = when (this) {
+ ProviderSettingFieldError.REQUIRED -> string.feat_setting_provider_error_required
+ ProviderSettingFieldError.TOO_LONG -> string.feat_setting_provider_error_too_long
+ ProviderSettingFieldError.UNSAFE_VALUE -> string.feat_setting_provider_error_unsafe_value
+ ProviderSettingFieldError.INVALID_NUMBER -> string.feat_setting_provider_error_number
+ ProviderSettingFieldError.INVALID_BOOLEAN -> string.feat_setting_provider_error_boolean
+ ProviderSettingFieldError.INVALID_CHOICE -> string.feat_setting_provider_error_choice
+}
+
+private val REMOTE_TV_SUBSCRIPTION_SOURCES = setOf(
+ DataSource.M3U,
+ DataSource.EPG,
+ DataSource.Xtream,
+)
+
+private const val MAX_PROVIDER_DESCRIPTION_LENGTH = 1_024
+
+internal const val PROVIDER_SOURCE_PREFIX = "provider:"
+
+internal fun DataSource.subscriptionSelectionKey(): String = "data-source:$value"
+
+internal fun ProviderSubscriptionSource.subscriptionSelectionKey(): String =
+ providerSourceSelectionKey(providerId.value, providerKind.value)
+
+internal fun providerSourceSelectionKey(
+ providerId: String,
+ providerKind: String,
+): String = "$PROVIDER_SOURCE_PREFIX$providerId:$providerKind"
+
+internal fun ordinarySubscriptionSourceOrNull(sourceKey: String): DataSource? =
+ REMOTE_TV_SUBSCRIPTION_SOURCES.firstOrNull { source ->
+ source.subscriptionSelectionKey() == sourceKey
+ }
+
+@Composable
+private fun Warning(
+ text: String,
+ modifier: Modifier = Modifier
+) {
+ val spacing = LocalSpacing.current
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(spacing.small),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(12.dp),
+ ) {
+ Icon(imageVector = Icons.Rounded.Warning, contentDescription = null)
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionSourcePickerScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionSourcePickerScreen.kt
new file mode 100644
index 000000000..86bd270a4
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionSourcePickerScreen.kt
@@ -0,0 +1,382 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowForward
+import androidx.compose.material.icons.rounded.Cloud
+import androidx.compose.material.icons.rounded.Extension
+import androidx.compose.material.icons.rounded.Link
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.m3u.business.setting.ProviderDiscoveryState
+import com.m3u.business.setting.ProviderOperationState
+import com.m3u.business.setting.ProviderSubscriptionSource
+import com.m3u.business.setting.SettingProperties
+import com.m3u.business.setting.subscriptionSources
+import com.m3u.data.database.model.DataSource
+import com.m3u.data.repository.provider.SubscriptionProviderExecutionKind
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.material.ktx.plus
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
+
+private val SubscriptionPickerMaxWidth = 640.dp
+
+private data class SubscriptionSourceRow(
+ val key: String,
+ val label: String,
+ val supporting: String?,
+ val supportingContentDescription: String? = null,
+ val icon: ImageVector,
+ val ordinarySource: DataSource? = null,
+ val providerSource: ProviderSubscriptionSource? = null,
+)
+
+@Composable
+context(properties: SettingProperties)
+internal fun SubscriptionSourcePickerScreen(
+ dataOperationInProgress: Boolean,
+ subscriptionSubmissionBlocked: Boolean,
+ providerDiscoveryState: ProviderDiscoveryState,
+ providerOperationState: ProviderOperationState,
+ onSelectSubscriptionProviderVariant: (String, String) -> Unit,
+ onRetryProviderDiscovery: () -> Unit,
+ onOpenEditor: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ val bidiFormatter = rememberUiBidiFormatter()
+ val ordinarySources = listOf(
+ SubscriptionSourceRow(
+ key = DataSource.M3U.subscriptionSelectionKey(),
+ label = stringResource(DataSource.M3U.resId),
+ supporting = stringResource(
+ string.feat_setting_playlist_source_m3u_description
+ ),
+ icon = Icons.Rounded.Link,
+ ordinarySource = DataSource.M3U,
+ ),
+ SubscriptionSourceRow(
+ key = DataSource.Xtream.subscriptionSelectionKey(),
+ label = stringResource(DataSource.Xtream.resId),
+ supporting = stringResource(
+ string.feat_setting_playlist_source_xtream_description
+ ),
+ icon = Icons.Rounded.Cloud,
+ ordinarySource = DataSource.Xtream,
+ ),
+ )
+ val providerSources = providerDiscoveryState.subscriptionSources()
+ .map { source ->
+ val stableProviderId = bidiFormatter.ltr(source.providerId.value)
+ val variantName = bidiFormatter.natural(source.displayName)
+ .ifBlank { bidiFormatter.natural(source.providerDisplayName) }
+ .ifBlank { stableProviderId }
+ val providerName = bidiFormatter.natural(source.providerDisplayName)
+ .ifBlank { stableProviderId }
+ val discloseStableIdentity =
+ source.executionKind == SubscriptionProviderExecutionKind.EXTERNAL
+ val supporting = if (discloseStableIdentity) {
+ stringResource(
+ string.feat_setting_provider_choice_with_identifier,
+ providerName,
+ stableProviderId,
+ )
+ } else {
+ providerName.takeUnless { name -> name == variantName }
+ }
+ SubscriptionSourceRow(
+ key = source.subscriptionSelectionKey(),
+ label = variantName,
+ supporting = supporting,
+ supportingContentDescription = if (discloseStableIdentity) {
+ stringResource(
+ string.feat_setting_provider_choice_with_identifier_description,
+ providerName,
+ stableProviderId,
+ )
+ } else {
+ null
+ },
+ icon = Icons.Rounded.Extension,
+ providerSource = source,
+ )
+ }
+ val enabled = !providerOperationState.isBusy &&
+ !dataOperationInProgress &&
+ !subscriptionSubmissionBlocked
+ val providerNotice =
+ if (
+ providerDiscoveryState is ProviderDiscoveryState.Ready &&
+ providerSources.isEmpty()
+ ) {
+ ProviderDiscoveryNotice.EMPTY
+ } else {
+ providerDiscoveryNotice(
+ state = providerDiscoveryState,
+ providerSelected = false,
+ )
+ }
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .testTag("playlist-source-picker"),
+ contentPadding = contentPadding + PaddingValues(vertical = 16.dp),
+ ) {
+ item(key = "description") {
+ SubscriptionPickerPageContent {
+ Text(
+ text = stringResource(
+ string.feat_setting_playlist_source_picker_description
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ }
+ }
+ item(key = "built-in-heading") {
+ SubscriptionPickerPageContent {
+ SubscriptionPickerHeading(
+ text = stringResource(
+ string.feat_setting_playlist_built_in_sources
+ )
+ )
+ }
+ }
+ item(key = "built-in-sources") {
+ SubscriptionPickerPageContent {
+ SubscriptionSourceGroup(
+ sources = ordinarySources,
+ enabled = enabled,
+ onClick = { source ->
+ properties.selectedState.value =
+ requireNotNull(source.ordinarySource)
+ onOpenEditor(source.key)
+ },
+ )
+ }
+ }
+ item(key = "providers-heading") {
+ SubscriptionPickerPageContent {
+ SubscriptionPickerHeading(
+ text = stringResource(string.feat_setting_playlist_providers)
+ )
+ }
+ }
+ if (providerSources.isNotEmpty()) {
+ item(key = "provider-sources") {
+ SubscriptionPickerPageContent {
+ SubscriptionSourceGroup(
+ sources = providerSources,
+ enabled = enabled,
+ onClick = { source ->
+ val provider = requireNotNull(source.providerSource)
+ properties.selectedState.value = DataSource.Provider
+ onSelectSubscriptionProviderVariant(
+ provider.providerId.value,
+ provider.providerKind.value,
+ )
+ onOpenEditor(source.key)
+ },
+ )
+ }
+ }
+ }
+ when (providerNotice) {
+ ProviderDiscoveryNotice.NONE -> Unit
+ ProviderDiscoveryNotice.LOADING -> {
+ item(key = "providers-loading") {
+ SubscriptionPickerPageContent {
+ ProviderDiscoveryLoadingNotice(
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+ }
+ ProviderDiscoveryNotice.EMPTY -> {
+ item(key = "providers-empty") {
+ SubscriptionPickerPageContent {
+ ProviderDiscoveryRetryNotice(
+ message = stringResource(
+ string.feat_setting_provider_discovery_empty
+ ),
+ onRetry = onRetryProviderDiscovery,
+ enabled = enabled,
+ testTag = "provider-discovery-empty",
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ }
+ }
+ ProviderDiscoveryNotice.FAILED -> {
+ item(key = "providers-failed") {
+ SubscriptionPickerPageContent {
+ ProviderDiscoveryRetryNotice(
+ message = stringResource(
+ string.feat_setting_provider_discovery_failed
+ ),
+ onRetry = onRetryProviderDiscovery,
+ enabled = enabled,
+ testTag = "provider-discovery-failed",
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SubscriptionSourceGroup(
+ sources: List,
+ enabled: Boolean,
+ onClick: (SubscriptionSourceRow) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column {
+ sources.forEachIndexed { index, source ->
+ SubscriptionSourceListItem(
+ source = source,
+ enabled = enabled,
+ onClick = { onClick(source) },
+ )
+ if (index != sources.lastIndex) {
+ SubscriptionPickerDivider()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SubscriptionSourceListItem(
+ source: SubscriptionSourceRow,
+ enabled: Boolean,
+ onClick: () -> Unit,
+) {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = source.label,
+ style = MaterialTheme.typography.titleMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ supportingContent = source.supporting?.let { supporting ->
+ {
+ Text(
+ text = supporting,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ modifier = source.supportingContentDescription?.let { description ->
+ Modifier.clearAndSetSemantics {
+ contentDescription = description
+ }
+ } ?: Modifier,
+ )
+ }
+ },
+ leadingContent = {
+ Icon(imageVector = source.icon, contentDescription = null)
+ },
+ trailingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
+ contentDescription = null,
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .alpha(if (enabled) 1f else 0.38f)
+ .clickable(
+ enabled = enabled,
+ role = Role.Button,
+ onClick = onClick,
+ )
+ .testTag("playlist-source:${source.key}"),
+ )
+}
+
+@Composable
+private fun SubscriptionPickerHeading(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 8.dp)
+ .semantics { heading() },
+ )
+}
+
+@Composable
+private fun SubscriptionPickerDivider() {
+ HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
+}
+
+@Composable
+private fun SubscriptionPickerPageContent(
+ content: @Composable () -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = SubscriptionPickerMaxWidth)
+ .fillMaxWidth(),
+ verticalArrangement = Arrangement.Top,
+ ) {
+ content()
+ }
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionsFragment.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionsFragment.kt
deleted file mode 100644
index 49802dfb6..000000000
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionsFragment.kt
+++ /dev/null
@@ -1,500 +0,0 @@
-package com.m3u.smartphone.ui.business.setting.fragments
-
-import android.Manifest
-import android.annotation.SuppressLint
-import android.content.Intent
-import android.net.Uri
-import android.provider.Settings
-import androidx.compose.animation.Crossfade
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.PageSize
-import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.rounded.ContentPaste
-import androidx.compose.material.icons.rounded.Warning
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.FilledIconButton
-import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.LocalContentColor
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.unit.dp
-import com.google.accompanist.permissions.rememberPermissionState
-import com.m3u.business.setting.BackingUpAndRestoringState
-import com.m3u.business.setting.SettingProperties
-import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
-import com.m3u.core.foundation.architecture.preferences.preferenceOf
-import com.m3u.data.database.model.Channel
-import com.m3u.data.database.model.DataSource
-import com.m3u.data.database.model.Playlist
-import com.m3u.i18n.R.string
-import com.m3u.smartphone.benchmark.DebugBenchmarkSettings
-import com.m3u.smartphone.ui.business.setting.components.DataSourceSelection
-import com.m3u.smartphone.ui.business.setting.components.EpgPlaylistItem
-import com.m3u.smartphone.ui.business.setting.components.HiddenChannelItem
-import com.m3u.smartphone.ui.business.setting.components.HiddenPlaylistGroupItem
-import com.m3u.smartphone.ui.business.setting.components.LocalStorageButton
-import com.m3u.smartphone.ui.business.setting.components.LocalStorageSwitch
-import com.m3u.smartphone.ui.business.setting.components.RemoteControlSubscribeSwitch
-import com.m3u.smartphone.ui.common.helper.LocalHelper
-import com.m3u.smartphone.ui.material.components.HorizontalPagerIndicator
-import com.m3u.smartphone.ui.material.components.PlaceholderField
-import com.m3u.smartphone.ui.material.components.SelectionsDefaults
-import com.m3u.smartphone.ui.material.ktx.checkPermissionOrRationale
-import com.m3u.smartphone.ui.material.ktx.textHorizontalLabel
-import com.m3u.smartphone.ui.material.model.LocalSpacing
-
-private enum class SubscriptionsFragmentPage {
- MAIN, EPG_PLAYLISTS, HIDDEN_STREAMS, HIDDEN_PLAYLIST_CATEGORIES
-}
-
-@Composable
-context(_: SettingProperties)
-internal fun SubscriptionsFragment(
- backingUpOrRestoring: BackingUpAndRestoringState,
- hiddenChannels: List,
- hiddenCategoriesWithPlaylists: List>,
- onUnhideChannel: (Int) -> Unit,
- onUnhidePlaylistCategory: (playlistUrl: String, category: String) -> Unit,
- onClipboard: (String) -> Unit,
- onSubscribe: () -> Unit,
- backup: () -> Unit,
- restore: () -> Unit,
- epgs: List,
- onDeleteEpgPlaylist: (String) -> Unit,
- modifier: Modifier = Modifier,
- contentPadding: PaddingValues = PaddingValues()
-) {
- val spacing = LocalSpacing.current
- val pagerState = rememberPagerState(initialPage = 0) { SubscriptionsFragmentPage.entries.size }
-
- Box {
- HorizontalPager(
- state = pagerState,
- verticalAlignment = Alignment.Top,
- contentPadding = contentPadding,
- modifier = modifier,
- key = { SubscriptionsFragmentPage.entries[it] },
- pageSize = PageSize.Fill,
- pageSpacing = 1.dp
- ) { page ->
- when (SubscriptionsFragmentPage.entries[page]) {
- SubscriptionsFragmentPage.MAIN -> {
- MainContentImpl(
- backingUpOrRestoring = backingUpOrRestoring,
- onClipboard = onClipboard,
- onSubscribe = onSubscribe,
- backup = backup,
- restore = restore,
- modifier = Modifier.fillMaxSize()
- )
- }
-
- SubscriptionsFragmentPage.EPG_PLAYLISTS -> {
- EpgsContentImpl(
- epgs = epgs,
- onDeleteEpgPlaylist = onDeleteEpgPlaylist,
- modifier = Modifier.fillMaxSize()
- )
- }
-
- SubscriptionsFragmentPage.HIDDEN_STREAMS -> {
- HiddenStreamContentImpl(
- hiddenChannels = hiddenChannels,
- onUnhideChannel = onUnhideChannel,
- modifier = Modifier.fillMaxSize()
- )
- }
-
- SubscriptionsFragmentPage.HIDDEN_PLAYLIST_CATEGORIES -> {
- HiddenPlaylistCategoriesContentImpl(
- hiddenCategoriesWithPlaylists = hiddenCategoriesWithPlaylists,
- onUnhidePlaylistCategory = onUnhidePlaylistCategory,
- modifier = Modifier.fillMaxSize()
- )
- }
- }
- }
- HorizontalPagerIndicator(
- pagerState = pagerState,
- modifier = Modifier
- .align(Alignment.BottomCenter)
- .padding(contentPadding)
- .padding(spacing.medium)
- )
- }
-}
-
-@Composable
-context(properties: SettingProperties)
-private fun MainContentImpl(
- backingUpOrRestoring: BackingUpAndRestoringState,
- onClipboard: (String) -> Unit,
- onSubscribe: () -> Unit,
- backup: () -> Unit,
- restore: () -> Unit,
- modifier: Modifier = Modifier
-) {
- val spacing = LocalSpacing.current
- val clipboardManager = LocalClipboardManager.current
- val helper = LocalHelper.current
- val remoteControl by preferenceOf(PreferencesKeys.REMOTE_CONTROL)
-
- LazyColumn(
- verticalArrangement = Arrangement.spacedBy(spacing.small),
- contentPadding = PaddingValues(spacing.medium),
- modifier = modifier
- ) {
- item {
- DataSourceSelection(
- selectedState = properties.selectedState,
- supported = listOf(
- DataSource.M3U,
- DataSource.EPG,
- DataSource.Xtream,
- DataSource.Emby,
- DataSource.Dropbox
- )
- )
- }
-
- item {
- when (properties.selectedState.value) {
- DataSource.M3U -> M3UInputContent()
- DataSource.EPG -> EPGInputContent()
- DataSource.Xtream -> XtreamInputContent()
- DataSource.Emby -> {}
- DataSource.Dropbox -> {}
- }
- }
-
- item {
- Spacer(Modifier.size(spacing.medium))
- }
- item {
- if (properties.selectedState.value == DataSource.M3U) {
- LocalStorageSwitch(
- checked = properties.localStorageState.value,
- onChanged = { properties.localStorageState.value = it },
- enabled = !properties.forTvState.value
- )
- }
- if (remoteControl) {
- RemoteControlSubscribeSwitch(
- checked = properties.forTvState.value,
- onChanged = { properties.forTvState.value = !properties.forTvState.value },
- enabled = !properties.localStorageState.value
- )
- }
- }
- item {
- @SuppressLint("InlinedApi")
- val postNotificationPermission = rememberPermissionState(
- Manifest.permission.POST_NOTIFICATIONS
- )
- Column {
- Row {
- Button(
- modifier = Modifier.weight(1f),
- onClick = {
- postNotificationPermission.checkPermissionOrRationale(
- showRationale = {
- val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
- .apply {
- putExtra(
- Settings.EXTRA_APP_PACKAGE,
- helper.activityContext.packageName
- )
- }
- helper.activityContext.startActivity(intent)
- },
- block = {
- onSubscribe()
- }
- )
- }
- ) {
- Text(stringResource(string.feat_setting_label_subscribe).uppercase())
- }
- when (properties.selectedState.value) {
- DataSource.M3U, DataSource.Xtream -> {
- IconButton(
- onClick = {
- onClipboard(clipboardManager.getText()?.text.orEmpty())
- }
- ) {
- Icon(
- imageVector = Icons.Rounded.ContentPaste,
- contentDescription = null
- )
- }
- }
-
- else -> {}
- }
- }
- val backupText = stringResource(string.feat_setting_label_backup).uppercase()
- val restoreText = stringResource(string.feat_setting_label_restore).uppercase()
-
-
- TextButton(
- onClick = backup,
- enabled = backingUpOrRestoring == BackingUpAndRestoringState.NONE,
- modifier = Modifier.align(Alignment.CenterHorizontally)
- ) {
- Text(
- text = backupText
- )
- }
- TextButton(
- onClick = restore,
- enabled = backingUpOrRestoring == BackingUpAndRestoringState.NONE,
- modifier = Modifier.align(Alignment.CenterHorizontally)
- ) {
- Text(
- text = restoreText
- )
- }
- }
-
- }
-
- item {
- Spacer(Modifier.imePadding())
- }
- }
-}
-
-
-@Composable
-private fun EpgsContentImpl(
- epgs: List,
- onDeleteEpgPlaylist: (String) -> Unit,
- modifier: Modifier = Modifier
-) {
- Column(
- modifier = modifier.fillMaxWidth()
- ) {
- Text(
- text = stringResource(string.feat_setting_label_epg_playlists),
- color = MaterialTheme.colorScheme.onPrimary,
- style = MaterialTheme.typography.labelLarge,
- modifier = Modifier.textHorizontalLabel()
- )
- epgs.forEach { epgPlaylist ->
- EpgPlaylistItem(
- epgPlaylist = epgPlaylist,
- onDeleteEpgPlaylist = { onDeleteEpgPlaylist(epgPlaylist.url) }
- )
- }
- }
-}
-
-@Composable
-private fun HiddenStreamContentImpl(
- hiddenChannels: List,
- onUnhideChannel: (Int) -> Unit,
- modifier: Modifier = Modifier
-) {
- Column(
- modifier = modifier.fillMaxWidth()
- ) {
- Text(
- text = stringResource(string.feat_setting_label_hidden_channels),
- color = MaterialTheme.colorScheme.onPrimary,
- style = MaterialTheme.typography.labelLarge,
- modifier = Modifier.textHorizontalLabel()
- )
- hiddenChannels.forEach { channel ->
- HiddenChannelItem(
- channel = channel,
- onHidden = { onUnhideChannel(channel.id) }
- )
- }
- }
-}
-
-@Composable
-private fun HiddenPlaylistCategoriesContentImpl(
- hiddenCategoriesWithPlaylists: List>,
- onUnhidePlaylistCategory: (playlistUrl: String, category: String) -> Unit,
- modifier: Modifier = Modifier
-) {
- Column(modifier.fillMaxWidth()) {
- Text(
- text = stringResource(string.feat_setting_label_hidden_playlist_groups),
- color = MaterialTheme.colorScheme.onPrimary,
- style = MaterialTheme.typography.labelLarge,
- modifier = Modifier.textHorizontalLabel()
- )
- hiddenCategoriesWithPlaylists.forEach { (playlist, category) ->
- HiddenPlaylistGroupItem(
- playlist = playlist,
- group = category,
- onHidden = { onUnhidePlaylistCategory(playlist.url, category) }
- )
- }
- }
-}
-
-@Composable
-context(properties: SettingProperties)
-private fun M3UInputContent(
- modifier: Modifier = Modifier
-) {
- val spacing = LocalSpacing.current
- val context = LocalContext.current
- LaunchedEffect(Unit) {
- properties.applyBenchmarkPlaylistPrefill(DebugBenchmarkSettings.from(context))
- }
- Column(
- modifier = modifier,
- verticalArrangement = Arrangement.spacedBy(spacing.small)
- ) {
- PlaceholderField(
- text = properties.titleState.value,
- placeholder = stringResource(string.feat_setting_placeholder_title).uppercase(),
- onValueChange = { properties.titleState.value = Uri.decode(it) },
- imeAction = ImeAction.Next,
- modifier = Modifier.fillMaxWidth()
- )
- Crossfade(
- targetState = properties.localStorageState.value,
- label = "url"
- ) { localStorage ->
- if (!localStorage) {
- PlaceholderField(
- text = properties.urlState.value,
- placeholder = stringResource(string.feat_setting_placeholder_url).uppercase(),
- onValueChange = { properties.urlState.value = Uri.decode(it) },
- modifier = Modifier.fillMaxWidth()
- )
- } else {
- LocalStorageButton(
- titleState = properties.titleState,
- uriState = properties.uriState,
- )
- }
- }
- }
-}
-
-private fun SettingProperties.applyBenchmarkPlaylistPrefill(settings: DebugBenchmarkSettings) {
- settings.getString(DebugBenchmarkSettings.PLAYLIST_TITLE)
- ?.let { titleState.value = it }
- settings.getString(DebugBenchmarkSettings.PLAYLIST_URL)
- ?.let { urlState.value = it }
-}
-
-@Composable
-context(properties: SettingProperties)
-private fun EPGInputContent(
- modifier: Modifier = Modifier
-) {
- val spacing = LocalSpacing.current
- Column(
- modifier = modifier,
- verticalArrangement = Arrangement.spacedBy(spacing.small)
- ) {
- PlaceholderField(
- text = properties.titleState.value,
- placeholder = stringResource(string.feat_setting_placeholder_epg_title).uppercase(),
- onValueChange = { properties.titleState.value = Uri.decode(it) },
- modifier = Modifier.fillMaxWidth()
- )
- PlaceholderField(
- text = properties.epgState.value,
- placeholder = stringResource(string.feat_setting_placeholder_epg).uppercase(),
- onValueChange = { properties.epgState.value = Uri.decode(it) },
- modifier = Modifier.fillMaxWidth()
- )
- }
-}
-
-@Composable
-context(properties: SettingProperties)
-private fun XtreamInputContent(modifier: Modifier = Modifier) {
- val spacing = LocalSpacing.current
-
- Column(
- modifier = modifier,
- verticalArrangement = Arrangement.spacedBy(spacing.small)
- ) {
- PlaceholderField(
- text = properties.titleState.value,
- placeholder = stringResource(string.feat_setting_placeholder_title).uppercase(),
- onValueChange = { properties.titleState.value = Uri.decode(it) },
- modifier = Modifier.fillMaxWidth()
- )
- PlaceholderField(
- text = properties.basicUrlState.value,
- placeholder = stringResource(string.feat_setting_placeholder_basic_url).uppercase(),
- onValueChange = { properties.basicUrlState.value = it },
- modifier = Modifier.fillMaxWidth()
- )
- PlaceholderField(
- text = properties.usernameState.value,
- placeholder = stringResource(string.feat_setting_placeholder_username).uppercase(),
- onValueChange = { properties.usernameState.value = it },
- modifier = Modifier.fillMaxWidth()
- )
- PlaceholderField(
- text = properties.passwordState.value,
- placeholder = stringResource(string.feat_setting_placeholder_password).uppercase(),
- onValueChange = { properties.passwordState.value = it },
- modifier = Modifier.fillMaxWidth()
- )
- Warning(stringResource(string.feat_setting_warning_xtream_takes_much_more_time))
- }
-}
-
-@Composable
-private fun Warning(
- text: String,
- modifier: Modifier = Modifier
-) {
- val spacing = LocalSpacing.current
- CompositionLocalProvider(
- LocalContentColor provides LocalContentColor.current.copy(0.54f)
- ) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(spacing.small),
- verticalAlignment = Alignment.CenterVertically,
- modifier = modifier
- ) {
- Icon(imageVector = Icons.Rounded.Warning, contentDescription = null)
- Text(
- text = text,
- style = MaterialTheme.typography.labelLarge
- )
- }
- }
-}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/OtherPreferences.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/OtherPreferences.kt
index 17ad07b9b..c6d624db7 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/OtherPreferences.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/OtherPreferences.kt
@@ -3,7 +3,6 @@ package com.m3u.smartphone.ui.business.setting.fragments.preferences
import android.content.Intent
import android.net.Uri
import android.provider.Settings
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
@@ -21,7 +20,6 @@ import com.m3u.core.foundation.util.basic.title
import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.material.components.Preference
import com.m3u.smartphone.ui.material.components.TrailingIconPreference
-import com.m3u.smartphone.ui.material.model.LocalSpacing
@Composable
internal fun OtherPreferences(
@@ -29,11 +27,9 @@ internal fun OtherPreferences(
versionCode: Int,
modifier: Modifier = Modifier
) {
- val spacing = LocalSpacing.current
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
Column(
- verticalArrangement = Arrangement.spacedBy(spacing.small),
modifier = modifier
) {
TrailingIconPreference(
@@ -62,4 +58,4 @@ internal fun OtherPreferences(
}
)
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/PreferencesFragment.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/PreferencesFragment.kt
index 1cac69192..090f3e3b8 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/PreferencesFragment.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/PreferencesFragment.kt
@@ -19,6 +19,7 @@ internal fun PreferencesFragment(
versionCode: Int,
codecPackEnabled: Boolean,
navigateToPlaylistManagement: () -> Unit,
+ navigateToExtensionPlugins: () -> Unit,
navigateToThemeSelector: () -> Unit,
navigateToOptional: () -> Unit,
navigateToCodecPack: () -> Unit,
@@ -35,6 +36,7 @@ internal fun PreferencesFragment(
RegularPreferences(
fragment = fragment,
navigateToPlaylistManagement = navigateToPlaylistManagement,
+ navigateToExtensionPlugins = navigateToExtensionPlugins,
navigateToThemeSelector = navigateToThemeSelector,
navigateToOptional = navigateToOptional,
codecPackEnabled = codecPackEnabled,
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/RegularPreferences.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/RegularPreferences.kt
index 5205fe585..7e2427d26 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/RegularPreferences.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/preferences/RegularPreferences.kt
@@ -1,61 +1,87 @@
package com.m3u.smartphone.ui.business.setting.fragments.preferences
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ColorLens
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.MusicNote
+import androidx.compose.material.icons.rounded.Tune
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import com.m3u.core.foundation.util.basic.title
import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.material.components.Preference
-import com.m3u.smartphone.ui.material.model.LocalSpacing
import com.m3u.smartphone.ui.material.components.SettingDestination
@Composable
internal fun RegularPreferences(
fragment: SettingDestination,
navigateToPlaylistManagement: () -> Unit,
+ navigateToExtensionPlugins: () -> Unit,
navigateToThemeSelector: () -> Unit,
navigateToOptional: () -> Unit,
codecPackEnabled: Boolean,
navigateToCodecPack: () -> Unit,
modifier: Modifier = Modifier
) {
- val spacing = LocalSpacing.current
Column(
- modifier = modifier,
- verticalArrangement = Arrangement.spacedBy(spacing.small)
+ modifier = modifier.selectableGroup(),
) {
Preference(
- title = stringResource(string.feat_setting_playlist_management).title(),
+ title = stringResource(string.feat_setting_playlist_management),
icon = Icons.Rounded.MusicNote,
- enabled = fragment != SettingDestination.Playlists,
+ selected = fragment.isPlaylistDestination(),
onClick = navigateToPlaylistManagement
)
+ Preference(
+ title = stringResource(string.feat_setting_extension_plugins),
+ icon = Icons.Rounded.Extension,
+ selected = fragment.isExtensionPluginDestination(),
+ onClick = navigateToExtensionPlugins,
+ modifier = Modifier.testTag("extension-entry"),
+ )
Preference(
title = stringResource(string.feat_setting_appearance).title(),
icon = Icons.Rounded.ColorLens,
- enabled = fragment != SettingDestination.Appearance,
+ selected = fragment == SettingDestination.Appearance,
onClick = navigateToThemeSelector
)
Preference(
title = stringResource(string.feat_setting_optional_features).title(),
- icon = Icons.Rounded.Extension,
- enabled = fragment != SettingDestination.Optional,
+ icon = Icons.Rounded.Tune,
+ selected = fragment == SettingDestination.Optional,
onClick = navigateToOptional
)
if (codecPackEnabled) {
Preference(
title = stringResource(string.feat_setting_codec_pack).title(),
icon = Icons.Rounded.Download,
- enabled = fragment != SettingDestination.CodecPack,
+ selected = fragment == SettingDestination.CodecPack,
onClick = navigateToCodecPack
)
}
}
-}
\ No newline at end of file
+}
+
+private fun SettingDestination.isPlaylistDestination(): Boolean = when (this) {
+ SettingDestination.Playlists,
+ is SettingDestination.PlaylistConfiguration,
+ SettingDestination.PlaylistSourcePicker,
+ is SettingDestination.PlaylistEditor,
+ SettingDestination.PlaylistEpgSources,
+ SettingDestination.PlaylistHiddenChannels,
+ SettingDestination.PlaylistHiddenCategories -> true
+ else -> false
+}
+
+private fun SettingDestination.isExtensionPluginDestination(): Boolean = when (this) {
+ SettingDestination.ExtensionPlugins,
+ is SettingDestination.ExtensionPluginDetails,
+ is SettingDestination.ExtensionPluginAuthorization,
+ is SettingDestination.ExtensionPluginSettings -> true
+ else -> false
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/AppNavHost.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/AppNavHost.kt
index 6f6c40c4a..ea2a68446 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/AppNavHost.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/AppNavHost.kt
@@ -32,6 +32,8 @@ fun AppNavHost(
navigateToChannel: () -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
+ showBottomEdgeBlur: Boolean = true,
+ onNestedDetailVisibilityChanged: (Boolean) -> Unit = {},
startDestination: String = Destination.Foryou.name
) {
val context = LocalContext.current
@@ -57,7 +59,9 @@ fun AppNavHost(
},
navigateToPlaylistConfiguration = {
navController.navigateToPlaylistConfiguration(it.url)
- }
+ },
+ showBottomEdgeBlur = showBottomEdgeBlur,
+ onNestedDetailVisibilityChanged = onNestedDetailVisibilityChanged,
)
playlistScreen(
navigateToChannel = {
@@ -76,6 +80,14 @@ fun AppNavHost(
},
contentPadding = contentPadding
)
- playlistConfigurationScreen(contentPadding)
+ playlistConfigurationScreen(
+ contentPadding = contentPadding,
+ onBack = {
+ navController.popBackStack()
+ },
+ onPlaylistRemoved = {
+ navController.popBackStack()
+ },
+ )
}
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/RootGraph.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/RootGraph.kt
index 3745df5eb..5de3c499c 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/RootGraph.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/RootGraph.kt
@@ -9,13 +9,12 @@ import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.m3u.data.database.model.Playlist
-import com.m3u.smartphone.ui.business.extension.ExtensionRoute
-import com.m3u.smartphone.ui.material.ktx.Edge
-import com.m3u.smartphone.ui.material.ktx.blurEdge
import com.m3u.smartphone.ui.business.favourite.FavoriteRoute
import com.m3u.smartphone.ui.business.foryou.ForyouRoute
import com.m3u.smartphone.ui.business.setting.SettingRoute
import com.m3u.smartphone.ui.material.components.Destination
+import com.m3u.smartphone.ui.material.ktx.Edge
+import com.m3u.smartphone.ui.material.ktx.blurEdge
fun NavGraphBuilder.rootGraph(
contentPadding: PaddingValues,
@@ -23,6 +22,8 @@ fun NavGraphBuilder.rootGraph(
navigateToChannel: () -> Unit,
navigateToSettingPlaylistManagement: () -> Unit,
navigateToPlaylistConfiguration: (Playlist) -> Unit,
+ showBottomEdgeBlur: Boolean,
+ onNestedDetailVisibilityChanged: (Boolean) -> Unit,
) {
composable(
route = Destination.Foryou.name,
@@ -37,10 +38,16 @@ fun NavGraphBuilder.rootGraph(
contentPadding = contentPadding,
modifier = Modifier
.fillMaxSize()
- .blurEdge(
- edge = Edge.Bottom,
- color = MaterialTheme.colorScheme.background
- )
+ .then(
+ if (showBottomEdgeBlur) {
+ Modifier.blurEdge(
+ edge = Edge.Bottom,
+ color = MaterialTheme.colorScheme.background,
+ )
+ } else {
+ Modifier
+ },
+ ),
)
}
composable(
@@ -53,26 +60,16 @@ fun NavGraphBuilder.rootGraph(
contentPadding = contentPadding,
modifier = Modifier
.fillMaxSize()
- .blurEdge(
- edge = Edge.Bottom,
- color = MaterialTheme.colorScheme.background
- )
- )
- }
-
- composable(
- route = Destination.Extension.name,
- enterTransition = { fadeIn() },
- exitTransition = { fadeOut() }
- ) {
- ExtensionRoute(
- contentPadding = contentPadding,
- modifier = Modifier
- .fillMaxSize()
- .blurEdge(
- edge = Edge.Bottom,
- color = MaterialTheme.colorScheme.background
- )
+ .then(
+ if (showBottomEdgeBlur) {
+ Modifier.blurEdge(
+ edge = Edge.Bottom,
+ color = MaterialTheme.colorScheme.background,
+ )
+ } else {
+ Modifier
+ },
+ ),
)
}
@@ -83,12 +80,19 @@ fun NavGraphBuilder.rootGraph(
) {
SettingRoute(
contentPadding = contentPadding,
+ onDetailVisibilityChanged = onNestedDetailVisibilityChanged,
modifier = Modifier
.fillMaxSize()
- .blurEdge(
- edge = Edge.Bottom,
- color = MaterialTheme.colorScheme.background
- )
+ .then(
+ if (showBottomEdgeBlur) {
+ Modifier.blurEdge(
+ edge = Edge.Bottom,
+ color = MaterialTheme.colorScheme.background,
+ )
+ } else {
+ Modifier
+ },
+ ),
)
}
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/CodeRow.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/CodeRow.kt
index b081116b2..3580edbd4 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/CodeRow.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/CodeRow.kt
@@ -3,66 +3,81 @@ package com.m3u.smartphone.ui.common.connect
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
import com.m3u.smartphone.ui.material.model.LocalSpacing
@Composable
internal fun CodeRow(
code: String,
length: Int,
- onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
+ val bidiFormatter = rememberUiBidiFormatter()
val element = remember(code) { code.toCharArray().map { it.toString() } }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(
- horizontal = spacing.extraLarge,
- vertical = spacing.medium
- )
- .clickable(
- onClick = onClick,
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- )
- .then(modifier),
- horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.CenterVertically
- ) {
- repeat(length) { index ->
- CodeField(
- text = element.getOrNull(index).orEmpty()
- )
+ val pairingCodeDescription = when {
+ code.isBlank() -> stringResource(string.ui_remote_control_pairing_code_empty)
+ else -> stringResource(
+ string.ui_remote_control_pairing_code,
+ bidiFormatter.ltr(code.toCharArray().joinToString(separator = " "))
+ )
+ }
+
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(
+ horizontal = spacing.medium,
+ vertical = spacing.medium
+ )
+ .clearAndSetSemantics {
+ contentDescription = pairingCodeDescription
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ repeat(length) { index ->
+ CodeField(
+ text = element.getOrNull(index).orEmpty(),
+ modifier = Modifier.weight(1f)
+ )
+ }
}
}
}
@Composable
-private fun CodeField(text: String) {
+private fun CodeField(
+ text: String,
+ modifier: Modifier = Modifier
+) {
Box(
- modifier = Modifier
- .padding(start = 4.dp, end = 4.dp)
- .size(40.dp, 45.dp)
+ modifier = modifier
+ .padding(horizontal = 4.dp)
+ .heightIn(min = 45.dp)
.background(
color = MaterialTheme.colorScheme.onSurface.copy(.05f),
shape = RoundedCornerShape(6.dp)
@@ -73,7 +88,9 @@ private fun CodeField(text: String) {
)
) {
Text(
- modifier = Modifier.align(Alignment.Center),
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(vertical = 4.dp),
text = text,
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/DPadContent.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/DPadContent.kt
index 2a4a74d9a..a35b0ee8e 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/DPadContent.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/DPadContent.kt
@@ -1,9 +1,12 @@
package com.m3u.smartphone.ui.common.connect
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -11,13 +14,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDirection
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m3u.data.tv.model.RemoteDirection
import com.m3u.data.tv.model.TvInfo
import com.m3u.i18n.R.string
-import com.m3u.smartphone.ui.material.components.FontFamilies
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
import com.m3u.smartphone.ui.material.model.LocalSpacing
@Composable
@@ -28,27 +35,28 @@ internal fun DPadContent(
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
+ val bidiFormatter = rememberUiBidiFormatter()
+ val scrollState = rememberScrollState()
Column(
- modifier = modifier.fillMaxWidth(),
+ modifier = modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState)
+ .padding(bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spacing.medium)
) {
Text(
- text = tvInfo.model,
+ text = bidiFormatter.natural(tvInfo.model),
textAlign = TextAlign.Center,
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
- )
- Text(
- text = " ",
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.labelMedium,
- maxLines = 1,
- fontFamily = FontFamilies.JetbrainsMono,
+ style = MaterialTheme.typography.headlineSmall.copy(
+ textDirection = TextDirection.ContentOrLtr,
+ ),
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp, 0.dp, 16.dp, 8.dp)
+ .padding(start = 16.dp, end = 16.dp, top = 8.dp)
+ .semantics { heading() },
)
RemoteDirectionController(
@@ -57,9 +65,11 @@ internal fun DPadContent(
TextButton(
onClick = forgetTvCodeOnSmartphone,
- modifier = Modifier.padding(top = 4.dp)
+ modifier = Modifier
+ .heightIn(min = 48.dp)
+ .padding(top = 4.dp)
) {
- Text(stringResource(string.ui_remote_control_disconnect).uppercase())
+ Text(stringResource(string.ui_remote_control_disconnect))
}
}
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/PrepareContent.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/PrepareContent.kt
index c97d6898a..c5affebf0 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/PrepareContent.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/PrepareContent.kt
@@ -7,12 +7,14 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Button
@@ -23,12 +25,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.m3u.core.foundation.util.basic.title
import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.material.ktx.rememberUiBidiFormatter
@Composable
@InternalComposeApi
@@ -40,42 +45,54 @@ internal fun PrepareContent(
modifier: Modifier = Modifier,
subtitle: String = stringResource(string.ui_remote_control_pair_subtitle)
) {
- val title = stringResource(string.ui_remote_control_pair_title).title()
- Column(modifier) {
+ val bidiFormatter = rememberUiBidiFormatter()
+ val displaySubtitle = bidiFormatter.natural(subtitle)
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState)
+ .padding(bottom = 16.dp),
+ ) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Text(
- text = title,
+ text = stringResource(string.ui_remote_control_pair_title),
textAlign = TextAlign.Center,
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp)
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier
+ .padding(start = 16.dp, end = 16.dp, top = 8.dp)
+ .semantics { heading() },
)
AnimatedContent(
- targetState = subtitle,
+ targetState = displaySubtitle,
label = "remote-control-subtitle",
transitionSpec = {
- fadeIn() + slideInVertically { it } togetherWith fadeOut() + slideOutVertically { it }
+ fadeIn() + slideInVertically { it } togetherWith
+ fadeOut() + slideOutVertically { it }
}
) { currentSubtitle ->
Text(
text = currentSubtitle,
textAlign = TextAlign.Center,
- fontSize = 14.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.78f),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 8.dp, 16.dp, 0.dp)
+ .semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
)
}
CodeRow(
code = code,
- length = 6,
- onClick = {}
+ length = 6
)
Button(
@@ -88,8 +105,8 @@ internal fun PrepareContent(
) {
Text(
when {
- searchingOrConnecting -> stringResource(string.ui_remote_control_connecting).uppercase()
- else -> stringResource(string.ui_remote_control_connect).uppercase()
+ searchingOrConnecting -> stringResource(string.ui_remote_control_connecting)
+ else -> stringResource(string.ui_remote_control_connect)
}
)
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/RemoteDirectionController.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/RemoteDirectionController.kt
index 04012048e..2f8e121d1 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/RemoteDirectionController.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/RemoteDirectionController.kt
@@ -19,9 +19,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
-import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.KeyboardArrowDown
+import androidx.compose.material.icons.rounded.KeyboardArrowLeft
+import androidx.compose.material.icons.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -42,8 +42,18 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.m3u.data.tv.model.RemoteDirection
+import com.m3u.i18n.R.string
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -128,6 +138,12 @@ internal fun RemoteDirectionController(
targetValue = pressedPosition.takeOrElse { draggedPosition.takeOrElse { Offset.Zero } },
label = "current-position"
)
+ val directionPadDescription = stringResource(string.ui_remote_control_direction_pad)
+ val moveUpDescription = stringResource(string.ui_remote_control_direction_up)
+ val moveDownDescription = stringResource(string.ui_remote_control_direction_down)
+ val moveLeftDescription = stringResource(string.ui_remote_control_direction_left)
+ val moveRightDescription = stringResource(string.ui_remote_control_direction_right)
+ val confirmDescription = stringResource(string.ui_remote_control_direction_confirm)
LaunchedEffect(Unit) {
interactionSource
@@ -169,7 +185,35 @@ internal fun RemoteDirectionController(
.launchIn(this)
}
- Box(contentAlignment = Alignment.Center) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier.semantics(mergeDescendants = true) {
+ contentDescription = directionPadDescription
+ role = Role.Button
+ onClick(label = confirmDescription) {
+ onRemoteDirection(RemoteDirection.ENTER)
+ true
+ }
+ customActions = listOf(
+ CustomAccessibilityAction(moveUpDescription) {
+ onRemoteDirection(RemoteDirection.UP)
+ true
+ },
+ CustomAccessibilityAction(moveDownDescription) {
+ onRemoteDirection(RemoteDirection.DOWN)
+ true
+ },
+ CustomAccessibilityAction(moveLeftDescription) {
+ onRemoteDirection(RemoteDirection.LEFT)
+ true
+ },
+ CustomAccessibilityAction(moveRightDescription) {
+ onRemoteDirection(RemoteDirection.RIGHT)
+ true
+ }
+ )
+ }
+ ) {
Canvas(
modifier = Modifier
.wrapContentSize(Alignment.Center)
@@ -199,7 +243,7 @@ internal fun RemoteDirectionController(
controllerCenter = Offset(size.width / 2f, size.height / 2f)
controllerRadius = minOf(size.width, size.height) / 2f
}
- .then(modifier)
+ .clearAndSetSemantics { }
) {
val radius = size.minDimension / 2
drawCircle(
@@ -216,8 +260,8 @@ internal fun RemoteDirectionController(
)
}
val icon = when (direction) {
- RemoteDirection.LEFT -> Icons.AutoMirrored.Rounded.KeyboardArrowLeft
- RemoteDirection.RIGHT -> Icons.AutoMirrored.Rounded.KeyboardArrowRight
+ RemoteDirection.LEFT -> Icons.Rounded.KeyboardArrowLeft
+ RemoteDirection.RIGHT -> Icons.Rounded.KeyboardArrowRight
RemoteDirection.UP -> Icons.Rounded.KeyboardArrowUp
RemoteDirection.DOWN -> Icons.Rounded.KeyboardArrowDown
else -> null
@@ -236,7 +280,7 @@ internal fun RemoteDirectionController(
if (icon != null) {
Icon(
imageVector = icon,
- contentDescription = direction?.name,
+ contentDescription = null,
tint = contentColor,
modifier = Modifier.matchParentSize()
)
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/VirtualNumberKeyboard.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/VirtualNumberKeyboard.kt
index 9b595416a..7477853be 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/VirtualNumberKeyboard.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/connect/VirtualNumberKeyboard.kt
@@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -20,11 +20,14 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m3u.i18n.R.string
@@ -35,114 +38,123 @@ internal fun VirtualNumberKeyboard(
code: String,
onCode: (String) -> Unit,
) {
- Column(
- modifier
- .fillMaxWidth()
- .background(MaterialTheme.colorScheme.surface)
- .background(MaterialTheme.colorScheme.onSurface.copy(.1f))
- ) {
- Row(Modifier.fillMaxWidth()) {
- KeyboardKey(
- modifier = Modifier.weight(1f),
- text = "1",
- onClick = { if (code.length < 6) onCode(code + "1") }
- )
- KeyboardKey(
- modifier = Modifier.weight(1f),
- text = "2",
- onClick = { if (code.length < 6) onCode(code + "2") }
- )
- KeyboardKey(
- modifier = Modifier.weight(1f),
- text = "3",
- onClick = { if (code.length < 6) onCode(code + "3") }
- )
- }
- Row(Modifier.fillMaxWidth()) {
- KeyboardKey(
- modifier = Modifier.weight(1f),
- text = "4",
- onClick = { if (code.length < 6) onCode(code + "4") }
- )
- KeyboardKey(
- modifier = Modifier.weight(1f),
- text = "5",
- onClick = { if (code.length < 6) onCode(code + "5") }
- )
- KeyboardKey(
- modifier = Modifier.weight(1f),
- text = "6",
- onClick = { if (code.length < 6) onCode(code + "6") }
- )
- }
- Row(Modifier.fillMaxWidth()) {
- KeyboardKey(
- modifier = Modifier.weight(1f),
- text = "7",
- onClick = { if (code.length < 6) onCode(code + "7") }
- )
- KeyboardKey(
- modifier = Modifier.weight(1f),
- text = "8",
- onClick = { if (code.length < 6) onCode(code + "8") }
- )
- KeyboardKey(
- modifier = Modifier.weight(1f),
- text = "9",
- onClick = { if (code.length < 6) onCode(code + "9") }
- )
- }
- Row(Modifier.fillMaxWidth()) {
- Column(
- modifier = Modifier
- .height(54.dp)
- .weight(1f)
- .clickable(
- onClick = {
- if (code.isNotEmpty()) {
- onCode(code.substring(0, code.length - 1))
- }
- },
- indication = ripple(color = MaterialTheme.colorScheme.primary),
- interactionSource = remember { MutableInteractionSource() }
- ),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Icon(
- modifier = Modifier.size(38.dp),
- imageVector = Icons.Rounded.ArrowBackIosNew,
- contentDescription = stringResource(string.ui_remote_control_keypad_backspace)
+ val digitKeysEnabled = areRemoteDigitKeysEnabled(code.length)
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+ Column(
+ modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface)
+ .background(MaterialTheme.colorScheme.onSurface.copy(.1f))
+ ) {
+ Row(Modifier.fillMaxWidth()) {
+ KeyboardKey(
+ modifier = Modifier.weight(1f),
+ text = "1",
+ enabled = digitKeysEnabled,
+ onClick = { onCode(code + "1") }
+ )
+ KeyboardKey(
+ modifier = Modifier.weight(1f),
+ text = "2",
+ enabled = digitKeysEnabled,
+ onClick = { onCode(code + "2") }
+ )
+ KeyboardKey(
+ modifier = Modifier.weight(1f),
+ text = "3",
+ enabled = digitKeysEnabled,
+ onClick = { onCode(code + "3") }
)
}
- KeyboardKey(
- modifier = Modifier.weight(1f),
- text = "0",
- onClick = { if (code.length < 6) onCode(code + "0") }
- )
-
- Column(
- modifier = Modifier
- .height(54.dp)
- .weight(1f)
- .clickable(
- onClick = {
- if (code.isNotEmpty()) {
- onCode("")
- }
- },
- indication = ripple(color = MaterialTheme.colorScheme.primary),
- interactionSource = remember { MutableInteractionSource() }
- ),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Icon(
- modifier = Modifier.size(38.dp),
- imageVector = Icons.Rounded.Delete,
- contentDescription = stringResource(string.ui_remote_control_keypad_clear)
+ Row(Modifier.fillMaxWidth()) {
+ KeyboardKey(
+ modifier = Modifier.weight(1f),
+ text = "4",
+ enabled = digitKeysEnabled,
+ onClick = { onCode(code + "4") }
+ )
+ KeyboardKey(
+ modifier = Modifier.weight(1f),
+ text = "5",
+ enabled = digitKeysEnabled,
+ onClick = { onCode(code + "5") }
+ )
+ KeyboardKey(
+ modifier = Modifier.weight(1f),
+ text = "6",
+ enabled = digitKeysEnabled,
+ onClick = { onCode(code + "6") }
)
}
+ Row(Modifier.fillMaxWidth()) {
+ KeyboardKey(
+ modifier = Modifier.weight(1f),
+ text = "7",
+ enabled = digitKeysEnabled,
+ onClick = { onCode(code + "7") }
+ )
+ KeyboardKey(
+ modifier = Modifier.weight(1f),
+ text = "8",
+ enabled = digitKeysEnabled,
+ onClick = { onCode(code + "8") }
+ )
+ KeyboardKey(
+ modifier = Modifier.weight(1f),
+ text = "9",
+ enabled = digitKeysEnabled,
+ onClick = { onCode(code + "9") }
+ )
+ }
+ Row(Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier
+ .heightIn(min = 54.dp)
+ .weight(1f)
+ .clickable(
+ enabled = code.isNotEmpty(),
+ onClick = {
+ onCode(code.substring(0, code.length - 1))
+ },
+ indication = ripple(color = MaterialTheme.colorScheme.primary),
+ interactionSource = remember { MutableInteractionSource() }
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ modifier = Modifier.size(38.dp),
+ imageVector = Icons.Rounded.ArrowBackIosNew,
+ contentDescription = stringResource(string.ui_remote_control_keypad_backspace)
+ )
+ }
+ KeyboardKey(
+ modifier = Modifier.weight(1f),
+ text = "0",
+ enabled = digitKeysEnabled,
+ onClick = { onCode(code + "0") }
+ )
+
+ Column(
+ modifier = Modifier
+ .heightIn(min = 54.dp)
+ .weight(1f)
+ .clickable(
+ enabled = code.isNotEmpty(),
+ onClick = { onCode("") },
+ indication = ripple(color = MaterialTheme.colorScheme.primary),
+ interactionSource = remember { MutableInteractionSource() }
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ modifier = Modifier.size(38.dp),
+ imageVector = Icons.Rounded.Delete,
+ contentDescription = stringResource(string.ui_remote_control_keypad_clear)
+ )
+ }
+ }
}
}
}
@@ -151,10 +163,12 @@ internal fun VirtualNumberKeyboard(
private fun KeyboardKey(
modifier: Modifier,
text: String,
+ enabled: Boolean,
onClick: () -> Unit
) {
TextButton(
- modifier = modifier.height(54.dp),
+ modifier = modifier.heightIn(min = 54.dp),
+ enabled = enabled,
onClick = onClick,
shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(0.dp)
@@ -163,7 +177,11 @@ private fun KeyboardKey(
text = text,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface.copy(.8f)
+ color = MaterialTheme.colorScheme.onSurface.copy(
+ alpha = if (enabled) 0.8f else 0.38f
+ )
)
}
}
+
+internal fun areRemoteDigitKeysEnabled(codeLength: Int): Boolean = codeLength < 6
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/internal/Toolkit.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/internal/Toolkit.kt
index 18288eb98..d10e11574 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/internal/Toolkit.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/common/internal/Toolkit.kt
@@ -5,11 +5,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
import com.m3u.core.foundation.architecture.preferences.preferenceOf
+import com.m3u.core.foundation.architecture.preferences.themePreferencesOf
import com.m3u.smartphone.ui.common.helper.Helper
import com.m3u.smartphone.ui.common.helper.LocalHelper
import com.m3u.smartphone.ui.material.LocalM3UHapticFeedback
@@ -31,22 +31,15 @@ fun Toolkit(
val smartphoneTypography: Material3Typography = remember(prevTypography) {
prevTypography.withFontFamily(FontFamilies.GoogleSans)
}
- val followSystemTheme by preferenceOf(PreferencesKeys.FOLLOW_SYSTEM_THEME)
- val darkMode by preferenceOf(PreferencesKeys.DARK_MODE)
val compactDimension by preferenceOf(PreferencesKeys.COMPACT_DIMENSION)
- val argb by preferenceOf(PreferencesKeys.COLOR_ARGB)
- val useDynamicColors by preferenceOf(PreferencesKeys.USE_DYNAMIC_COLORS)
+ val themePreferences by themePreferencesOf()
val isSystemInDarkTheme = isSystemInDarkTheme()
- val useDarkTheme by remember {
- derivedStateOf {
- when {
- alwaysUseDarkTheme -> true
- followSystemTheme -> isSystemInDarkTheme
- else -> darkMode
- }
- }
+ val useDarkTheme = when {
+ alwaysUseDarkTheme -> true
+ themePreferences.followSystemTheme -> isSystemInDarkTheme
+ else -> themePreferences.selection.isDark
}
val spacing = if (compactDimension) Spacing.COMPACT
@@ -57,9 +50,10 @@ fun Toolkit(
LocalSpacing provides spacing
) {
Theme(
- argb = argb,
+ argb = themePreferences.selection.argb,
+ themeStyle = themePreferences.selection.style,
useDarkTheme = useDarkTheme,
- useDynamicColors = useDynamicColors,
+ useDynamicColors = themePreferences.useDynamicColors,
typography = smartphoneTypography
) {
LaunchedEffect(useDarkTheme) {
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Destination.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Destination.kt
index ac76376a9..3ed69f23f 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Destination.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Destination.kt
@@ -4,17 +4,16 @@ import android.os.Parcelable
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
-import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.rounded.Collections
-import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
import com.m3u.i18n.R.string
import kotlinx.parcelize.Parcelize
+import java.util.UUID
@Immutable
enum class Destination(
@@ -32,11 +31,6 @@ enum class Destination(
unselectedIcon = Icons.Outlined.Collections,
iconTextId = string.ui_destination_favourite
),
- Extension(
- selectedIcon = Icons.Rounded.Extension,
- unselectedIcon = Icons.Outlined.Extension,
- iconTextId = string.ui_destination_extension
- ),
Setting(
selectedIcon = Icons.Rounded.Settings,
unselectedIcon = Icons.Outlined.Settings,
@@ -63,6 +57,63 @@ sealed interface SettingDestination : Parcelable {
@Parcelize
data object Playlists : SettingDestination
+ @Immutable
+ @Parcelize
+ data class PlaylistConfiguration(
+ val playlistReference: String,
+ ) : SettingDestination
+
+ @Immutable
+ @Parcelize
+ data object PlaylistSourcePicker : SettingDestination
+
+ @Immutable
+ @Parcelize
+ data class PlaylistEditor(
+ val sourceKey: String,
+ val draftKey: String = UUID.randomUUID().toString(),
+ val providerId: String? = null,
+ val providerKind: String? = null,
+ val reauthenticationPlaylistUrl: String? = null,
+ ) : SettingDestination
+
+ @Immutable
+ @Parcelize
+ data object PlaylistEpgSources : SettingDestination
+
+ @Immutable
+ @Parcelize
+ data object PlaylistHiddenChannels : SettingDestination
+
+ @Immutable
+ @Parcelize
+ data object PlaylistHiddenCategories : SettingDestination
+
+ @Immutable
+ @Parcelize
+ data object ExtensionPlugins : SettingDestination
+
+ @Immutable
+ @Parcelize
+ data class ExtensionPluginDetails(
+ val packageName: String,
+ val serviceName: String,
+ ) : SettingDestination
+
+ @Immutable
+ @Parcelize
+ data class ExtensionPluginAuthorization(
+ val packageName: String,
+ val serviceName: String,
+ val reauthorize: Boolean,
+ ) : SettingDestination
+
+ @Immutable
+ @Parcelize
+ data class ExtensionPluginSettings(
+ val extensionId: String,
+ ) : SettingDestination
+
@Immutable
@Parcelize
data object Appearance : SettingDestination
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/FontFamilies.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/FontFamilies.kt
index a6b23f18f..80a680161 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/FontFamilies.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/FontFamilies.kt
@@ -5,6 +5,7 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.toFontFamily
+import androidx.compose.ui.text.TextStyle
import com.m3u.smartphone.R
object FontFamilies {
@@ -67,3 +68,8 @@ fun Typography.withFontFamily(
fontFamily = fontFamily
)
)
+
+fun TextStyle.withEditorialVoice(): TextStyle = copy(
+ fontFamily = FontFamily.Serif,
+ fontWeight = FontWeight.Medium,
+)
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Preferences.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Preferences.kt
index 875795ad4..04d34dfa8 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Preferences.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/Preferences.kt
@@ -1,11 +1,10 @@
package com.m3u.smartphone.ui.material.components
-import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.material3.CardDefaults
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -13,29 +12,22 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedCard
-import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
-import androidx.compose.material3.TooltipBox
-import androidx.compose.material3.TooltipDefaults
-import androidx.compose.material3.rememberTooltipState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import com.m3u.smartphone.ui.material.model.LocalSpacing
-import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
@Composable
fun Preference(
@@ -44,79 +36,72 @@ fun Preference(
enabled: Boolean = true,
content: String? = null,
elevation: Dp = 0.dp,
+ selected: Boolean? = null,
+ role: Role? = null,
onClick: () -> Unit = {},
icon: ImageVector? = null,
trailing: @Composable (() -> Unit)? = null
) {
- val spacing = LocalSpacing.current
val interactionSource = remember { MutableInteractionSource() }
- val focus by interactionSource.collectIsFocusedAsState()
-
- TooltipBox(
- state = rememberTooltipState(),
- positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
- tooltip = {
- if (!content.isNullOrEmpty()) {
- PlainTooltip {
- Text(
- text = content.capitalize(Locale.current)
- )
- }
+ val alpha = if (enabled) 1f else 0.38f
+ ListItem(
+ headlineContent = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ },
+ supportingContent = content?.let { supportingText ->
+ @Composable {
+ Text(
+ text = supportingText,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ },
+ trailingContent = trailing,
+ leadingContent = icon?.let {
+ @Composable {
+ Icon(imageVector = it, contentDescription = null)
}
- }
- ) {
- val alpha = if (enabled) 1f else 0.38f
- OutlinedCard(
- colors = CardDefaults.outlinedCardColors(Color.Transparent),
- shape = AbsoluteSmoothCornerShape(spacing.medium, 65)
- ) {
- ListItem(
- headlineContent = {
- Text(
- text = title,
- style = MaterialTheme.typography.titleMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ },
+ tonalElevation = LocalAbsoluteTonalElevation.current,
+ shadowElevation = elevation,
+ colors = ListItemDefaults.colors(
+ containerColor = if (selected == true) {
+ MaterialTheme.colorScheme.surfaceContainerHighest
+ } else {
+ Color.Transparent
+ },
+ overlineColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha),
+ supportingColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha),
+ headlineColor = MaterialTheme.colorScheme.onSurface.copy(alpha)
+ ),
+ modifier = modifier
+ .clip(MaterialTheme.shapes.large)
+ .semantics(mergeDescendants = true) {}
+ .then(
+ if (selected != null) {
+ Modifier.selectable(
+ selected = selected,
+ enabled = enabled,
+ role = role ?: Role.Tab,
+ onClick = onClick,
+ interactionSource = interactionSource,
+ indication = ripple()
)
- },
- supportingContent = {
- if (content != null) {
- Text(
- text = content.capitalize(Locale.current),
- style = MaterialTheme.typography.bodyMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier then if (focus) Modifier.basicMarquee()
- else Modifier
- )
- }
- },
- trailingContent = trailing,
- leadingContent = icon?.let {
- @Composable {
- Icon(imageVector = it, contentDescription = null)
- }
- },
- tonalElevation = LocalAbsoluteTonalElevation.current,
- shadowElevation = elevation,
- colors = ListItemDefaults.colors(
- containerColor = Color.Transparent,
- overlineColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha),
- supportingColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha),
- headlineColor = MaterialTheme.colorScheme.onSurface.copy(alpha)
- ),
- modifier = modifier
- .semantics(mergeDescendants = true) {}
- .clickable(
+ } else {
+ Modifier.clickable(
enabled = enabled,
+ role = role,
onClick = onClick,
interactionSource = interactionSource,
indication = ripple()
)
- .fillMaxWidth()
+ }
)
- }
- }
+ .fillMaxWidth()
+ )
}
@@ -142,6 +127,7 @@ fun CheckBoxPreference(
}
},
modifier = modifier,
+ role = Role.Checkbox,
trailing = {
Checkbox(
enabled = enabled,
@@ -175,6 +161,7 @@ fun SwitchPreference(
}
},
modifier = modifier,
+ role = Role.Switch,
trailing = {
Switch(
enabled = enabled,
@@ -237,12 +224,14 @@ fun TextPreference(
modifier = modifier,
trailing = {
Text(
- text = trailing.uppercase(),
+ text = trailing,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.End,
+ modifier = Modifier.widthIn(max = 144.dp)
)
},
icon = icon
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/SnackHost.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/SnackHost.kt
index 594e173ea..62fbd785c 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/SnackHost.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/SnackHost.kt
@@ -1,17 +1,13 @@
package com.m3u.smartphone.ui.material.components
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
-import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -19,27 +15,34 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Tv
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalAccessibilityManager
import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m3u.core.foundation.wrapper.Message
import com.m3u.data.service.collectMessageAsState
-import androidx.compose.material3.Icon
import com.m3u.smartphone.ui.material.model.LocalDuration
import com.m3u.smartphone.ui.material.model.LocalSpacing
+import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Composable
@@ -50,21 +53,47 @@ fun SnackHost(
val spacing = LocalSpacing.current
val duration = LocalDuration.current
val feedback = LocalHapticFeedback.current
+ val accessibilityManager = LocalAccessibilityManager.current
+ val coroutineScope = rememberCoroutineScope()
val message by collectMessageAsState()
+ var displayedMessage by remember { mutableStateOf(Message.Dynamic.EMPTY) }
+ var dismissJob by remember { mutableStateOf(null) }
- val tv by remember {
- derivedStateOf { message.type == Message.TYPE_TELEVISION }
- }
+ LaunchedEffect(message, accessibilityManager) {
+ if (message.level == Message.LEVEL_EMPTY) return@LaunchedEffect
- val interactionSource = remember { MutableInteractionSource() }
+ displayedMessage = message
+ dismissJob?.cancel()
+ val originalTimeoutMillis = message.duration.inWholeMilliseconds.coerceAtLeast(0L)
+ val recommendedTimeoutMillis = calculateSnackTimeoutMillis(
+ originalTimeoutMillis = originalTimeoutMillis,
+ recommendedTimeoutMillis = accessibilityManager?.calculateRecommendedTimeoutMillis(
+ originalTimeoutMillis = originalTimeoutMillis,
+ containsIcons = message.type == Message.TYPE_TELEVISION,
+ containsText = true,
+ containsControls = false,
+ ),
+ )
+ if (recommendedTimeoutMillis != Long.MAX_VALUE) {
+ val emittedMessage = message
+ dismissJob = coroutineScope.launch {
+ delay(recommendedTimeoutMillis.milliseconds)
+ if (displayedMessage == emittedMessage) {
+ displayedMessage = Message.Dynamic.EMPTY
+ }
+ }
+ }
+ }
- val isPressed by interactionSource.collectIsPressedAsState()
+ val tv by remember {
+ derivedStateOf { displayedMessage.type == Message.TYPE_TELEVISION }
+ }
val currentContainerColor by animateColorAsState(
- targetValue = when (message.type) {
+ targetValue = when (displayedMessage.type) {
Message.TYPE_TELEVISION -> theme.onBackground
- else -> when (message.level) {
+ else -> when (displayedMessage.level) {
Message.LEVEL_ERROR -> theme.error
Message.LEVEL_WARN -> theme.tertiary
else -> theme.primary
@@ -73,9 +102,9 @@ fun SnackHost(
label = "snack-host-color"
)
val currentContentColor by animateColorAsState(
- targetValue = when (message.type) {
+ targetValue = when (displayedMessage.type) {
Message.TYPE_TELEVISION -> theme.background
- else -> when (message.level) {
+ else -> when (displayedMessage.level) {
Message.LEVEL_ERROR -> theme.onError
Message.LEVEL_WARN -> theme.onTertiary
else -> theme.onPrimary
@@ -83,12 +112,8 @@ fun SnackHost(
},
label = "snack-host-color"
)
- val currentScale by animateFloatAsState(
- targetValue = if (isPressed) 1.02f else 1f,
- label = "snack-host-scale"
- )
AnimatedVisibility(
- visible = message.level != Message.LEVEL_EMPTY,
+ visible = displayedMessage.level != Message.LEVEL_EMPTY,
enter = slideInVertically(
animationSpec = spring()
) { it } + fadeIn(
@@ -99,16 +124,13 @@ fun SnackHost(
) { it } + fadeOut(
animationSpec = spring()
),
- modifier = Modifier
- .graphicsLayer {
- scaleX = currentScale
- scaleY = currentScale
- }
- .then(modifier)
+ modifier = modifier
) {
- LaunchedEffect(Unit) {
- delay(duration.fast.milliseconds)
- feedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ LaunchedEffect(displayedMessage) {
+ if (displayedMessage.level != Message.LEVEL_EMPTY) {
+ delay(duration.fast.milliseconds)
+ feedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
}
Card(
colors = CardDefaults.cardColors(
@@ -116,11 +138,13 @@ fun SnackHost(
contentColor = currentContentColor
),
elevation = CardDefaults.elevatedCardElevation(0.dp),
- onClick = { },
- interactionSource = interactionSource,
- modifier = Modifier.animateContentSize()
+ modifier = Modifier
+ .animateContentSize()
+ .semantics {
+ liveRegion = LiveRegionMode.Polite
+ }
) {
- val text = message.formatText()
+ val text = displayedMessage.formatText()
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -138,20 +162,22 @@ fun SnackHost(
)
}
}
- Crossfade(
- targetState = isPressed,
- label = "snake-host-text"
- ) { isPressed ->
- Text(
- text = text,
- maxLines = if (isPressed) Int.MAX_VALUE else 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.SemiBold,
- modifier = Modifier.alignByBaseline()
- )
- }
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.alignByBaseline()
+ )
}
}
}
}
+
+internal fun calculateSnackTimeoutMillis(
+ originalTimeoutMillis: Long,
+ recommendedTimeoutMillis: Long?,
+): Long {
+ return recommendedTimeoutMillis
+ ?.coerceAtLeast(originalTimeoutMillis)
+ ?: originalTimeoutMillis
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/SortBottomSheet.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/SortBottomSheet.kt
index c73a4849c..f58aaebf0 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/SortBottomSheet.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/SortBottomSheet.kt
@@ -39,7 +39,7 @@ fun SortBottomSheet(
header = {
Icon(
imageVector = Icons.AutoMirrored.Rounded.Sort,
- contentDescription = "sort"
+ contentDescription = null
)
Text(
text = stringResource(string.ui_sort),
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/TextFields.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/TextFields.kt
index 2058a3c70..f50a40beb 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/TextFields.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/TextFields.kt
@@ -3,19 +3,23 @@
package com.m3u.smartphone.ui.material.components
import androidx.activity.compose.BackHandler
-import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
@@ -28,6 +32,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -39,18 +44,25 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
-import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.error
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.material.ktx.InteractionType
import com.m3u.smartphone.ui.material.ktx.interactionBorder
@@ -63,6 +75,7 @@ fun TextField(
shape: Shape = TextFieldDefaults.shape(),
placeholder: String = "",
keyboardType: KeyboardType = KeyboardType.Text,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
readOnly: Boolean = false,
singleLine: Boolean = true,
imeAction: ImeAction? = null,
@@ -71,12 +84,18 @@ fun TextField(
fontSize: TextUnit = TextFieldDefaults.TextFontSize,
fontWeight: FontWeight? = null,
isError: Boolean = false,
+ errorMessage: String? = null,
onValueChange: (String) -> Unit = {},
) {
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
val focus by interactionSource.collectIsFocusedAsState()
+ val semanticErrorMessage = when {
+ !isError -> null
+ !errorMessage.isNullOrBlank() -> errorMessage
+ else -> stringResource(string.ui_error_unknown)
+ }
BackHandler(focus) {
focusManager.clearFocus()
@@ -112,9 +131,18 @@ fun TextField(
autoCorrectEnabled = false,
imeAction = imeAction ?: if (singleLine) ImeAction.Done else ImeAction.Default
),
+ visualTransformation = visualTransformation,
interactionSource = interactionSource,
modifier = modifier
.fillMaxWidth()
+ .semantics {
+ if (placeholder.isNotBlank()) {
+ contentDescription = placeholder
+ }
+ if (semanticErrorMessage != null) {
+ error(semanticErrorMessage)
+ }
+ }
.focusRequester(focusRequester),
readOnly = readOnly,
cursorBrush = SolidColor(contentColor),
@@ -145,6 +173,7 @@ fun TextField(
if (text.isEmpty()) {
Text(
+ modifier = Modifier.clearTextFieldLabelSemantics(),
text = placeholder,
color = contentColor.copy(.35f),
fontSize = fontSize,
@@ -165,9 +194,11 @@ fun PlaceholderField(
modifier: Modifier = Modifier,
backgroundColor: Color = TextFieldDefaults.containerColor(),
contentColor: Color = TextFieldDefaults.contentColor(),
+ placeholderColor: Color = contentColor.copy(alpha = 0.35f),
shape: Shape = TextFieldDefaults.shape(),
placeholder: String = "",
keyboardType: KeyboardType = KeyboardType.Text,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
fontWeight: FontWeight? = null,
readOnly: Boolean = false,
singleLine: Boolean = true,
@@ -175,12 +206,22 @@ fun PlaceholderField(
imeAction: ImeAction = ImeAction.Done,
keyboardActions: KeyboardActions? = null,
icon: ImageVector? = null,
+ textDirection: TextDirection = TextDirection.Unspecified,
onValueChange: (String) -> Unit = {},
) {
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
val interactionSource = remember { MutableInteractionSource() }
val focus by interactionSource.collectIsFocusedAsState()
+ val density = LocalDensity.current
+ val imeBottom = WindowInsets.ime.getBottom(density)
+
+ LaunchedEffect(focus, imeBottom) {
+ if (focus && imeBottom > 0) {
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
BackHandler(focus) {
focusManager.clearFocus()
@@ -203,7 +244,8 @@ fun PlaceholderField(
fontFamily = MaterialTheme.typography.bodyMedium.fontFamily,
fontSize = fontSize,
color = contentColor,
- fontWeight = fontWeight
+ fontWeight = fontWeight,
+ textDirection = textDirection,
),
onValueChange = {
onValueChange(it)
@@ -218,9 +260,16 @@ fun PlaceholderField(
autoCorrectEnabled = false,
imeAction = imeAction
),
+ visualTransformation = visualTransformation,
interactionSource = interactionSource,
modifier = modifier
+ .bringIntoViewRequester(bringIntoViewRequester)
.fillMaxWidth()
+ .semantics {
+ if (placeholder.isNotBlank()) {
+ contentDescription = placeholder
+ }
+ }
.focusRequester(focusRequester),
readOnly = readOnly,
cursorBrush = SolidColor(contentColor.copy(.35f)),
@@ -253,7 +302,7 @@ fun PlaceholderField(
)
}
- Box(
+ Column(
Modifier
.interactionBorder(
type = InteractionType.PRESS,
@@ -264,41 +313,53 @@ fun PlaceholderField(
.defaultMinSize(minHeight = 56.dp)
.padding(
start = if (icon == null) 15.dp else 0.dp,
- end = 15.dp
- ),
- contentAlignment = Alignment.CenterStart
+ end = 15.dp,
+ top = 7.dp,
+ bottom = 7.dp
+ )
) {
val hasText = text.isNotEmpty()
-
- val animPlaceholder: Dp by animateDpAsState(
- if (focus || hasText) (-10).dp else 0.dp,
- label = "placeholder-translation-y"
- )
+ val showFloatingLabel = focus || hasText
val animPlaceHolderFontSize: Float by animateFloatAsState(
- targetValue = if (focus || hasText) 12f else 14f,
+ targetValue = if (showFloatingLabel) 12f else 14f,
label = "placeholder-font-size"
)
- Text(
- modifier = Modifier
- .graphicsLayer {
- translationY = animPlaceholder.toPx()
- },
- text = placeholder,
- color = contentColor.copy(alpha = .35f),
- fontSize = animPlaceHolderFontSize.sp,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- fontWeight = FontWeight.SemiBold
- )
-
- Box(
- Modifier
- .padding(top = -animPlaceholder)
- .fillMaxWidth()
- .heightIn(18.dp),
- ) {
- innerTextField()
+ if (showFloatingLabel) {
+ Text(
+ modifier = Modifier.clearTextFieldLabelSemantics(),
+ text = placeholder,
+ color = placeholderColor,
+ fontSize = animPlaceHolderFontSize.sp,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ fontWeight = FontWeight.SemiBold
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 18.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ innerTextField()
+ }
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 42.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Text(
+ modifier = Modifier.clearTextFieldLabelSemantics(),
+ text = placeholder,
+ color = placeholderColor,
+ fontSize = animPlaceHolderFontSize.sp,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
}
}
}
@@ -307,6 +368,8 @@ fun PlaceholderField(
}
}
+private fun Modifier.clearTextFieldLabelSemantics(): Modifier = clearAndSetSemantics {}
+
private object TextFieldDefaults {
val TextFontSize = 16.sp
val LabelFontSize = 18.sp
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ThemeSelection.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ThemeSelection.kt
index 988f1a5fe..998610cee 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ThemeSelection.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/ThemeSelection.kt
@@ -6,19 +6,23 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.InfiniteRepeatableSpec
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
-import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
@@ -39,27 +43,41 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.onClick as semanticsOnClick
+import androidx.compose.ui.semantics.onLongClick as semanticsOnLongClick
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
-import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.m3u.core.foundation.architecture.preferences.ThemeStyle
+import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
+import com.m3u.i18n.R.string
import com.m3u.smartphone.ui.material.LocalM3UHapticFeedback
import com.m3u.smartphone.ui.material.ktx.InteractionType
-import com.m3u.smartphone.ui.material.ktx.createScheme
+import com.m3u.smartphone.ui.material.ktx.Edge
+import com.m3u.smartphone.ui.material.ktx.createAppColorScheme
import com.m3u.smartphone.ui.material.ktx.interactionBorder
+import com.m3u.smartphone.ui.material.ktx.resolvePhysicalEdge
import com.m3u.smartphone.ui.material.model.LocalSpacing
-import com.m3u.core.foundation.ui.SugarColors
-import com.m3u.core.foundation.components.AbsoluteSmoothCornerShape
+import java.util.Locale
import kotlin.math.max
/**
@@ -69,115 +87,138 @@ import kotlin.math.max
fun ThemeSelection(
argb: Int,
isDark: Boolean,
+ themeStyle: Int = ThemeStyle.MATERIAL,
selected: Boolean,
+ themeName: String = argb.toUInt().toString(16).uppercase(Locale.ROOT),
onClick: () -> Unit,
- onLongClick: () -> Unit,
+ onLongClick: (() -> Unit)?,
+ onLongClickLabel: String? = null,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
- val colorScheme = remember(argb, isDark) {
- createScheme(argb, isDark)
+ val colorScheme = remember(argb, isDark, themeStyle) {
+ createAppColorScheme(argb, isDark, themeStyle)
}
-
- val alpha by animateFloatAsState(
- targetValue = if (selected) 0f else 0.4f,
- label = "alpha"
- )
- val blurRadius by animateFloatAsState(
- targetValue = if (selected) 0f else 16f,
- label = "blur-radius"
- )
val feedback = LocalM3UHapticFeedback.current
val interactionSource = remember { MutableInteractionSource() }
+ val activateSelection = {
+ if (!selected) {
+ feedback.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
+ onClick()
+ }
+ }
+ val openSelectionEditor = onLongClick?.let { openEditor ->
+ {
+ feedback.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ openEditor()
+ }
+ }
- val content: @Composable () -> Unit = {
- Box(
- modifier = Modifier
- .graphicsLayer {
- if (blurRadius != 0f) renderEffect = BlurEffect(blurRadius, blurRadius)
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = modifier
+ .width(96.dp)
+ .combinedClickable(
+ interactionSource = interactionSource,
+ indication = ripple(),
+ role = Role.RadioButton,
+ onClick = activateSelection,
+ onLongClickLabel = onLongClickLabel,
+ onLongClick = openSelectionEditor,
+ )
+ .clearAndSetSemantics {
+ contentDescription = themeName
+ this.selected = selected
+ role = Role.RadioButton
+ semanticsOnClick {
+ activateSelection()
+ true
}
- .drawWithContent {
- drawContent()
- drawRect(
- color = Color.Black.copy(
- alpha = alpha
- )
- )
+ if (openSelectionEditor != null) {
+ semanticsOnLongClick(label = onLongClickLabel) {
+ openSelectionEditor()
+ true
+ }
}
- )
- }
- Box(
- contentAlignment = Alignment.Center
+ },
) {
- val shape = AbsoluteSmoothCornerShape(spacing.large, 65)
- val brush = Brush.createPremiumBrush(
- colorScheme.primary,
- colorScheme.secondary
- )
- val borderWidth by animateDpAsState(
- if (selected) Dp.Infinity else spacing.extraSmall
- )
+ val shape = RoundedCornerShape(24.dp)
OutlinedCard(
shape = shape,
colors = CardDefaults.outlinedCardColors(
- containerColor = colorScheme.background,
- contentColor = colorScheme.onBackground
+ containerColor = colorScheme.surface,
+ contentColor = colorScheme.onSurface,
),
- border = BorderStroke(borderWidth, brush),
- modifier = modifier
- .size(64.dp)
+ border = BorderStroke(
+ width = if (selected) 3.dp else 1.dp,
+ color = if (selected) colorScheme.primary else colorScheme.outlineVariant,
+ ),
+ modifier = Modifier
+ .size(width = 88.dp, height = 76.dp)
.interactionBorder(
type = InteractionType.PRESS,
source = interactionSource,
shape = shape,
- color = colorScheme.primary
- )
- .combinedClickable(
- onClick = onClick,
- onLongClick = onLongClick,
- indication = null,
- interactionSource = null
- )
+ color = colorScheme.primary,
+ ),
) {
Box(
- modifier = Modifier.combinedClickable(
- interactionSource = interactionSource,
- indication = ripple(),
- onClick = {
- if (selected) return@combinedClickable
- feedback.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
- onClick()
- },
- onLongClick = {
- feedback.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
- onLongClick()
- }
- ),
- content = { content() }
- )
- }
-
- Crossfade(selected, label = "icon") { selected ->
- Icon(
- imageVector = when {
- selected -> Icons.Rounded.CheckCircle
- else -> when (isDark) {
- true -> Icons.Rounded.DarkMode
- false -> Icons.Rounded.LightMode
- }
- },
- contentDescription = "icon",
- tint = when {
- selected -> colorScheme.onPrimary
- else -> when (isDark) {
- true -> SugarColors.Tee.color
- false -> SugarColors.Yellow.color
- }
+ modifier = Modifier
+ .fillMaxSize()
+ .background(colorScheme.background),
+ ) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(start = 10.dp, bottom = 10.dp)
+ .width(48.dp)
+ .height(18.dp)
+ .clip(CircleShape)
+ .background(colorScheme.primary),
+ )
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = 10.dp, end = 10.dp)
+ .size(24.dp)
+ .clip(CircleShape)
+ .background(colorScheme.secondaryContainer),
+ )
+ Crossfade(selected, label = "theme-selection-indicator") { isSelected ->
+ Icon(
+ imageVector = if (isSelected) {
+ Icons.Rounded.CheckCircle
+ } else if (isDark) {
+ Icons.Rounded.DarkMode
+ } else {
+ Icons.Rounded.LightMode
+ },
+ contentDescription = null,
+ tint = if (isSelected) {
+ colorScheme.primary
+ } else {
+ colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier
+ .align(Alignment.Center)
+ .size(24.dp),
+ )
}
- )
+ }
}
+ Text(
+ text = themeName,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center,
+ minLines = 2,
+ maxLines = 2,
+ modifier = Modifier
+ .padding(top = spacing.extraSmall)
+ .width(96.dp),
+ )
}
}
@@ -189,6 +230,7 @@ fun ThemeAddSelection(
onClick: () -> Unit
) {
val spacing = LocalSpacing.current
+ val addDescription = stringResource(string.ui_theme_add)
Box(
contentAlignment = Alignment.Center
) {
@@ -204,14 +246,17 @@ fun ThemeAddSelection(
scaleY = 0.8f
}
.size(96.dp)
- .padding(spacing.extraSmall),
+ .padding(spacing.extraSmall)
+ .semantics(mergeDescendants = true) {
+ contentDescription = addDescription
+ },
onClick = onClick,
content = {}
)
Icon(
imageVector = Icons.Rounded.Add,
- contentDescription = "",
+ contentDescription = null,
tint = contentColor
)
}
@@ -221,11 +266,13 @@ fun ThemeAddSelection(
internal fun MessageItem(
containerColor: Color,
contentColor: Color,
- left: Boolean,
+ alignedToStart: Boolean,
contentDescription: String,
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
+ val logicalTailEdge = if (alignedToStart) Edge.Start else Edge.End
+ val tailEdge = resolvePhysicalEdge(logicalTailEdge, LocalLayoutDirection.current)
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = containerColor,
@@ -234,26 +281,26 @@ internal fun MessageItem(
shape = AbsoluteSmoothCornerShape(
cornerRadiusTL = spacing.small,
cornerRadiusTR = spacing.small,
- cornerRadiusBL = if (left) spacing.none else spacing.small,
- cornerRadiusBR = if (!left) spacing.none else spacing.small
+ cornerRadiusBL = if (tailEdge == Edge.Start) spacing.none else spacing.small,
+ cornerRadiusBR = if (tailEdge == Edge.End) spacing.none else spacing.small
),
modifier = modifier
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
- .sizeIn(minWidth = if (!left) 48.dp else 32.dp)
- .padding(if (left) spacing.extraSmall else spacing.small)
+ .sizeIn(minWidth = if (!alignedToStart) 48.dp else 32.dp)
+ .padding(if (alignedToStart) spacing.extraSmall else spacing.small)
) {
Text(
text = AnnotatedString.fromHtml(contentDescription),
style = MaterialTheme.typography.bodyLarge
.copy(
- fontSize = if (!left) 14.sp
+ fontSize = if (!alignedToStart) 14.sp
else 12.sp
),
color = contentColor,
- lineHeight = if (!left) 16.sp
+ lineHeight = if (!alignedToStart) 16.sp
else 14.sp
)
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskButton.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskButton.kt
index 6f82379f1..f717ea3cd 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskButton.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskButton.kt
@@ -33,7 +33,7 @@ fun MaskButton(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
- Text(text = contentDescription.uppercase())
+ Text(text = contentDescription)
}
}
) {
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskCircleButton.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskCircleButton.kt
index 859645dc3..2e730ef04 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskCircleButton.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskCircleButton.kt
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
fun MaskCircleButton(
state: MaskState,
icon: ImageVector,
+ contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tint: Color = Color.Unspecified,
@@ -39,8 +40,8 @@ fun MaskCircleButton(
) {
Icon(
imageVector = icon,
- contentDescription = null,
+ contentDescription = contentDescription,
modifier = Modifier.size(dimension)
)
}
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskState.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskState.kt
index c1ed20f33..5a89dae8f 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskState.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/components/mask/MaskState.kt
@@ -2,6 +2,7 @@ package com.m3u.smartphone.ui.material.components.mask
import androidx.annotation.IntRange
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -10,6 +11,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalAccessibilityManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -41,31 +43,35 @@ private class MaskStateCoroutineImpl(
) : MaskState, CoroutineScope by coroutineScope {
private var currentTime: Long by mutableLongStateOf(systemClock)
private var lastTime: Long by mutableLongStateOf(0L)
+ private var explicitlySleeping by mutableStateOf(true)
private var keys by mutableStateOf>(emptySet())
override val locked: Boolean = keys.isNotEmpty()
override val visible: Boolean by derivedStateOf {
- val before = (locked || (currentTime - lastTime <= minDuration))
+ val before = locked || (!explicitlySleeping && currentTime - lastTime <= minDuration)
interceptor?.invoke(before) ?: before
}
init {
if (minDuration < 1L) error("minSecondDuration cannot less than 1s.")
- launch {
- while (true) {
- delay(1000L)
- currentTime += 1
- }
+ }
+
+ private val tickerJob = launch {
+ while (true) {
+ delay(1000L)
+ currentTime += 1
}
}
private val systemClock: Long get() = System.currentTimeMillis() / 1000
override fun wake(duration: Duration) {
+ explicitlySleeping = false
lastTime = currentTime + duration.inWholeMilliseconds / 1000
}
override fun sleep() {
+ explicitlySleeping = true
lastTime = 0
val iterator = unlockedJobs.iterator()
while (iterator.hasNext()) {
@@ -116,6 +122,12 @@ private class MaskStateCoroutineImpl(
override fun intercept(interceptor: MaskInterceptor?) {
this.interceptor = interceptor
}
+
+ fun dispose() {
+ tickerJob.cancel()
+ unlockedJobs.values.forEach(Job::cancel)
+ unlockedJobs.clear()
+ }
}
@Composable
@@ -123,10 +135,41 @@ fun rememberMaskState(
@IntRange(from = 1) minDuration: Long = MaskDefaults.MIN_DURATION_SECOND,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
): MaskState {
- return remember(minDuration, coroutineScope) {
+ val accessibilityManager = LocalAccessibilityManager.current
+ val baseDurationMillis = minDuration.saturatedMilliseconds()
+ val recommendedDurationMillis = accessibilityManager?.calculateRecommendedTimeoutMillis(
+ originalTimeoutMillis = baseDurationMillis,
+ containsIcons = true,
+ containsText = true,
+ containsControls = true,
+ ) ?: baseDurationMillis
+ val effectiveDuration = calculateRecommendedMaskDurationSeconds(
+ baseDurationSeconds = minDuration,
+ recommendedDurationMillis = recommendedDurationMillis,
+ )
+ val state = remember(effectiveDuration, coroutineScope) {
MaskStateCoroutineImpl(
- minDuration = minDuration,
+ minDuration = effectiveDuration,
coroutineScope = coroutineScope,
)
}
+ DisposableEffect(state) {
+ onDispose(state::dispose)
+ }
+ return state
+}
+
+internal fun calculateRecommendedMaskDurationSeconds(
+ baseDurationSeconds: Long,
+ recommendedDurationMillis: Long,
+): Long {
+ require(baseDurationSeconds >= 1L)
+ if (recommendedDurationMillis == Long.MAX_VALUE) return Long.MAX_VALUE
+ val recommendedSeconds = recommendedDurationMillis / 1000L +
+ if (recommendedDurationMillis % 1000L == 0L) 0L else 1L
+ return recommendedSeconds.coerceAtLeast(baseDurationSeconds)
+}
+
+private fun Long.saturatedMilliseconds(): Long {
+ return if (this > Long.MAX_VALUE / 1000L) Long.MAX_VALUE else this * 1000L
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Blurs.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Blurs.kt
index f50606b8b..c9791f652 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Blurs.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Blurs.kt
@@ -9,6 +9,8 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
sealed interface Edge {
data object Start : Edge
@@ -26,20 +28,23 @@ fun Modifier.blurEdge(
enable: Boolean = true,
dimen: Float = BlurDefaults.DIMEN
): Modifier {
- return if (enable) drawWithCache {
- val brush = brush(
- colors = colors(color, edge),
- edge = edge,
- full = full(edge, size),
- dimen = dimen
- )
- onDrawWithContent {
- drawContent()
- drawRect(
- brush = brush,
- topLeft = topLeft(size, edge, dimen),
- size = size(size, edge, dimen),
+ return if (enable) composed {
+ val resolvedEdge = resolvePhysicalEdge(edge, LocalLayoutDirection.current)
+ Modifier.drawWithCache {
+ val brush = brush(
+ colors = colors(color, resolvedEdge),
+ edge = resolvedEdge,
+ full = full(resolvedEdge, size),
+ dimen = dimen
)
+ onDrawWithContent {
+ drawContent()
+ drawRect(
+ brush = brush,
+ topLeft = topLeft(size, resolvedEdge, dimen),
+ size = size(size, resolvedEdge, dimen),
+ )
+ }
}
} else this
}
@@ -51,17 +56,19 @@ fun Modifier.blurEdges(
dimen: Float = BlurDefaults.DIMEN
): Modifier {
return if (edges.size == 1) blurEdge(color, edges.first(), enable, dimen)
- else if (!enable || edges.isEmpty()) Modifier else composed {
+ else if (!enable || edges.isEmpty()) this else composed {
val currentColor by animateColorAsState(
targetValue = color,
label = "brushColor"
)
+ val layoutDirection = LocalLayoutDirection.current
Modifier.drawWithCache {
- val brushes = edges.map { edge ->
- edge to brush(
- colors = colors(currentColor, edge),
- edge = edge,
- full = full(edge, size),
+ val brushes = edges.map { logicalEdge ->
+ val resolvedEdge = resolvePhysicalEdge(logicalEdge, layoutDirection)
+ resolvedEdge to brush(
+ colors = colors(currentColor, resolvedEdge),
+ edge = resolvedEdge,
+ full = full(resolvedEdge, size),
dimen = dimen
)
}
@@ -79,6 +86,15 @@ fun Modifier.blurEdges(
}
}
+internal fun resolvePhysicalEdge(
+ edge: Edge,
+ layoutDirection: LayoutDirection,
+): Edge = when {
+ layoutDirection == LayoutDirection.Rtl && edge == Edge.Start -> Edge.End
+ layoutDirection == LayoutDirection.Rtl && edge == Edge.End -> Edge.Start
+ else -> edge
+}
+
private fun full(edge: Edge, size: Size): Float = when {
edge.isVertical -> size.height
else -> size.width
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Colors.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Colors.kt
index 1840d201a..9e45d70cb 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Colors.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/Colors.kt
@@ -5,8 +5,20 @@ import androidx.annotation.ColorInt
import androidx.annotation.IntRange
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
import com.google.android.material.color.utilities.Hct
import com.google.android.material.color.utilities.Scheme
+import com.m3u.core.foundation.architecture.preferences.ThemePreset
+import com.m3u.core.foundation.architecture.preferences.ThemeStyle
+
+fun createAppColorScheme(
+ argb: Int,
+ isDark: Boolean,
+ themeStyle: Int,
+): ColorScheme = when (themeStyle) {
+ ThemeStyle.WARM_EDITORIAL -> createWarmEditorialScheme(isDark)
+ else -> createScheme(argb, isDark)
+}
@SuppressLint("RestrictedApi")
fun createScheme(
@@ -35,7 +47,7 @@ fun createScheme(
onSurface = Color(scheme.onSurface),
surfaceVariant = Color(scheme.surfaceVariant),
onSurfaceVariant = Color(scheme.onSurfaceVariant),
- surfaceTint = Color(scheme.surfaceVariant), // todo
+ surfaceTint = Color(scheme.primary),
inverseSurface = Color(scheme.inverseSurface),
inverseOnSurface = Color(scheme.inverseOnSurface),
error = Color(scheme.error),
@@ -61,10 +73,121 @@ fun createScheme(
surfaceContainerHigh = getColor(argb, if (!isDark) 92 else 17, 6),
surfaceContainerHighest = getColor(argb, if (!isDark) 90 else 22, 6),
surfaceContainerLow = getColor(argb, if (!isDark) 96 else 10, 6),
- surfaceContainerLowest = getColor(argb, if (!isDark) 100 else 4, 6)
+ surfaceContainerLowest = getColor(argb, if (!isDark) 100 else 4, 6),
+ primaryFixed = Color(getColor(scheme.primary, 90)),
+ primaryFixedDim = Color(getColor(scheme.primary, 80)),
+ onPrimaryFixed = Color(getColor(scheme.primary, 10)),
+ onPrimaryFixedVariant = Color(getColor(scheme.primary, 30)),
+ secondaryFixed = Color(getColor(scheme.secondary, 90)),
+ secondaryFixedDim = Color(getColor(scheme.secondary, 80)),
+ onSecondaryFixed = Color(getColor(scheme.secondary, 10)),
+ onSecondaryFixedVariant = Color(getColor(scheme.secondary, 30)),
+ tertiaryFixed = Color(getColor(scheme.tertiary, 90)),
+ tertiaryFixedDim = Color(getColor(scheme.tertiary, 80)),
+ onTertiaryFixed = Color(getColor(scheme.tertiary, 10)),
+ onTertiaryFixedVariant = Color(getColor(scheme.tertiary, 30)),
)
}
+/**
+ * A warm editorial palette inspired by paper, ink, and restrained print accents.
+ *
+ * These are semantic Material color roles, not a direct copy of another product's
+ * brand tokens. Text/container pairs are deliberately kept above WCAG AA contrast.
+ */
+private fun createWarmEditorialScheme(isDark: Boolean): ColorScheme {
+ val base = createScheme(ThemePreset.WARM_EDITORIAL_SEED, isDark)
+ val scheme = if (isDark) {
+ base.copy(
+ primary = Color(0xFFF0A58D),
+ onPrimary = Color(0xFF4E1406),
+ primaryContainer = Color(0xFF70301C),
+ onPrimaryContainer = Color(0xFFFFDAD0),
+ inversePrimary = Color(0xFF9B422B),
+ secondary = Color(0xFFB8CEA0),
+ onSecondary = Color(0xFF263417),
+ secondaryContainer = Color(0xFF3C4C2A),
+ onSecondaryContainer = Color(0xFFDCE8C9),
+ tertiary = Color(0xFFA5CBE4),
+ onTertiary = Color(0xFF0C344A),
+ tertiaryContainer = Color(0xFF264D63),
+ onTertiaryContainer = Color(0xFFCDE5F7),
+ background = Color(0xFF141413),
+ onBackground = Color(0xFFF2F0E8),
+ surface = Color(0xFF141413),
+ onSurface = Color(0xFFF2F0E8),
+ surfaceVariant = Color(0xFF474640),
+ onSurfaceVariant = Color(0xFFC9C6BD),
+ surfaceTint = Color(0xFFF0A58D),
+ inverseSurface = Color(0xFFE6E3DA),
+ inverseOnSurface = Color(0xFF2F2E2A),
+ outline = Color(0xFF938F85),
+ outlineVariant = Color(0xFF474640),
+ surfaceBright = Color(0xFF3A3934),
+ surfaceDim = Color(0xFF141413),
+ surfaceContainerLowest = Color(0xFF10100F),
+ surfaceContainerLow = Color(0xFF1B1B19),
+ surfaceContainer = Color(0xFF201F1D),
+ surfaceContainerHigh = Color(0xFF2A2926),
+ surfaceContainerHighest = Color(0xFF34332F),
+ )
+ } else {
+ base.copy(
+ primary = Color(0xFF9B422B),
+ onPrimary = Color.White,
+ primaryContainer = Color(0xFFF4D4C8),
+ onPrimaryContainer = Color(0xFF351008),
+ inversePrimary = Color(0xFFF0A58D),
+ secondary = Color(0xFF53653F),
+ onSecondary = Color.White,
+ secondaryContainer = Color(0xFFDCE8C9),
+ onSecondaryContainer = Color(0xFF152109),
+ tertiary = Color(0xFF3F6784),
+ onTertiary = Color.White,
+ tertiaryContainer = Color(0xFFCDE5F7),
+ onTertiaryContainer = Color(0xFF071E2C),
+ background = Color(0xFFFAF9F5),
+ onBackground = Color(0xFF1F1F1C),
+ surface = Color(0xFFFAF9F5),
+ onSurface = Color(0xFF1F1F1C),
+ surfaceVariant = Color(0xFFE8E6DC),
+ onSurfaceVariant = Color(0xFF4B4942),
+ surfaceTint = Color(0xFF9B422B),
+ inverseSurface = Color(0xFF31302C),
+ inverseOnSurface = Color(0xFFF3F1EA),
+ outline = Color(0xFF79776F),
+ outlineVariant = Color(0xFFCBC9C0),
+ surfaceBright = Color(0xFFFAF9F5),
+ surfaceDim = Color(0xFFDDDCD4),
+ surfaceContainerLowest = Color.White,
+ surfaceContainerLow = Color(0xFFF5F3EC),
+ surfaceContainer = Color(0xFFEFEEE7),
+ surfaceContainerHigh = Color(0xFFE9E7DF),
+ surfaceContainerHighest = Color(0xFFE3E1D9),
+ )
+ }
+ return scheme.copy(
+ primaryFixed = Color(0xFFF4D4C8),
+ primaryFixedDim = Color(0xFFF0A58D),
+ onPrimaryFixed = Color(0xFF351008),
+ onPrimaryFixedVariant = Color(0xFF70301C),
+ secondaryFixed = Color(0xFFDCE8C9),
+ secondaryFixedDim = Color(0xFFB8CEA0),
+ onSecondaryFixed = Color(0xFF152109),
+ onSecondaryFixedVariant = Color(0xFF3C4C2A),
+ tertiaryFixed = Color(0xFFCDE5F7),
+ tertiaryFixedDim = Color(0xFFA5CBE4),
+ onTertiaryFixed = Color(0xFF071E2C),
+ onTertiaryFixedVariant = Color(0xFF264D63),
+ )
+}
+
+internal fun contrastingContentColor(background: Color): Color =
+ if (background.luminance() > DARK_CONTENT_LUMINANCE_THRESHOLD) {
+ Color.Black
+ } else {
+ Color.White
+ }
@ColorInt
@SuppressLint("RestrictedApi")
@@ -88,3 +211,5 @@ private fun getColor(
hctColor.chroma = chroma.toDouble()
return Color(hctColor.toInt())
}
+
+private const val DARK_CONTENT_LUMINANCE_THRESHOLD = 0.179f
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/PaddingValues.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/PaddingValues.kt
index e1cb66fec..293e3ac7c 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/PaddingValues.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/PaddingValues.kt
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
@Composable
operator fun PaddingValues.plus(another: PaddingValues): PaddingValues {
@@ -31,13 +32,19 @@ operator fun PaddingValues.minus(another: PaddingValues): PaddingValues {
@Composable
infix fun PaddingValues.only(side: WindowInsetsSides): PaddingValues {
- val layoutDirection = LocalLayoutDirection.current
+ return only(side, LocalLayoutDirection.current)
+}
+
+internal fun PaddingValues.only(
+ side: WindowInsetsSides,
+ layoutDirection: LayoutDirection,
+): PaddingValues {
return when (side) {
WindowInsetsSides.Start ->
PaddingValues(start = calculateStartPadding(layoutDirection))
WindowInsetsSides.End ->
- PaddingValues(end = calculateStartPadding(layoutDirection))
+ PaddingValues(end = calculateEndPadding(layoutDirection))
WindowInsetsSides.Top -> PaddingValues(top = calculateTopPadding())
WindowInsetsSides.Bottom -> PaddingValues(bottom = calculateBottomPadding())
@@ -49,4 +56,4 @@ infix fun PaddingValues.only(side: WindowInsetsSides): PaddingValues {
infix fun PaddingValues.split(side: WindowInsetsSides?): Pair {
if (side == null) return PaddingValues() to this
return (this only side) to (this - (this only side))
-}
\ No newline at end of file
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/SourceReferenceFormatter.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/SourceReferenceFormatter.kt
new file mode 100644
index 000000000..1d2c08aa8
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/SourceReferenceFormatter.kt
@@ -0,0 +1,49 @@
+package com.m3u.smartphone.ui.material.ktx
+
+import com.m3u.core.foundation.util.basic.sanitizeDisplayText
+
+private const val MAX_SOURCE_REFERENCE_LENGTH = 256
+
+/**
+ * Returns only the part of a source reference that is useful for identification
+ * without exposing credentials commonly embedded in URLs.
+ */
+internal fun String.safeSourceReference(): String? {
+ // Strip unsafe controls without truncating first. Truncating before removing URL user info
+ // could turn a long credential into what looks like a safe authority.
+ val clean = sanitizeDisplayText(
+ maximumCharacters = length,
+ maximumUtf8Bytes = Int.MAX_VALUE,
+ ).trim()
+ if (clean.isEmpty()) return null
+
+ val schemeSeparator = clean.indexOf("://")
+ if (schemeSeparator <= 0) return null
+ val scheme = clean.substring(0, schemeSeparator).lowercase()
+ val remainder = clean.substring(schemeSeparator + 3)
+
+ return when (scheme) {
+ "http", "https" -> {
+ val authority = remainder
+ .substringBefore('/')
+ .substringBefore('?')
+ .substringBefore('#')
+ .substringAfterLast('@')
+ .takeIf(String::isNotBlank)
+ ?: return null
+ "$scheme://$authority"
+ .take(MAX_SOURCE_REFERENCE_LENGTH)
+ }
+
+ "content", "file" -> remainder
+ .substringBefore('?')
+ .substringBefore('#')
+ .trimEnd('/')
+ .substringAfterLast('/')
+ .substringAfterLast(':')
+ .takeIf(String::isNotBlank)
+ ?.take(MAX_SOURCE_REFERENCE_LENGTH)
+
+ else -> null
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/UiBidiFormatter.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/UiBidiFormatter.kt
new file mode 100644
index 000000000..025f9f4f8
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/ktx/UiBidiFormatter.kt
@@ -0,0 +1,57 @@
+package com.m3u.smartphone.ui.material.ktx
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.core.text.BidiFormatter
+import androidx.core.text.TextDirectionHeuristicsCompat
+import com.m3u.core.foundation.util.basic.sanitizeDisplayText
+
+@Composable
+internal fun rememberUiBidiFormatter(): UiBidiFormatter {
+ val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+ return remember(isRtl) { UiBidiFormatter(isRtl) }
+}
+
+internal class UiBidiFormatter(isRtlContext: Boolean) {
+ private val formatter = BidiFormatter.getInstance(isRtlContext)
+
+ fun natural(
+ value: String,
+ maximumCharacters: Int = DEFAULT_DISPLAY_CHARACTER_LIMIT,
+ ): String = formatter.unicodeWrap(
+ value.safeDisplayText(maximumCharacters),
+ )
+
+ fun ltr(value: String): String = formatter.unicodeWrap(
+ value.safeDisplayText(),
+ TextDirectionHeuristicsCompat.LTR,
+ )
+
+ /**
+ * Sanitizes a standalone technical value without adding bidirectional formatting controls.
+ *
+ * The returned plain text keeps its original character order and full sanitized length, so it
+ * can be displayed with an LTR text direction and copied without hidden formatter markers.
+ */
+ fun standaloneTechnical(value: String): String = value.withoutBidiControls()
+}
+
+internal fun String.safeDisplayText(
+ maximumCharacters: Int = DEFAULT_DISPLAY_CHARACTER_LIMIT,
+): String = sanitizeDisplayText(
+ maximumCharacters = maximumCharacters,
+ maximumUtf8Bytes = maximumCharacters.saturatingTimes(MAX_UTF8_BYTES_PER_CODE_POINT),
+)
+
+internal fun String.withoutBidiControls(): String = sanitizeDisplayText(
+ maximumCharacters = length,
+ maximumUtf8Bytes = Int.MAX_VALUE,
+)
+
+private fun Int.saturatingTimes(multiplier: Int): Int =
+ if (this > Int.MAX_VALUE / multiplier) Int.MAX_VALUE else this * multiplier
+
+private const val DEFAULT_DISPLAY_CHARACTER_LIMIT = 256
+private const val MAX_UTF8_BYTES_PER_CODE_POINT = 4
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/model/Theme.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/model/Theme.kt
index 04cb9c91f..ef0a53327 100644
--- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/model/Theme.kt
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/material/model/Theme.kt
@@ -8,14 +8,20 @@ import androidx.compose.material3.Typography
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext
-import com.m3u.smartphone.ui.material.ktx.createScheme
+import com.m3u.core.foundation.architecture.preferences.ThemeStyle
+import com.m3u.smartphone.ui.material.ktx.createAppColorScheme
+
+val LocalThemeStyle = staticCompositionLocalOf { ThemeStyle.MATERIAL }
@Composable
@SuppressLint("RestrictedApi")
fun Theme(
argb: Int,
+ themeStyle: Int,
useDynamicColors: Boolean,
useDarkTheme: Boolean = isSystemInDarkTheme(),
typography: Typography,
@@ -23,18 +29,23 @@ fun Theme(
) {
val context = LocalContext.current
val supportsDynamicTheming = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
- val colorScheme = remember(useDynamicColors, useDarkTheme, argb, context) {
- if (useDynamicColors && supportsDynamicTheming) {
- if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
- } else {
- createScheme(argb, useDarkTheme)
+ val colorScheme = if (useDynamicColors && supportsDynamicTheming) {
+ if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ } else {
+ remember(useDarkTheme, argb, themeStyle) {
+ createAppColorScheme(argb, useDarkTheme, themeStyle)
}
}
+ val resolvedStyle = themeStyle.takeIf { it == ThemeStyle.WARM_EDITORIAL }
+ ?: ThemeStyle.MATERIAL
- MaterialTheme(
- colorScheme = colorScheme,
- typography = typography
+ CompositionLocalProvider(
+ LocalThemeStyle provides resolvedStyle,
) {
- content()
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = typography,
+ content = content,
+ )
}
}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/navigation/AppNavigationLayout.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/navigation/AppNavigationLayout.kt
new file mode 100644
index 000000000..d1ad7ec3d
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/navigation/AppNavigationLayout.kt
@@ -0,0 +1,164 @@
+package com.m3u.smartphone.ui.navigation
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+internal enum class AppNavigationMode {
+ BottomOverlay,
+ SideRail,
+}
+
+@Immutable
+internal data class AppContentInsets(
+ val layoutPadding: PaddingValues,
+ val contentPadding: PaddingValues,
+ val navigationClearance: Dp,
+)
+
+internal fun resolveAppNavigationMode(windowWidth: Dp): AppNavigationMode =
+ if (windowWidth < 600.dp) {
+ AppNavigationMode.BottomOverlay
+ } else {
+ AppNavigationMode.SideRail
+ }
+
+internal fun shouldShowBottomNavigation(
+ mode: AppNavigationMode,
+ isTopLevelRoute: Boolean,
+ isSearchActive: Boolean,
+ isImeVisible: Boolean,
+): Boolean = mode == AppNavigationMode.BottomOverlay &&
+ isTopLevelRoute &&
+ !isSearchActive &&
+ !isImeVisible
+
+internal fun shouldShowContextualTopBar(
+ isRootPlaylistConfiguration: Boolean,
+ isNestedDetailVisible: Boolean,
+): Boolean = isRootPlaylistConfiguration || isNestedDetailVisible
+
+internal fun shouldShowRemoteControlAction(
+ remoteControlEnabled: Boolean,
+ isSearchActive: Boolean,
+ isImeVisible: Boolean,
+ isRootPlaylistConfiguration: Boolean,
+ isNestedDetailVisible: Boolean,
+): Boolean = remoteControlEnabled &&
+ !isSearchActive &&
+ !isImeVisible &&
+ !isRootPlaylistConfiguration &&
+ !isNestedDetailVisible
+
+internal fun shouldReserveBottomNavigationSpace(
+ mode: AppNavigationMode,
+ isNavigationCurrentlyVisible: Boolean,
+ isNavigationTargetVisible: Boolean,
+): Boolean = mode == AppNavigationMode.BottomOverlay &&
+ (isNavigationCurrentlyVisible || isNavigationTargetVisible)
+
+internal fun shouldCaptureNavigationBackdrop(
+ mode: AppNavigationMode,
+ supportsBackdropEffects: Boolean,
+ isNavigationCurrentlyVisible: Boolean,
+ isNavigationTargetVisible: Boolean,
+): Boolean = supportsBackdropEffects &&
+ shouldReserveBottomNavigationSpace(
+ mode = mode,
+ isNavigationCurrentlyVisible = isNavigationCurrentlyVisible,
+ isNavigationTargetVisible = isNavigationTargetVisible,
+ )
+
+internal fun shouldShowBottomEdgeBlur(mode: AppNavigationMode): Boolean =
+ mode == AppNavigationMode.SideRail
+
+internal fun calculateFloatingNavigationWidth(
+ containerWidth: Dp,
+ itemCount: Int,
+ maximumWidth: Dp = 360.dp,
+): Dp {
+ val safeItemCount = itemCount.coerceAtLeast(1)
+ val innerHorizontalPadding = 8.dp
+ val preferredWidth = (76.dp * safeItemCount) + innerHorizontalPadding
+ val minimumWidth = (52.dp * safeItemCount) + innerHorizontalPadding
+ val widthWithinEdges = (containerWidth - 20.dp * 2)
+ .coerceAtLeast(minimumWidth)
+
+ return minOf(
+ preferredWidth,
+ widthWithinEdges,
+ maximumWidth,
+ containerWidth,
+ )
+}
+
+internal fun calculateFloatingNavigationDockNavigationWidth(
+ containerWidth: Dp,
+ itemCount: Int,
+ trailingActionVisible: Boolean,
+ trailingActionSlotWidth: Dp = 76.dp,
+): Dp {
+ val availableWidth = containerWidth - if (trailingActionVisible) {
+ trailingActionSlotWidth
+ } else {
+ 0.dp
+ }
+ return calculateFloatingNavigationWidth(
+ containerWidth = availableWidth.coerceAtLeast(0.dp),
+ itemCount = itemCount,
+ )
+}
+
+internal fun calculateLayoutPadding(
+ mode: AppNavigationMode,
+ safeStartInset: Dp,
+ safeEndInset: Dp,
+): PaddingValues = when (mode) {
+ AppNavigationMode.BottomOverlay -> PaddingValues(
+ start = safeStartInset,
+ end = safeEndInset,
+ )
+
+ AppNavigationMode.SideRail -> PaddingValues(end = safeEndInset)
+}
+
+internal fun calculateContentBottomPadding(
+ mode: AppNavigationMode,
+ isTopLevelRoute: Boolean,
+ safeBottomInset: Dp,
+ measuredNavigationHeight: Dp,
+ floatingUtilityHeight: Dp = 0.dp,
+ navigationBottomGap: Dp = 12.dp,
+ contentGap: Dp = 12.dp,
+ regularContentSpacing: Dp = 16.dp,
+): Dp {
+ if (mode == AppNavigationMode.SideRail) {
+ return if (floatingUtilityHeight > 0.dp) {
+ safeBottomInset + regularContentSpacing + floatingUtilityHeight + contentGap
+ } else {
+ safeBottomInset
+ }
+ }
+
+ val navigationPadding = when {
+ isTopLevelRoute -> {
+ safeBottomInset + navigationBottomGap + measuredNavigationHeight + contentGap
+ }
+
+ else -> {
+ safeBottomInset + regularContentSpacing
+ }
+ }
+ if (floatingUtilityHeight <= 0.dp) return navigationPadding
+
+ val utilityBottomPadding = if (isTopLevelRoute) {
+ navigationPadding
+ } else {
+ safeBottomInset + regularContentSpacing
+ }
+ return maxOf(
+ navigationPadding,
+ utilityBottomPadding + floatingUtilityHeight + contentGap,
+ )
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/navigation/FloatingAppNavigationBar.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/navigation/FloatingAppNavigationBar.kt
new file mode 100644
index 000000000..3180ab40c
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/navigation/FloatingAppNavigationBar.kt
@@ -0,0 +1,1099 @@
+package com.m3u.smartphone.ui.navigation
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
+import androidx.compose.foundation.gestures.horizontalDrag
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.SettingsRemote
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.AbsoluteAlignment
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.util.VelocityTracker
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import com.kyant.backdrop.Backdrop
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
+import com.kyant.backdrop.drawBackdrop
+import com.kyant.backdrop.effects.blur
+import com.kyant.backdrop.effects.lens
+import com.kyant.backdrop.effects.vibrancy
+import com.kyant.backdrop.highlight.Highlight
+import com.kyant.backdrop.shadow.InnerShadow
+import com.kyant.backdrop.shadow.Shadow
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.material.components.Destination
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+import kotlin.math.roundToInt
+
+internal const val FLOATING_NAVIGATION_TEST_TAG = "floating-app-navigation"
+internal const val FLOATING_REMOTE_CONTROL_TEST_TAG = "floating-remote-control-action"
+
+internal class FloatingNavigationSettleController {
+ var job: Job? = null
+ var generation: Long = 0L
+ private set
+
+ fun invalidate(): Long {
+ generation += 1L
+ job?.cancel()
+ job = null
+ return generation
+ }
+
+ fun isCurrent(candidate: Long): Boolean = candidate == generation
+}
+
+@Composable
+internal fun FloatingAppNavigationDock(
+ selectedDestination: Destination?,
+ backdrop: Backdrop,
+ useBackdropEffects: Boolean,
+ enabled: Boolean,
+ remoteControlVisible: Boolean,
+ onDestinationSelected: (Destination) -> Unit,
+ onOpenRemoteControl: () -> Unit,
+ onHeightChanged: (Dp) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BoxWithConstraints(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ val showRemoteControl = enabled && remoteControlVisible
+ val targetNavigationWidth = calculateFloatingNavigationDockNavigationWidth(
+ containerWidth = maxWidth,
+ itemCount = Destination.entries.size,
+ trailingActionVisible = showRemoteControl,
+ trailingActionSlotWidth = FLOATING_REMOTE_CONTROL_SLOT_WIDTH,
+ )
+ val navigationWidth by animateDpAsState(
+ targetValue = targetNavigationWidth,
+ label = "floating-navigation-dock-width",
+ )
+ val remoteControlSlotWidth by animateDpAsState(
+ targetValue = if (showRemoteControl) {
+ FLOATING_REMOTE_CONTROL_SLOT_WIDTH
+ } else {
+ 0.dp
+ },
+ label = "floating-remote-control-slot-width",
+ )
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .width(navigationWidth + remoteControlSlotWidth)
+ .height(FLOATING_NAVIGATION_HEIGHT),
+ ) {
+ FloatingAppNavigationBar(
+ selectedDestination = selectedDestination,
+ backdrop = backdrop,
+ useBackdropEffects = useBackdropEffects,
+ enabled = enabled,
+ onDestinationSelected = onDestinationSelected,
+ onHeightChanged = onHeightChanged,
+ widthResolvedByParent = true,
+ modifier = Modifier.width(navigationWidth),
+ )
+ Box(
+ contentAlignment = Alignment.CenterEnd,
+ modifier = Modifier
+ .width(remoteControlSlotWidth)
+ .fillMaxHeight(),
+ ) {
+ FloatingRemoteControlVisibility(
+ visible = showRemoteControl,
+ backdrop = backdrop,
+ useBackdropEffects = useBackdropEffects,
+ enabled = enabled,
+ onClick = onOpenRemoteControl,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun FloatingRemoteControlVisibility(
+ visible: Boolean,
+ backdrop: Backdrop,
+ useBackdropEffects: Boolean,
+ enabled: Boolean,
+ onClick: () -> Unit,
+) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn() + scaleIn(initialScale = 0.78f),
+ exit = fadeOut() + scaleOut(targetScale = 0.78f),
+ ) {
+ FloatingRemoteControlAction(
+ backdrop = backdrop,
+ useBackdropEffects = useBackdropEffects,
+ enabled = enabled,
+ onClick = onClick,
+ )
+ }
+}
+
+@Composable
+private fun FloatingRemoteControlAction(
+ backdrop: Backdrop,
+ useBackdropEffects: Boolean,
+ enabled: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val density = LocalDensity.current
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val pressScale by animateFloatAsState(
+ targetValue = if (isPressed) 0.92f else 1f,
+ animationSpec = spring(
+ dampingRatio = 0.72f,
+ stiffness = 620f,
+ ),
+ label = "floating-remote-control-press",
+ )
+ val glassTokens = resolveFloatingNavigationGlassTokens(
+ colorScheme = MaterialTheme.colorScheme,
+ useBackdropEffects = useBackdropEffects,
+ )
+ val actionSizePx = with(density) { FLOATING_REMOTE_CONTROL_SIZE.toPx() }
+ val blurRadiusPx = with(density) { (FLOATING_NAVIGATION_INNER_PADDING * 2).toPx() }
+ val surfaceModifier = if (useBackdropEffects) {
+ Modifier.drawBackdrop(
+ backdrop = backdrop,
+ shape = { CircleShape },
+ effects = {
+ vibrancy()
+ blur(blurRadiusPx)
+ lens(
+ refractionHeight = actionSizePx * SHELL_REFRACTION_HEIGHT_SHARE,
+ refractionAmount = actionSizePx * REMOTE_ACTION_REFRACTION_AMOUNT_SHARE,
+ depthEffect = true,
+ )
+ },
+ highlight = {
+ Highlight.Default.copy(alpha = glassTokens.highlightAlpha)
+ },
+ shadow = {
+ Shadow.Default.copy(color = glassTokens.shadowColor)
+ },
+ layerBlock = {
+ scaleX = pressScale
+ scaleY = pressScale
+ },
+ onDrawSurface = {
+ drawRect(glassTokens.surfaceColor)
+ },
+ )
+ } else {
+ Modifier
+ .graphicsLayer {
+ scaleX = pressScale
+ scaleY = pressScale
+ }
+ .shadow(
+ elevation = 8.dp,
+ shape = CircleShape,
+ clip = false,
+ )
+ .background(glassTokens.surfaceColor, CircleShape)
+ }
+ val label = stringResource(string.feat_setting_remote_control)
+ val interactionModifier = if (enabled) {
+ Modifier
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ role = Role.Button,
+ onClickLabel = label,
+ onClick = onClick,
+ )
+ .semantics {
+ contentDescription = label
+ role = Role.Button
+ }
+ } else {
+ Modifier.clearAndSetSemantics {}
+ }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .size(FLOATING_REMOTE_CONTROL_SIZE)
+ .then(surfaceModifier)
+ .border(
+ width = 1.dp,
+ color = glassTokens.outlineColor,
+ shape = CircleShape,
+ )
+ .clip(CircleShape)
+ .testTag(FLOATING_REMOTE_CONTROL_TEST_TAG)
+ .then(interactionModifier),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.SettingsRemote,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(NAVIGATION_ICON_SIZE),
+ )
+ }
+}
+
+@Composable
+internal fun FloatingAppNavigationBar(
+ selectedDestination: Destination?,
+ backdrop: Backdrop,
+ useBackdropEffects: Boolean,
+ enabled: Boolean,
+ onDestinationSelected: (Destination) -> Unit,
+ onHeightChanged: (Dp) -> Unit,
+ widthResolvedByParent: Boolean = false,
+ modifier: Modifier = Modifier,
+) {
+ val destinations = Destination.entries
+ val selectedIndex = destinations.indexOf(selectedDestination).coerceAtLeast(0)
+ val density = LocalDensity.current
+ val hapticFeedback = LocalHapticFeedback.current
+ val layoutDirection = LocalLayoutDirection.current
+ val animationScope = rememberCoroutineScope()
+ val currentOnDestinationSelected by rememberUpdatedState(onDestinationSelected)
+ val currentSelectedIndex by rememberUpdatedState(selectedIndex)
+ val currentEnabled by rememberUpdatedState(enabled)
+ val settleController = remember { FloatingNavigationSettleController() }
+ val navigationContentBackdrop = rememberLayerBackdrop()
+ val indicatorBackdrop = rememberCombinedBackdrop(
+ backdrop,
+ navigationContentBackdrop,
+ )
+ val indicatorPosition = remember(destinations.size) {
+ Animatable(selectedIndex.toFloat(), visibilityThreshold = 0.001f)
+ }
+ val flowVelocity = remember {
+ Animatable(0f, visibilityThreshold = 0.01f)
+ }
+ val panelDragDistance = remember { Animatable(0f) }
+ val pressureProgress = remember {
+ Animatable(0f, visibilityThreshold = 0.001f)
+ }
+ val interactionSources = remember(destinations.size) {
+ List(destinations.size) { MutableInteractionSource() }
+ }
+ val pressedStates = interactionSources.map { source ->
+ source.collectIsPressedAsState()
+ }
+ val isAnyItemPressed = pressedStates.any { it.value }
+ var isDragging by remember { mutableStateOf(false) }
+ var isSettling by remember { mutableStateOf(false) }
+
+ fun startVisualSettle(
+ targetIndex: Int,
+ startPosition: Float? = null,
+ startFlowVelocityItemsPerSecond: Float? = null,
+ startPanelDistancePx: Float? = null,
+ onPositionSettled: (() -> Unit)? = null,
+ ) {
+ val generation = settleController.invalidate()
+ isSettling = true
+ val job = animationScope.launch {
+ try {
+ startPosition?.let { indicatorPosition.snapTo(it) }
+ startFlowVelocityItemsPerSecond?.let { flowVelocity.snapTo(it) }
+ startPanelDistancePx?.let { panelDragDistance.snapTo(it) }
+ coroutineScope {
+ val positionJob = launch {
+ indicatorPosition.animateTo(
+ targetValue = targetIndex.toFloat(),
+ animationSpec = spring(
+ dampingRatio = 0.82f,
+ stiffness = 720f,
+ visibilityThreshold = 0.001f,
+ ),
+ )
+ }
+ launch {
+ flowVelocity.animateTo(
+ targetValue = 0f,
+ animationSpec = spring(
+ dampingRatio = 0.72f,
+ stiffness = 420f,
+ visibilityThreshold = 0.01f,
+ ),
+ )
+ }
+ launch {
+ panelDragDistance.animateTo(
+ targetValue = 0f,
+ animationSpec = spring(
+ dampingRatio = 0.86f,
+ stiffness = 380f,
+ visibilityThreshold = 0.5f,
+ ),
+ )
+ }
+ positionJob.join()
+ if (settleController.isCurrent(generation)) {
+ onPositionSettled?.invoke()
+ }
+ }
+ } finally {
+ if (settleController.isCurrent(generation)) {
+ settleController.job = null
+ isSettling = false
+ }
+ }
+ }
+ if (settleController.isCurrent(generation) && job.isActive) {
+ settleController.job = job
+ }
+ }
+
+ LaunchedEffect(enabled, isAnyItemPressed, isDragging, isSettling) {
+ val interacting = enabled && (isAnyItemPressed || isDragging || isSettling)
+ pressureProgress.animateTo(
+ targetValue = if (interacting) 1f else 0f,
+ animationSpec = tween(
+ durationMillis = if (interacting) {
+ INDICATOR_EXPAND_DURATION_MILLIS
+ } else {
+ INDICATOR_COLLAPSE_DURATION_MILLIS
+ },
+ easing = FastOutSlowInEasing,
+ ),
+ )
+ }
+
+ LaunchedEffect(selectedIndex, enabled) {
+ isDragging = false
+ if (!enabled) {
+ settleController.invalidate()
+ isSettling = false
+ indicatorPosition.snapTo(selectedIndex.toFloat())
+ flowVelocity.snapTo(0f)
+ panelDragDistance.snapTo(0f)
+ return@LaunchedEffect
+ }
+ val needsVisualSettle =
+ abs(indicatorPosition.value - selectedIndex.toFloat()) > 0.001f ||
+ abs(flowVelocity.value) > 0.01f ||
+ abs(panelDragDistance.value) > 0.5f
+ if (needsVisualSettle) {
+ startVisualSettle(targetIndex = selectedIndex)
+ } else {
+ settleController.invalidate()
+ isSettling = false
+ }
+ }
+
+ val glassTokens = resolveFloatingNavigationGlassTokens(
+ colorScheme = MaterialTheme.colorScheme,
+ useBackdropEffects = useBackdropEffects,
+ )
+
+ BoxWithConstraints(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ val navigationWidth = if (widthResolvedByParent) {
+ maxWidth
+ } else {
+ calculateFloatingNavigationWidth(
+ containerWidth = maxWidth,
+ itemCount = destinations.size,
+ )
+ }
+ val itemWidth = (navigationWidth - FLOATING_NAVIGATION_INNER_PADDING * 2) /
+ destinations.size
+ val itemWidthPx = with(density) { itemWidth.toPx() }
+ val navigationWidthPx = with(density) { navigationWidth.toPx() }
+ val navigationHeightPx = with(density) { FLOATING_NAVIGATION_HEIGHT.toPx() }
+ val indicatorHeightPx = with(density) {
+ FLOATING_NAVIGATION_INDICATOR_HEIGHT.toPx()
+ }
+ val innerPaddingPx = with(density) { FLOATING_NAVIGATION_INNER_PADDING.toPx() }
+ LaunchedEffect(itemWidthPx, layoutDirection) {
+ settleController.invalidate()
+ isDragging = false
+ isSettling = false
+ indicatorPosition.snapTo(currentSelectedIndex.toFloat())
+ flowVelocity.snapTo(0f)
+ panelDragDistance.snapTo(0f)
+ }
+ val indicatorTopPx = (navigationHeightPx - indicatorHeightPx) / 2f
+ val normalizedPanelDrag = (panelDragDistance.value / navigationWidthPx)
+ .coerceIn(-1f, 1f)
+ val panelTranslationLimitPx = (navigationHeightPx - indicatorHeightPx) / 2f
+ val panelOffsetPx = panelTranslationLimitPx *
+ normalizedPanelDrag *
+ (1f - PANEL_DRAG_DECELERATION * abs(normalizedPanelDrag))
+ val indicatorMorph = resolveFloatingNavigationIndicatorMorph(
+ interactionProgress = pressureProgress.value,
+ velocityItemsPerSecond = flowVelocity.value,
+ )
+ val shellScaleX = 1f +
+ innerPaddingPx * 2f / navigationWidthPx * indicatorMorph.pressureProgress
+ val shellScaleY = 1f +
+ innerPaddingPx / navigationHeightPx * indicatorMorph.pressureProgress
+ val physicalLeadDirection = if (layoutDirection == LayoutDirection.Ltr) 1f else -1f
+ val indicatorLeadOffsetPx =
+ indicatorMorph.leadOffsetFraction * itemWidthPx * physicalLeadDirection
+ val indicatorX = resolveFloatingNavigationIndicatorPhysicalX(
+ position = indicatorPosition.value,
+ itemWidthPx = itemWidthPx,
+ navigationWidthPx = navigationWidthPx,
+ innerPaddingPx = innerPaddingPx,
+ isRtl = layoutDirection == LayoutDirection.Rtl,
+ )
+ val highlightedIndex = indicatorPosition.value
+ .roundToInt()
+ .coerceIn(destinations.indices)
+ val selectedIconScale = 1f +
+ (indicatorMorph.scaleY - 1f) * SELECTED_ICON_MORPH_SHARE
+ val blurRadiusPx = innerPaddingPx * 2f
+ val shellRefractionHeightPx = indicatorHeightPx * SHELL_REFRACTION_HEIGHT_SHARE
+ val shellRefractionAmountPx = itemWidthPx * SHELL_REFRACTION_AMOUNT_SHARE
+ val indicatorRefractionHeightPx = indicatorHeightPx *
+ INDICATOR_REFRACTION_HEIGHT_SHARE *
+ indicatorMorph.refractionProgress
+ val indicatorRefractionAmountPx = itemWidthPx *
+ INDICATOR_REFRACTION_AMOUNT_SHARE *
+ indicatorMorph.refractionProgress
+ val shellSurfaceModifier = if (useBackdropEffects) {
+ Modifier.drawBackdrop(
+ backdrop = backdrop,
+ shape = { CircleShape },
+ effects = {
+ vibrancy()
+ blur(blurRadiusPx)
+ lens(
+ refractionHeight = shellRefractionHeightPx,
+ refractionAmount = shellRefractionAmountPx,
+ depthEffect = true,
+ )
+ },
+ highlight = {
+ Highlight.Default.copy(alpha = glassTokens.highlightAlpha)
+ },
+ shadow = {
+ Shadow.Default.copy(color = glassTokens.shadowColor)
+ },
+ layerBlock = {
+ translationX = panelOffsetPx
+ scaleX = shellScaleX
+ scaleY = shellScaleY
+ },
+ onDrawSurface = {
+ drawRect(glassTokens.surfaceColor)
+ },
+ )
+ } else {
+ Modifier.background(glassTokens.surfaceColor, CircleShape)
+ }
+ val indicatorSurfaceModifier = if (useBackdropEffects) {
+ Modifier.drawBackdrop(
+ backdrop = indicatorBackdrop,
+ shape = { CircleShape },
+ effects = {
+ lens(
+ refractionHeight = indicatorRefractionHeightPx,
+ refractionAmount = indicatorRefractionAmountPx,
+ depthEffect = true,
+ chromaticAberration = true,
+ )
+ },
+ highlight = {
+ Highlight.Default.copy(alpha = indicatorMorph.refractionProgress)
+ },
+ shadow = {
+ Shadow(alpha = indicatorMorph.refractionProgress)
+ },
+ innerShadow = {
+ InnerShadow(
+ radius = FLOATING_NAVIGATION_INNER_PADDING * 2 *
+ indicatorMorph.refractionProgress,
+ alpha = indicatorMorph.refractionProgress,
+ )
+ },
+ layerBlock = {
+ scaleX = indicatorMorph.scaleX
+ scaleY = indicatorMorph.scaleY
+ },
+ onDrawSurface = {
+ drawRect(
+ color = glassTokens.idleIndicatorColor,
+ alpha = 1f - indicatorMorph.pressureProgress,
+ )
+ drawRect(
+ color = Color.Black.copy(
+ alpha = INDICATOR_DEPTH_OVERLAY_ALPHA *
+ indicatorMorph.refractionProgress,
+ ),
+ )
+ },
+ )
+ } else {
+ Modifier
+ .background(glassTokens.idleIndicatorColor, CircleShape)
+ .border(
+ width = 1.dp,
+ color = glassTokens.outlineColor.copy(
+ alpha = FALLBACK_INDICATOR_BORDER_ALPHA,
+ ),
+ shape = CircleShape,
+ )
+ }
+ val shellFallbackTransformModifier = if (useBackdropEffects) {
+ Modifier
+ } else {
+ Modifier
+ .graphicsLayer {
+ translationX = panelOffsetPx
+ scaleX = shellScaleX
+ scaleY = shellScaleY
+ }
+ .shadow(
+ elevation = 8.dp,
+ shape = CircleShape,
+ clip = false,
+ )
+ }
+ val indicatorFallbackTransformModifier = if (useBackdropEffects) {
+ Modifier
+ } else {
+ Modifier.graphicsLayer {
+ scaleX = indicatorMorph.scaleX
+ scaleY = indicatorMorph.scaleY
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .width(navigationWidth)
+ .height(FLOATING_NAVIGATION_HEIGHT)
+ .onSizeChanged { size ->
+ onHeightChanged(with(density) { size.height.toDp() })
+ }
+ .testTag(FLOATING_NAVIGATION_TEST_TAG),
+ ) {
+ NavigationGlassContent(
+ destinations = destinations,
+ itemWidth = itemWidth,
+ iconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ useSelectedIcons = false,
+ iconScale = 1f,
+ modifier = Modifier
+ .fillMaxSize()
+ .then(shellFallbackTransformModifier)
+ .then(shellSurfaceModifier)
+ .border(
+ width = 1.dp,
+ color = glassTokens.outlineColor,
+ shape = CircleShape,
+ )
+ .padding(FLOATING_NAVIGATION_INNER_PADDING),
+ )
+
+ if (useBackdropEffects) {
+ NavigationGlassContent(
+ destinations = destinations,
+ itemWidth = itemWidth,
+ iconColor = MaterialTheme.colorScheme.primary,
+ useSelectedIcons = true,
+ iconScale = selectedIconScale,
+ modifier = Modifier
+ .align(Alignment.Center)
+ .clearAndSetSemantics {}
+ .alpha(0f)
+ .layerBackdrop(navigationContentBackdrop)
+ .drawBackdrop(
+ backdrop = backdrop,
+ shape = { CircleShape },
+ effects = {
+ vibrancy()
+ blur(blurRadiusPx)
+ lens(
+ refractionHeight = shellRefractionHeightPx *
+ indicatorMorph.refractionProgress,
+ refractionAmount = shellRefractionAmountPx *
+ indicatorMorph.refractionProgress,
+ depthEffect = true,
+ )
+ },
+ highlight = {
+ Highlight.Default.copy(
+ alpha = indicatorMorph.refractionProgress,
+ )
+ },
+ layerBlock = {
+ translationX = panelOffsetPx
+ },
+ onDrawSurface = {
+ drawRect(glassTokens.surfaceColor)
+ },
+ )
+ .height(FLOATING_NAVIGATION_INDICATOR_HEIGHT)
+ .fillMaxWidth()
+ .padding(horizontal = FLOATING_NAVIGATION_INNER_PADDING),
+ )
+ }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .align(AbsoluteAlignment.TopLeft)
+ .absoluteOffset {
+ IntOffset(
+ x = (
+ indicatorX +
+ panelOffsetPx +
+ indicatorLeadOffsetPx
+ ).roundToInt(),
+ y = indicatorTopPx.roundToInt(),
+ )
+ }
+ .width(itemWidth)
+ .height(FLOATING_NAVIGATION_INDICATOR_HEIGHT)
+ .then(indicatorFallbackTransformModifier)
+ .then(indicatorSurfaceModifier),
+ ) {
+ if (!useBackdropEffects) {
+ Icon(
+ imageVector = destinations[highlightedIndex].selectedIcon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .size(NAVIGATION_ICON_SIZE)
+ .graphicsLayer {
+ scaleX = selectedIconScale
+ scaleY = selectedIconScale
+ },
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(FLOATING_NAVIGATION_INNER_PADDING)
+ .then(
+ if (enabled) {
+ Modifier.selectableGroup()
+ } else {
+ Modifier.clearAndSetSemantics {}
+ },
+ )
+ .pointerInput(
+ destinations.size,
+ itemWidthPx,
+ layoutDirection,
+ enabled,
+ ) {
+ if (!enabled) return@pointerInput
+
+ var dragUpdateJob: Job? = null
+ try {
+ coroutineScope {
+ awaitEachGesture {
+ val velocityTracker = VelocityTracker()
+ val down = awaitFirstDown(requireUnconsumed = false)
+ velocityTracker.addPosition(down.uptimeMillis, down.position)
+ val gestureGeneration = settleController.invalidate()
+ isSettling = false
+
+ var overSlop = 0f
+ val dragStart = awaitHorizontalTouchSlopOrCancellation(
+ pointerId = down.id,
+ ) { change, over ->
+ change.consume()
+ overSlop = over
+ }
+ if (dragStart == null) {
+ if (settleController.isCurrent(gestureGeneration)) {
+ startVisualSettle(
+ targetIndex = currentSelectedIndex,
+ )
+ }
+ return@awaitEachGesture
+ }
+ if (!settleController.isCurrent(gestureGeneration)) {
+ return@awaitEachGesture
+ }
+
+ isDragging = true
+
+ val logicalDirection = if (
+ layoutDirection == LayoutDirection.Ltr
+ ) {
+ 1f
+ } else {
+ -1f
+ }
+ val lastItemPosition = (destinations.size - 1).toFloat()
+ var desiredPosition = indicatorPosition.value
+ var visualPosition = indicatorPosition.value
+ var previousVisualPosition = visualPosition
+ var previousUptimeMillis = dragStart.uptimeMillis
+ var accumulatedPanelDistance = panelDragDistance.value
+
+ fun scheduleDragUpdate(
+ dragAmountPx: Float,
+ uptimeMillis: Long,
+ ) {
+ if (!settleController.isCurrent(gestureGeneration)) {
+ return
+ }
+ desiredPosition = applyFloatingNavigationDrag(
+ desiredPosition = desiredPosition,
+ deltaItems = dragAmountPx /
+ itemWidthPx *
+ logicalDirection,
+ itemCount = destinations.size,
+ )
+ visualPosition = desiredPosition.coerceIn(
+ minimumValue = 0f,
+ maximumValue = lastItemPosition,
+ )
+ val elapsedSeconds = (
+ uptimeMillis - previousUptimeMillis
+ ).coerceAtLeast(1L) / 1000f
+ val visualVelocity = (
+ visualPosition - previousVisualPosition
+ ) / elapsedSeconds
+ previousVisualPosition = visualPosition
+ previousUptimeMillis = uptimeMillis
+ accumulatedPanelDistance += dragAmountPx
+
+ dragUpdateJob?.cancel()
+ dragUpdateJob = launch {
+ if (
+ !settleController.isCurrent(
+ gestureGeneration,
+ )
+ ) {
+ return@launch
+ }
+ indicatorPosition.snapTo(visualPosition)
+ if (
+ !settleController.isCurrent(
+ gestureGeneration,
+ )
+ ) {
+ return@launch
+ }
+ flowVelocity.snapTo(visualVelocity)
+ if (
+ !settleController.isCurrent(
+ gestureGeneration,
+ )
+ ) {
+ return@launch
+ }
+ panelDragDistance.snapTo(accumulatedPanelDistance)
+ }
+ }
+
+ scheduleDragUpdate(
+ dragAmountPx = overSlop,
+ uptimeMillis = dragStart.uptimeMillis,
+ )
+ velocityTracker.addPosition(
+ dragStart.uptimeMillis,
+ dragStart.position,
+ )
+ val completed = horizontalDrag(dragStart.id) { change ->
+ change.consume()
+ velocityTracker.addPosition(
+ change.uptimeMillis,
+ change.position,
+ )
+ scheduleDragUpdate(
+ dragAmountPx = change.position.x -
+ change.previousPosition.x,
+ uptimeMillis = change.uptimeMillis,
+ )
+ }
+ dragUpdateJob?.cancel()
+ if (!settleController.isCurrent(gestureGeneration)) {
+ isDragging = false
+ return@awaitEachGesture
+ }
+
+ val physicalVelocity = if (completed) {
+ velocityTracker.calculateVelocity().x
+ } else {
+ 0f
+ }
+ val logicalVelocity =
+ physicalVelocity * logicalDirection
+ val targetIndex = resolveFloatingNavigationReleaseTarget(
+ desiredPosition = desiredPosition,
+ velocityPxPerSecond = logicalVelocity,
+ itemWidthPx = itemWidthPx,
+ itemCount = destinations.size,
+ )
+ if (targetIndex != currentSelectedIndex) {
+ hapticFeedback.performHapticFeedback(
+ HapticFeedbackType.SegmentTick,
+ )
+ }
+
+ isDragging = false
+ startVisualSettle(
+ targetIndex = targetIndex,
+ startPosition = visualPosition,
+ startFlowVelocityItemsPerSecond =
+ logicalVelocity / itemWidthPx,
+ startPanelDistancePx = accumulatedPanelDistance,
+ onPositionSettled = {
+ if (
+ currentEnabled &&
+ targetIndex != currentSelectedIndex
+ ) {
+ currentOnDestinationSelected(
+ destinations[targetIndex],
+ )
+ }
+ },
+ )
+ }
+ }
+ } finally {
+ dragUpdateJob?.cancel()
+ settleController.invalidate()
+ isDragging = false
+ isSettling = false
+ }
+ },
+ ) {
+ destinations.forEachIndexed { index, destination ->
+ val label = stringResource(destination.iconTextId)
+ val selectDestination = {
+ if (currentEnabled) {
+ hapticFeedback.performHapticFeedback(
+ HapticFeedbackType.SegmentTick,
+ )
+ isDragging = false
+ startVisualSettle(targetIndex = index)
+ currentOnDestinationSelected(destination)
+ }
+ }
+ val destinationInteractionModifier = if (enabled) {
+ Modifier
+ .clearAndSetSemantics {
+ contentDescription = label
+ role = Role.Tab
+ selected = index == selectedIndex
+ onClick(label = label) {
+ selectDestination()
+ true
+ }
+ }
+ .selectable(
+ selected = index == selectedIndex,
+ interactionSource = interactionSources[index],
+ indication = null,
+ role = Role.Tab,
+ onClick = selectDestination,
+ )
+ } else {
+ Modifier.clearAndSetSemantics {}
+ }
+ Box(
+ modifier = Modifier
+ .width(itemWidth)
+ .fillMaxHeight()
+ .clip(CircleShape)
+ .then(destinationInteractionModifier),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun NavigationGlassContent(
+ destinations: List,
+ itemWidth: Dp,
+ iconColor: Color,
+ useSelectedIcons: Boolean,
+ iconScale: Float,
+ modifier: Modifier = Modifier,
+) {
+ Row(modifier = modifier) {
+ destinations.forEach { destination ->
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .width(itemWidth)
+ .fillMaxHeight(),
+ ) {
+ Icon(
+ imageVector = if (useSelectedIcons) {
+ destination.selectedIcon
+ } else {
+ destination.unselectedIcon
+ },
+ contentDescription = null,
+ tint = iconColor,
+ modifier = Modifier
+ .size(NAVIGATION_ICON_SIZE)
+ .graphicsLayer {
+ scaleX = iconScale
+ scaleY = iconScale
+ },
+ )
+ }
+ }
+ }
+}
+
+internal data class FloatingNavigationGlassTokens(
+ val surfaceColor: Color,
+ val outlineColor: Color,
+ val highlightAlpha: Float,
+ val shadowColor: Color,
+ val idleIndicatorColor: Color,
+)
+
+internal fun resolveFloatingNavigationGlassTokens(
+ colorScheme: ColorScheme,
+ useBackdropEffects: Boolean,
+): FloatingNavigationGlassTokens {
+ val isDarkTheme = colorScheme.background.luminance() < 0.5f
+ val surface = if (isDarkTheme) {
+ colorScheme.surfaceContainer
+ } else {
+ colorScheme.surfaceContainerLowest
+ }
+ return FloatingNavigationGlassTokens(
+ surfaceColor = surface.copy(
+ alpha = if (useBackdropEffects) {
+ GLASS_SURFACE_ALPHA
+ } else {
+ GLASS_FALLBACK_SURFACE_ALPHA
+ },
+ ),
+ outlineColor = if (isDarkTheme) {
+ colorScheme.outlineVariant.copy(alpha = DARK_GLASS_OUTLINE_ALPHA)
+ } else {
+ colorScheme.outline.copy(alpha = LIGHT_GLASS_OUTLINE_ALPHA)
+ },
+ highlightAlpha = if (isDarkTheme) {
+ DARK_GLASS_HIGHLIGHT_ALPHA
+ } else {
+ LIGHT_GLASS_HIGHLIGHT_ALPHA
+ },
+ shadowColor = Color.Black.copy(
+ alpha = if (isDarkTheme) {
+ DARK_GLASS_SHADOW_ALPHA
+ } else {
+ LIGHT_GLASS_SHADOW_ALPHA
+ },
+ ),
+ idleIndicatorColor = if (isDarkTheme) {
+ Color.White.copy(alpha = IDLE_INDICATOR_ALPHA)
+ } else {
+ Color.Black.copy(alpha = IDLE_INDICATOR_ALPHA)
+ },
+ )
+}
+
+private val FLOATING_NAVIGATION_HEIGHT = 64.dp
+private val FLOATING_NAVIGATION_INNER_PADDING = 4.dp
+private val FLOATING_REMOTE_CONTROL_SIZE = 64.dp
+private val FLOATING_REMOTE_CONTROL_SLOT_WIDTH = 76.dp
+private val FLOATING_NAVIGATION_INDICATOR_HEIGHT =
+ FLOATING_NAVIGATION_HEIGHT - FLOATING_NAVIGATION_INNER_PADDING * 2
+private val NAVIGATION_ICON_SIZE = 24.dp
+private const val PANEL_DRAG_DECELERATION = 0.36f
+private const val SELECTED_ICON_MORPH_SHARE = 0.32f
+private const val SHELL_REFRACTION_HEIGHT_SHARE = 0.34f
+private const val SHELL_REFRACTION_AMOUNT_SHARE = 0.28f
+private const val REMOTE_ACTION_REFRACTION_AMOUNT_SHARE = 0.18f
+private const val INDICATOR_REFRACTION_HEIGHT_SHARE = 0.22f
+private const val INDICATOR_REFRACTION_AMOUNT_SHARE = 0.18f
+private const val INDICATOR_DEPTH_OVERLAY_ALPHA = 0.025f
+private const val FALLBACK_INDICATOR_BORDER_ALPHA = 0.52f
+private const val GLASS_SURFACE_ALPHA = 0.40f
+private const val GLASS_FALLBACK_SURFACE_ALPHA = 0.94f
+private const val LIGHT_GLASS_OUTLINE_ALPHA = 0.56f
+private const val DARK_GLASS_OUTLINE_ALPHA = 0.72f
+private const val LIGHT_GLASS_HIGHLIGHT_ALPHA = 0.75f
+private const val DARK_GLASS_HIGHLIGHT_ALPHA = 0.38f
+private const val LIGHT_GLASS_SHADOW_ALPHA = 0.10f
+private const val DARK_GLASS_SHADOW_ALPHA = 0.20f
+private const val IDLE_INDICATOR_ALPHA = 0.10f
+private const val INDICATOR_EXPAND_DURATION_MILLIS = 110
+private const val INDICATOR_COLLAPSE_DURATION_MILLIS = 190
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/navigation/FloatingNavigationMotion.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/navigation/FloatingNavigationMotion.kt
new file mode 100644
index 000000000..73663abb6
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/navigation/FloatingNavigationMotion.kt
@@ -0,0 +1,123 @@
+package com.m3u.smartphone.ui.navigation
+
+import kotlin.math.abs
+import kotlin.math.exp
+import kotlin.math.roundToInt
+import kotlin.math.sign
+import kotlin.math.sqrt
+
+internal data class FloatingNavigationIndicatorMorph(
+ val scaleX: Float,
+ val scaleY: Float,
+ val leadOffsetFraction: Float,
+ val refractionProgress: Float,
+ val pressureProgress: Float,
+)
+
+internal fun resolveFloatingNavigationIndicatorPhysicalX(
+ position: Float,
+ itemWidthPx: Float,
+ navigationWidthPx: Float,
+ innerPaddingPx: Float,
+ isRtl: Boolean,
+): Float = if (isRtl) {
+ navigationWidthPx - innerPaddingPx - (position + 1f) * itemWidthPx
+} else {
+ innerPaddingPx + position * itemWidthPx
+}
+
+internal fun applyFloatingNavigationDrag(
+ desiredPosition: Float,
+ deltaItems: Float,
+ itemCount: Int,
+ inBoundsGain: Float = 0.97f,
+ edgeElasticity: Float = 0.23f,
+ overscrollLimitItems: Float = 0.38f,
+): Float {
+ if (itemCount <= 0) return 0f
+
+ val lastPosition = (itemCount - 1).toFloat()
+ val movementGain = if (desiredPosition < 0f || desiredPosition > lastPosition) {
+ edgeElasticity
+ } else {
+ inBoundsGain
+ }
+ return (desiredPosition + deltaItems * movementGain).coerceIn(
+ minimumValue = -overscrollLimitItems,
+ maximumValue = lastPosition + overscrollLimitItems,
+ )
+}
+
+internal fun resolveFloatingNavigationReleaseTarget(
+ desiredPosition: Float,
+ velocityPxPerSecond: Float,
+ itemWidthPx: Float,
+ itemCount: Int,
+ projectionTimeSeconds: Float = 0.16f,
+ maximumProjectedStepCount: Int = 1,
+): Int {
+ if (itemCount <= 0 || itemWidthPx <= 0f) return 0
+
+ val projectedPosition = desiredPosition +
+ velocityPxPerSecond / itemWidthPx * projectionTimeSeconds
+ val nearestPosition = desiredPosition.roundToInt()
+ var targetPosition = projectedPosition.roundToInt()
+ val maximumStepCount = maximumProjectedStepCount.coerceAtLeast(1)
+ if (abs(targetPosition - nearestPosition) > maximumStepCount) {
+ targetPosition = nearestPosition +
+ (targetPosition - nearestPosition).sign * maximumStepCount
+ }
+ return targetPosition.coerceIn(0, itemCount - 1)
+}
+
+/**
+ * Produces a bounded, direction-aware liquid response from interaction pressure and speed.
+ *
+ * The exponential scale keeps both axes positive and makes the stationary press expansion
+ * continuous with the additional horizontal flow stretch. The scales are intentionally
+ * direction-neutral; direction only moves the optical centre slightly ahead of the finger.
+ */
+internal fun resolveFloatingNavigationIndicatorMorph(
+ interactionProgress: Float,
+ velocityItemsPerSecond: Float,
+): FloatingNavigationIndicatorMorph {
+ val safeInteraction = when {
+ interactionProgress.isNaN() -> 0f
+ interactionProgress <= 0f -> 0f
+ interactionProgress >= 1f -> 1f
+ else -> interactionProgress
+ }
+ val pressure = safeInteraction * safeInteraction * (3f - 2f * safeInteraction)
+ val safeVelocity = when {
+ velocityItemsPerSecond.isNaN() -> 0f
+ velocityItemsPerSecond > FULL_FLOW_SPEED_ITEMS_PER_SECOND ->
+ FULL_FLOW_SPEED_ITEMS_PER_SECOND
+ velocityItemsPerSecond < -FULL_FLOW_SPEED_ITEMS_PER_SECOND ->
+ -FULL_FLOW_SPEED_ITEMS_PER_SECOND
+ else -> velocityItemsPerSecond
+ }
+ val normalizedSpeed = (abs(safeVelocity) / FULL_FLOW_SPEED_ITEMS_PER_SECOND)
+ .coerceIn(0f, 1f)
+ val flow = sqrt(normalizedSpeed)
+ val radialExpansion = PRESS_EXPANSION_LOG * pressure
+ val axialFlow = FLOW_STRETCH_LOG * pressure * flow
+ val direction = safeVelocity.sign
+
+ return FloatingNavigationIndicatorMorph(
+ scaleX = exp(radialExpansion + axialFlow),
+ scaleY = exp(radialExpansion - axialFlow * CROSS_AXIS_FLOW_RETENTION),
+ leadOffsetFraction = direction * MAXIMUM_LEAD_FRACTION * pressure * flow,
+ refractionProgress = (
+ pressure * (BASE_REFRACTION_SHARE + FLOW_REFRACTION_SHARE * flow)
+ ).coerceIn(0f, 1f),
+ pressureProgress = pressure,
+ )
+}
+
+private const val FULL_FLOW_SPEED_ITEMS_PER_SECOND = 6.5f
+private const val PRESS_EXPANSION_LOG = 0.27f
+private const val FLOW_STRETCH_LOG = 0.24f
+private const val CROSS_AXIS_FLOW_RETENTION = 0.58f
+private const val MAXIMUM_LEAD_FRACTION = 0.06f
+private const val BASE_REFRACTION_SHARE = 0.38f
+private const val FLOW_REFRACTION_SHARE = 0.62f
diff --git a/app/smartphone/src/main/res/xml-v28/backup_rules.xml b/app/smartphone/src/main/res/xml-v28/backup_rules.xml
new file mode 100644
index 000000000..231c610ec
--- /dev/null
+++ b/app/smartphone/src/main/res/xml-v28/backup_rules.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/smartphone/src/main/res/xml/backup_rules.xml b/app/smartphone/src/main/res/xml/backup_rules.xml
index fa0f996d2..aeefb4498 100644
--- a/app/smartphone/src/main/res/xml/backup_rules.xml
+++ b/app/smartphone/src/main/res/xml/backup_rules.xml
@@ -1,13 +1,12 @@
-
+
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
diff --git a/app/smartphone/src/main/res/xml/data_extraction_rules.xml b/app/smartphone/src/main/res/xml/data_extraction_rules.xml
index 9ee9997b0..fba117f86 100644
--- a/app/smartphone/src/main/res/xml/data_extraction_rules.xml
+++ b/app/smartphone/src/main/res/xml/data_extraction_rules.xml
@@ -1,19 +1,18 @@
-
+
-
+
+
+
+
+
+
+
-
-
\ No newline at end of file
+
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/TimeUtilsTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/TimeUtilsTest.kt
new file mode 100644
index 000000000..23c8f55a6
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/TimeUtilsTest.kt
@@ -0,0 +1,57 @@
+package com.m3u.smartphone
+
+import com.m3u.smartphone.TimeUtils.formatEOrSh
+import java.util.Locale
+import kotlinx.datetime.LocalDateTime
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class TimeUtilsTest {
+ @Test
+ fun `twelve hour clock includes localized day period`() {
+ assertEquals(
+ expected = "1:05:09 PM",
+ actual = LocalDateTime(2026, 7, 28, 13, 5, 9).formatEOrSh(
+ twelveHourClock = true,
+ ignoreSeconds = false,
+ locale = Locale.US,
+ ),
+ )
+ }
+
+ @Test
+ fun `day period follows locale ordering`() {
+ assertEquals(
+ expected = "下午1:05",
+ actual = formatTimeWithPattern(
+ hour = 13,
+ minute = 5,
+ second = null,
+ locale = Locale.SIMPLIFIED_CHINESE,
+ pattern = "ah:mm",
+ ),
+ )
+ }
+
+ @Test
+ fun `midnight uses twelve rather than zero`() {
+ assertEquals(
+ expected = "12:05 AM",
+ actual = LocalDateTime(2026, 7, 28, 0, 5).formatEOrSh(
+ twelveHourClock = true,
+ locale = Locale.US,
+ ),
+ )
+ }
+
+ @Test
+ fun `twenty four hour timeline keeps end of day hour`() {
+ assertEquals(
+ expected = "24:00",
+ actual = 24f.formatEOrSh(
+ use12HourFormat = false,
+ locale = Locale.US,
+ ),
+ )
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/AppViewModelSearchFlowTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/AppViewModelSearchFlowTest.kt
new file mode 100644
index 000000000..8373a0ab6
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/AppViewModelSearchFlowTest.kt
@@ -0,0 +1,106 @@
+package com.m3u.smartphone.ui
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class AppViewModelSearchFlowTest {
+ @Test
+ fun `result from another query is ignored even before the clear signal arrives`() {
+ val stale = QuerySearchResults("old", listOf("stale-result"))
+
+ assertEquals(emptyList(), stale.itemsFor("new"))
+ assertEquals(listOf("stale-result"), stale.itemsFor("old"))
+ }
+
+ @Test
+ fun `new query clears promoted results before its search completes`() = runBlocking {
+ withTimeout(TEST_TIMEOUT_MILLIS) {
+ val queries = MutableSharedFlow(replay = 1)
+ val newQueryResult = CompletableDeferred>()
+ val emissions = Channel>(Channel.UNLIMITED)
+ val collection = launch(start = CoroutineStart.UNDISPATCHED) {
+ queries.mapLatestSearchResults { query ->
+ when (query) {
+ "old" -> listOf("old-result")
+ "new" -> newQueryResult.await()
+ else -> error("Unexpected query: $query")
+ }
+ }.collect(emissions::send)
+ }
+
+ queries.emit("old")
+ assertEquals(
+ QuerySearchResults("old", emptyList()),
+ emissions.receive(),
+ )
+ assertEquals(
+ QuerySearchResults("old", listOf("old-result")),
+ emissions.receive(),
+ )
+
+ queries.emit("new")
+ assertEquals(
+ QuerySearchResults("new", emptyList()),
+ emissions.receive(),
+ )
+
+ newQueryResult.complete(listOf("new-result"))
+ assertEquals(
+ QuerySearchResults("new", listOf("new-result")),
+ emissions.receive(),
+ )
+ collection.cancel()
+ }
+ }
+
+ @Test
+ fun `cancelled older search cannot emit after a newer query`() = runBlocking {
+ withTimeout(TEST_TIMEOUT_MILLIS) {
+ val queries = MutableSharedFlow(replay = 1)
+ val oldQueryResult = CompletableDeferred>()
+ val newQueryResult = CompletableDeferred>()
+ val emissions = Channel>(Channel.UNLIMITED)
+ val collection = launch(start = CoroutineStart.UNDISPATCHED) {
+ queries.mapLatestSearchResults { query ->
+ when (query) {
+ "old" -> oldQueryResult.await()
+ "new" -> newQueryResult.await()
+ else -> error("Unexpected query: $query")
+ }
+ }.collect(emissions::send)
+ }
+
+ queries.emit("old")
+ assertEquals(
+ QuerySearchResults("old", emptyList()),
+ emissions.receive(),
+ )
+
+ queries.emit("new")
+ assertEquals(
+ QuerySearchResults("new", emptyList()),
+ emissions.receive(),
+ )
+
+ oldQueryResult.complete(listOf("stale-result"))
+ newQueryResult.complete(listOf("current-result"))
+ assertEquals(
+ QuerySearchResults("new", listOf("current-result")),
+ emissions.receive(),
+ )
+ collection.cancel()
+ }
+ }
+
+ private companion object {
+ const val TEST_TIMEOUT_MILLIS = 5_000L
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/configuration/PlaylistRefreshabilityTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/configuration/PlaylistRefreshabilityTest.kt
new file mode 100644
index 000000000..515ab6c6d
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/configuration/PlaylistRefreshabilityTest.kt
@@ -0,0 +1,28 @@
+package com.m3u.smartphone.ui.business.configuration
+
+import com.m3u.data.database.model.DataSource
+import com.m3u.data.database.model.Playlist
+import com.m3u.data.database.model.refreshable
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class PlaylistRefreshabilityTest {
+ @Test
+ fun `remote subscriptions expose refresh while local snapshots do not`() {
+ assertTrue(playlist(DataSource.M3U, "https://example.com/list.m3u").refreshable)
+ assertTrue(playlist(DataSource.Xtream, "xtream://account").refreshable)
+ assertTrue(playlist(DataSource.Provider, "provider://account").refreshable)
+
+ assertFalse(playlist(DataSource.M3U, "content://media/list.m3u").refreshable)
+ assertFalse(playlist(DataSource.M3U, "file:///tmp/list.m3u").refreshable)
+ assertFalse(playlist(DataSource.M3U, Playlist.URL_IMPORTED).refreshable)
+ assertFalse(playlist(DataSource.EPG, "https://example.com/guide.xml").refreshable)
+ }
+
+ private fun playlist(source: DataSource, url: String): Playlist = Playlist(
+ title = "Test",
+ url = url,
+ source = source,
+ )
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRowPolicyTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRowPolicyTest.kt
new file mode 100644
index 000000000..ae0b755f7
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/playlist/components/PlaylistTabRowPolicyTest.kt
@@ -0,0 +1,34 @@
+package com.m3u.smartphone.ui.business.playlist.components
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class PlaylistTabRowPolicyTest {
+ @Test
+ fun `expanded menu reports expanded state and collapse action`() {
+ val semantics = categoryMenuSemantics(
+ isExpanded = true,
+ expandedStateDescription = "expanded state",
+ collapsedStateDescription = "collapsed state",
+ expandActionLabel = "expand action",
+ collapseActionLabel = "collapse action",
+ )
+
+ assertEquals("expanded state", semantics.stateDescription)
+ assertEquals("collapse action", semantics.actionLabel)
+ }
+
+ @Test
+ fun `collapsed menu reports collapsed state and expand action`() {
+ val semantics = categoryMenuSemantics(
+ isExpanded = false,
+ expandedStateDescription = "expanded state",
+ collapsedStateDescription = "collapsed state",
+ expandActionLabel = "expand action",
+ collapseActionLabel = "collapse action",
+ )
+
+ assertEquals("collapsed state", semantics.stateDescription)
+ assertEquals("expand action", semantics.actionLabel)
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/AppearanceThemePolicyTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/AppearanceThemePolicyTest.kt
new file mode 100644
index 000000000..95affea04
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/AppearanceThemePolicyTest.kt
@@ -0,0 +1,75 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.data.database.model.ColorScheme
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class AppearanceThemePolicyTest {
+ @Test
+ fun `follow system chooses the variant matching the current system theme`() {
+ val light = ColorScheme(argb = 1, isDark = false, name = "light")
+ val dark = ColorScheme(argb = 1, isDark = true, name = "dark")
+
+ assertEquals(
+ listOf(light),
+ selectThemeSchemeVariants(
+ schemes = listOf(light, dark),
+ followSystemTheme = true,
+ systemUsesDarkTheme = false,
+ ),
+ )
+ assertEquals(
+ listOf(dark),
+ selectThemeSchemeVariants(
+ schemes = listOf(light, dark),
+ followSystemTheme = true,
+ systemUsesDarkTheme = true,
+ ),
+ )
+ }
+
+ @Test
+ fun `follow system keeps an unpaired custom theme available`() {
+ val darkOnly = ColorScheme(
+ argb = 2,
+ isDark = true,
+ name = "custom",
+ )
+
+ assertEquals(
+ listOf(darkOnly),
+ selectThemeSchemeVariants(
+ schemes = listOf(darkOnly),
+ followSystemTheme = true,
+ systemUsesDarkTheme = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `manual mode keeps every explicit light and dark variant`() {
+ val schemes = listOf(
+ ColorScheme(argb = 1, isDark = false, name = "light"),
+ ColorScheme(argb = 1, isDark = true, name = "dark"),
+ )
+
+ assertEquals(
+ schemes,
+ selectThemeSchemeVariants(
+ schemes = schemes,
+ followSystemTheme = false,
+ systemUsesDarkTheme = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `dynamic colors are active only when requested and supported`() {
+ assertFalse(areDynamicColorsActive(requested = false, supported = false))
+ assertFalse(areDynamicColorsActive(requested = false, supported = true))
+ assertFalse(areDynamicColorsActive(requested = true, supported = false))
+ assertTrue(areDynamicColorsActive(requested = true, supported = true))
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionCapabilityPresentationTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionCapabilityPresentationTest.kt
new file mode 100644
index 000000000..1f0ac158c
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionCapabilityPresentationTest.kt
@@ -0,0 +1,23 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.extension.api.ExtensionCapabilityIds
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class ExtensionCapabilityPresentationTest {
+ @Test
+ fun `every standard capability has a host name`() {
+ ExtensionCapabilityIds.All.forEach { capability ->
+ assertNotNull(
+ extensionCapabilityNameResource(capability.id),
+ "Missing host name for ${capability.id}",
+ )
+ }
+ }
+
+ @Test
+ fun `unknown optional capability keeps the wire id fallback`() {
+ assertNull(extensionCapabilityNameResource("vendor.future.optional"))
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionNetworkAccessPresentationTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionNetworkAccessPresentationTest.kt
new file mode 100644
index 000000000..95392a9c4
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionNetworkAccessPresentationTest.kt
@@ -0,0 +1,103 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.data.repository.extension.ExtensionNetworkOriginState
+import com.m3u.data.repository.extension.ExtensionSettingNetworkOrigin
+import com.m3u.data.repository.plugin.InstalledPlugin
+import com.m3u.data.repository.plugin.PluginFixedNetworkOrigin
+import com.m3u.data.repository.plugin.PluginNetworkAccess
+import com.m3u.extension.api.ExtensionState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ExtensionNetworkAccessPresentationTest {
+ @Test
+ fun `detail counts fixed and configured setting origins without empty fields`() {
+ val plugin = plugin(
+ fixedStates = listOf(
+ ExtensionNetworkOriginState.APPROVED,
+ ExtensionNetworkOriginState.REQUIRES_APPROVAL,
+ ),
+ settingStates = ExtensionNetworkOriginState.entries,
+ )
+
+ assertEquals(
+ ExtensionNetworkAccessCounts(approved = 2, total = 7),
+ plugin.networkAccessCounts(),
+ )
+ assertEquals(
+ ExtensionNetworkOriginState.entries
+ .filterNot { it == ExtensionNetworkOriginState.NOT_CONFIGURED },
+ plugin.visibleSettingNetworkOrigins().map { origin -> origin.state },
+ )
+ }
+
+ @Test
+ fun `only actionable setting origin states raise a detail warning`() {
+ listOf(
+ ExtensionNetworkOriginState.INVALID,
+ ExtensionNetworkOriginState.REQUIRES_APPROVAL,
+ ExtensionNetworkOriginState.SUSPENDED,
+ ExtensionNetworkOriginState.UNVERIFIED,
+ ).forEach { state ->
+ assertTrue(plugin(settingStates = listOf(state)).hasSettingNetworkOriginWarning)
+ assertTrue(state.requiresUserAttention)
+ }
+
+ listOf(
+ ExtensionNetworkOriginState.NOT_CONFIGURED,
+ ExtensionNetworkOriginState.APPROVED,
+ ).forEach { state ->
+ assertFalse(plugin(settingStates = listOf(state)).hasSettingNetworkOriginWarning)
+ assertFalse(state.requiresUserAttention)
+ }
+ }
+
+ private fun plugin(
+ fixedStates: List = emptyList(),
+ settingStates: List = emptyList(),
+ ): InstalledPlugin = InstalledPlugin(
+ packageName = "com.example.extension",
+ serviceName = "com.example.extension.Service",
+ certificateSha256 = "AA",
+ previousCertificateSha256 = null,
+ trusted = true,
+ signatureChanged = false,
+ extensionId = "com.example.extension",
+ enabled = true,
+ state = ExtensionState.ENABLED,
+ displayName = "Example",
+ version = "1.0.0",
+ developer = "Example",
+ requestedCapabilities = emptySet(),
+ grantedCapabilities = emptySet(),
+ capabilityPermissions = emptyList(),
+ inspectionError = null,
+ installed = true,
+ canClearData = true,
+ networkAccess = PluginNetworkAccess(
+ fixedOrigins = fixedStates.mapIndexed { index, state ->
+ PluginFixedNetworkOrigin(
+ origin = "https://fixed-$index.example:443",
+ state = state,
+ )
+ },
+ settingOrigins = settingStates.mapIndexed { index, state ->
+ ExtensionSettingNetworkOrigin(
+ sectionId = "network",
+ fieldKey = "origin-$index",
+ label = "Origin $index",
+ currentOrigin = when (state) {
+ ExtensionNetworkOriginState.NOT_CONFIGURED,
+ ExtensionNetworkOriginState.INVALID,
+ -> null
+
+ else -> "https://setting-$index.example:443"
+ },
+ state = state,
+ )
+ },
+ ),
+ )
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginActionAvailabilityTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginActionAvailabilityTest.kt
new file mode 100644
index 000000000..d17070f53
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginActionAvailabilityTest.kt
@@ -0,0 +1,110 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.extension.api.ExtensionState
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ExtensionPluginActionAvailabilityTest {
+ @Test
+ fun `enabled unhealthy plugin keeps disable without exposing settings or enable`() {
+ val actions = actions(
+ enabled = true,
+ state = ExtensionState.UNHEALTHY,
+ hasInspectionError = true,
+ trusted = true,
+ hasAuthorizationToken = true,
+ )
+
+ assertTrue(actions.disable)
+ assertFalse(actions.settings)
+ assertFalse(actions.enable)
+ assertTrue(actions.reauthorize)
+ }
+
+ @Test
+ fun `enabled incompatible plugin keeps disable even when inspection failed`() {
+ val actions = actions(
+ enabled = true,
+ state = ExtensionState.INCOMPATIBLE,
+ hasInspectionError = true,
+ )
+
+ assertTrue(actions.disable)
+ assertFalse(actions.settings)
+ assertFalse(actions.enable)
+ }
+
+ @Test
+ fun `healthy enabled plugin exposes settings and disable but never enable`() {
+ val actions = actions(
+ enabled = true,
+ state = ExtensionState.ENABLED,
+ )
+
+ assertTrue(actions.settings)
+ assertTrue(actions.disable)
+ assertFalse(actions.enable)
+ }
+
+ @Test
+ fun `only an eligible disabled plugin exposes enable`() {
+ val eligible = actions(
+ enabled = false,
+ state = ExtensionState.DISABLED,
+ hasAuthorizationToken = true,
+ )
+ val incompatible = actions(
+ enabled = false,
+ state = ExtensionState.INCOMPATIBLE,
+ hasAuthorizationToken = true,
+ )
+ val inspectionFailure = actions(
+ enabled = false,
+ state = ExtensionState.DISABLED,
+ hasInspectionError = true,
+ hasAuthorizationToken = true,
+ )
+
+ assertTrue(eligible.enable)
+ assertFalse(eligible.disable)
+ assertFalse(incompatible.enable)
+ assertFalse(inspectionFailure.enable)
+ }
+
+ @Test
+ fun `signature change offers reauthorization independently of enable`() {
+ val actions = actions(
+ enabled = false,
+ state = ExtensionState.DISABLED,
+ signatureChanged = true,
+ hasAuthorizationToken = true,
+ )
+
+ assertFalse(actions.enable)
+ assertTrue(actions.reauthorize)
+ assertTrue(actions.revoke)
+ }
+
+ private fun actions(
+ enabled: Boolean,
+ state: ExtensionState,
+ hasExtensionId: Boolean = true,
+ installed: Boolean = true,
+ signatureChanged: Boolean = false,
+ hasInspectionError: Boolean = false,
+ hasAuthorizationToken: Boolean = false,
+ trusted: Boolean = false,
+ canClearData: Boolean = false,
+ ) = extensionPluginActionAvailability(
+ enabled = enabled,
+ state = state,
+ hasExtensionId = hasExtensionId,
+ installed = installed,
+ signatureChanged = signatureChanged,
+ hasInspectionError = hasInspectionError,
+ hasAuthorizationToken = hasAuthorizationToken,
+ trusted = trusted,
+ canClearData = canClearData,
+ )
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginDetailPresentationTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginDetailPresentationTest.kt
new file mode 100644
index 000000000..44289177d
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionPluginDetailPresentationTest.kt
@@ -0,0 +1,154 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.business.setting.ExtensionPluginDiscoveryState
+import com.m3u.data.repository.plugin.InstalledPlugin
+import com.m3u.extension.api.ExtensionState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+class ExtensionPluginDetailPresentationTest {
+ @Test
+ fun `unresolved discovery never pretends that the plugin was removed`() {
+ assertIs(
+ resolve(ExtensionPluginDiscoveryState.Loading())
+ )
+ assertIs(
+ resolve(ExtensionPluginDiscoveryState.Error())
+ )
+ }
+
+ @Test
+ fun `authoritative discovery is required before a plugin is missing`() {
+ assertIs(
+ resolve(ExtensionPluginDiscoveryState.Empty)
+ )
+ assertIs(
+ resolve(
+ ExtensionPluginDiscoveryState.Content(
+ listOf(plugin(serviceName = "another.Service"))
+ )
+ )
+ )
+ }
+
+ @Test
+ fun `refresh keeps the previous detail content and its discovery status`() {
+ val target = plugin()
+ val refreshing = assertIs(
+ resolve(ExtensionPluginDiscoveryState.Loading(listOf(target)))
+ )
+ val failed = assertIs(
+ resolve(ExtensionPluginDiscoveryState.Error(listOf(target)))
+ )
+ val ready = assertIs(
+ resolve(ExtensionPluginDiscoveryState.Content(listOf(target)))
+ )
+
+ assertSame(target, refreshing.plugin)
+ assertEquals(
+ ExtensionPluginDiscoveryStatus.REFRESHING,
+ refreshing.discoveryStatus,
+ )
+ assertSame(target, failed.plugin)
+ assertEquals(
+ ExtensionPluginDiscoveryStatus.REFRESH_FAILED,
+ failed.discoveryStatus,
+ )
+ assertSame(target, ready.plugin)
+ assertEquals(ExtensionPluginDiscoveryStatus.READY, ready.discoveryStatus)
+ }
+
+ @Test
+ fun `compact actions stack before localized labels become narrow columns`() {
+ assertTrue(
+ shouldStackExtensionPluginActions(
+ availableWidthDp = 288f,
+ fontScale = 1f,
+ actionCount = 3,
+ )
+ )
+ assertTrue(
+ shouldStackExtensionPluginActions(
+ availableWidthDp = 328f,
+ fontScale = 1f,
+ actionCount = 3,
+ )
+ )
+ assertTrue(
+ shouldStackExtensionPluginActions(
+ availableWidthDp = 379f,
+ fontScale = 1.3f,
+ actionCount = 3,
+ )
+ )
+ assertFalse(
+ shouldStackExtensionPluginActions(
+ availableWidthDp = 400f,
+ fontScale = 1f,
+ actionCount = 3,
+ )
+ )
+ assertFalse(
+ shouldStackExtensionPluginActions(
+ availableWidthDp = 568f,
+ fontScale = 1.3f,
+ actionCount = 3,
+ )
+ )
+ assertTrue(
+ shouldStackExtensionPluginActions(
+ availableWidthDp = 608f,
+ fontScale = 2f,
+ actionCount = 3,
+ )
+ )
+ assertFalse(
+ shouldStackExtensionPluginActions(
+ availableWidthDp = 120f,
+ fontScale = 2f,
+ actionCount = 1,
+ )
+ )
+ }
+
+ private fun resolve(
+ state: ExtensionPluginDiscoveryState,
+ ): ExtensionPluginDetailContentState =
+ resolveExtensionPluginDetailContentState(
+ discoveryState = state,
+ packageName = PACKAGE_NAME,
+ serviceName = SERVICE_NAME,
+ )
+
+ private fun plugin(
+ serviceName: String = SERVICE_NAME,
+ ): InstalledPlugin = InstalledPlugin(
+ packageName = PACKAGE_NAME,
+ serviceName = serviceName,
+ certificateSha256 = "AA",
+ previousCertificateSha256 = null,
+ trusted = false,
+ signatureChanged = false,
+ extensionId = null,
+ enabled = false,
+ state = ExtensionState.DISABLED,
+ displayName = "Reference",
+ version = "1.0.0",
+ developer = "M3UAndroid",
+ requestedCapabilities = emptySet(),
+ grantedCapabilities = emptySet(),
+ capabilityPermissions = emptyList(),
+ inspectionError = null,
+ installed = true,
+ canClearData = false,
+ )
+
+ private companion object {
+ const val PACKAGE_NAME = "com.example.reference"
+ const val SERVICE_NAME = "com.example.reference.ExtensionService"
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionSettingsPresentationPolicyTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionSettingsPresentationPolicyTest.kt
new file mode 100644
index 000000000..edb4c605d
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ExtensionSettingsPresentationPolicyTest.kt
@@ -0,0 +1,86 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import androidx.compose.ui.text.style.TextDirection
+import com.m3u.data.repository.extension.ExtensionNetworkOriginState
+import com.m3u.extension.api.ExtensionSettingField
+import com.m3u.extension.api.ExtensionSettingType
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ExtensionSettingsPresentationPolicyTest {
+ @Test
+ fun `explicitly directional setting values use left to right text direction`() {
+ val technicalFields = listOf(
+ settingField(key = "port", type = ExtensionSettingType.NUMBER),
+ settingField(key = "server", networkOrigin = true),
+ )
+
+ technicalFields.forEach { field ->
+ assertEquals(
+ TextDirection.Ltr,
+ extensionSettingInputTextDirection(field),
+ "Expected ${field.key} to use LTR input",
+ )
+ }
+ }
+
+ @Test
+ fun `unconstrained setting values follow their content direction`() {
+ listOf(
+ "display_name",
+ "base_url",
+ "postal_address",
+ "password",
+ ).forEach { key ->
+ assertEquals(
+ TextDirection.ContentOrLtr,
+ extensionSettingInputTextDirection(
+ settingField(
+ key = key,
+ type = if (key == "password") {
+ ExtensionSettingType.SECRET
+ } else {
+ ExtensionSettingType.TEXT
+ },
+ )
+ ),
+ "Expected $key to follow its content direction",
+ )
+ }
+ }
+
+ @Test
+ fun `unchanged network origin can be resubmitted for approval`() {
+ assertTrue(
+ shouldShowExtensionSettingSaveAction(
+ dirty = false,
+ networkOriginState = ExtensionNetworkOriginState.REQUIRES_APPROVAL,
+ )
+ )
+ assertFalse(
+ shouldShowExtensionSettingSaveAction(
+ dirty = false,
+ networkOriginState = ExtensionNetworkOriginState.APPROVED,
+ )
+ )
+ assertTrue(
+ shouldShowExtensionSettingSaveAction(
+ dirty = true,
+ networkOriginState = ExtensionNetworkOriginState.NOT_CONFIGURED,
+ )
+ )
+ }
+
+ private fun settingField(
+ key: String,
+ type: ExtensionSettingType = ExtensionSettingType.TEXT,
+ networkOrigin: Boolean = false,
+ ): ExtensionSettingField = ExtensionSettingField(
+ key = key,
+ label = key,
+ type = type,
+ networkOrigin = networkOrigin,
+ )
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/PlaylistManagementPresentationTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/PlaylistManagementPresentationTest.kt
new file mode 100644
index 000000000..680e48b2d
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/PlaylistManagementPresentationTest.kt
@@ -0,0 +1,33 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import java.util.Locale
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class PlaylistManagementPresentationTest {
+ @Test
+ fun `playlist titles follow the current locale collation`() {
+ val titles = listOf("Örebro", "Zebra", "Ängelholm", "Åland")
+
+ assertEquals(
+ listOf("Zebra", "Åland", "Ängelholm", "Örebro"),
+ titles.sortedWith(
+ playlistTitleComparator(Locale.forLanguageTag("sv-SE"))
+ ),
+ )
+ assertEquals(
+ listOf("Åland", "Ängelholm", "Örebro", "Zebra"),
+ titles.sortedWith(
+ playlistTitleComparator(Locale.forLanguageTag("en-US"))
+ ),
+ )
+ }
+
+ @Test
+ fun `playlist title collation remains case insensitive`() {
+ val comparator = playlistTitleComparator(Locale.ENGLISH)
+
+ assertEquals(0, comparator.compare("News", "news"))
+ assertEquals(0, comparator.compare("News\u202E", "News"))
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ProviderDiscoveryUiPolicyTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ProviderDiscoveryUiPolicyTest.kt
new file mode 100644
index 000000000..481fdf7cd
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ProviderDiscoveryUiPolicyTest.kt
@@ -0,0 +1,49 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.business.setting.ProviderDiscoveryState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ProviderDiscoveryUiPolicyTest {
+ @Test
+ fun `ordinary source exposes loading and recoverable discovery failures`() {
+ assertEquals(
+ ProviderDiscoveryNotice.LOADING,
+ providerDiscoveryNotice(
+ state = ProviderDiscoveryState.Loading,
+ providerSelected = false,
+ ),
+ )
+ assertEquals(
+ ProviderDiscoveryNotice.EMPTY,
+ providerDiscoveryNotice(
+ state = ProviderDiscoveryState.Empty,
+ providerSelected = false,
+ ),
+ )
+ assertEquals(
+ ProviderDiscoveryNotice.FAILED,
+ providerDiscoveryNotice(
+ state = ProviderDiscoveryState.Failed(failureCount = 1),
+ providerSelected = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `provider form owns its discovery notice when selected`() {
+ listOf(
+ ProviderDiscoveryState.Loading,
+ ProviderDiscoveryState.Empty,
+ ProviderDiscoveryState.Failed(failureCount = 1),
+ ).forEach { state ->
+ assertEquals(
+ ProviderDiscoveryNotice.NONE,
+ providerDiscoveryNotice(
+ state = state,
+ providerSelected = true,
+ ),
+ )
+ }
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ProviderDisplayNameTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ProviderDisplayNameTest.kt
new file mode 100644
index 000000000..517760182
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/ProviderDisplayNameTest.kt
@@ -0,0 +1,70 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.business.setting.ProviderDiscoveryState
+import com.m3u.data.repository.provider.DiscoveredSubscriptionProvider
+import com.m3u.data.repository.provider.ProviderAccountSummary
+import com.m3u.data.repository.provider.SubscriptionProviderExecutionKind
+import com.m3u.extension.api.ExtensionId
+import com.m3u.extension.api.subscription.ProviderKind
+import com.m3u.extension.api.subscription.SubscriptionProviderDescriptor
+import com.m3u.extension.api.subscription.SubscriptionProviderVariant
+import com.m3u.smartphone.ui.business.configuration.providerDisplayName
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ProviderDisplayNameTest {
+ @Test
+ fun `current provider variant name is preferred`() {
+ assertEquals(
+ "Cinema Cloud",
+ providerDisplayName(
+ account = ACCOUNT,
+ discoveryState = ProviderDiscoveryState.Ready(
+ providers = listOf(
+ DiscoveredSubscriptionProvider(
+ descriptor = SubscriptionProviderDescriptor(
+ providerId = PROVIDER_ID,
+ displayName = "Cinema",
+ variants = listOf(
+ SubscriptionProviderVariant(
+ kind = PROVIDER_KIND,
+ displayName = "Cinema Cloud",
+ )
+ ),
+ ),
+ executionKind = SubscriptionProviderExecutionKind.EXTERNAL,
+ )
+ ),
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun `server name is a safe fallback when discovery is unavailable`() {
+ assertEquals(
+ "Living Room Server",
+ providerDisplayName(
+ account = ACCOUNT.copy(
+ serverName = "\u202ELiving Room Server\u202C",
+ ),
+ discoveryState = ProviderDiscoveryState.Failed(failureCount = 1),
+ ),
+ )
+ }
+
+ private companion object {
+ val PROVIDER_ID = ExtensionId("example.cinema")
+ val PROVIDER_KIND = ProviderKind("cloud")
+ val ACCOUNT = ProviderAccountSummary(
+ playlistTitle = "Living room",
+ playlistUrl = "provider://account",
+ providerId = PROVIDER_ID,
+ providerKind = PROVIDER_KIND,
+ baseUrl = "https://example.invalid",
+ username = "viewer",
+ serverName = "Living Room Server",
+ requiresReauthentication = false,
+ )
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionSourceKeyTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionSourceKeyTest.kt
new file mode 100644
index 000000000..dd71b902c
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/SubscriptionSourceKeyTest.kt
@@ -0,0 +1,29 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.data.database.model.DataSource
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class SubscriptionSourceKeyTest {
+ @Test
+ fun `ordinary source keys round trip without depending on display text`() {
+ listOf(DataSource.M3U, DataSource.EPG, DataSource.Xtream).forEach { source ->
+ assertEquals(
+ source,
+ ordinarySubscriptionSourceOrNull(source.subscriptionSelectionKey()),
+ )
+ }
+ }
+
+ @Test
+ fun `provider keys are namespaced and never decode as ordinary sources`() {
+ val key = providerSourceSelectionKey(
+ providerId = "com.example.provider",
+ providerKind = "live",
+ )
+
+ assertEquals("provider:com.example.provider:live", key)
+ assertNull(ordinarySubscriptionSourceOrNull(key))
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/UiBidiFormatterTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/UiBidiFormatterTest.kt
new file mode 100644
index 000000000..c71598d29
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/business/setting/fragments/UiBidiFormatterTest.kt
@@ -0,0 +1,82 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import com.m3u.smartphone.ui.material.ktx.UiBidiFormatter
+import com.m3u.smartphone.ui.material.ktx.safeDisplayText
+import com.m3u.smartphone.ui.material.ktx.withoutBidiControls
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class UiBidiFormatterTest {
+ @Test
+ fun `strip removes every bidi control accepted from dynamic metadata`() {
+ val controls = "\u061C\u200E\u200F\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069"
+
+ assertEquals("Plugin name", "Plugin${controls} name".withoutBidiControls())
+ }
+
+ @Test
+ fun `natural metadata is sanitized without changing its readable content`() {
+ val formatted = "Safe\u202Eexe".withoutBidiControls()
+
+ assertTrue(formatted.contains("Safeexe"))
+ assertFalse(formatted.contains('\u202E'))
+ }
+
+ @Test
+ fun `technical identifiers remain intact after sanitizing`() {
+ val formatted = "com.example\u2066.plugin".withoutBidiControls()
+
+ assertTrue(formatted.contains("com.example.plugin"))
+ assertFalse(formatted.contains('\u2066'))
+ }
+
+ @Test
+ fun `display sanitizing removes controls without reversing rtl words`() {
+ val formatted = "العربية\u202E\nقناة\u2029".safeDisplayText()
+
+ assertEquals("العربيةقناة", formatted)
+ assertFalse(formatted.any(Char::isISOControl))
+ }
+
+ @Test
+ fun `standalone technical values are not truncated at the display metadata limit`() {
+ val host = List(4) { index ->
+ ('a' + index).toString().repeat(61)
+ }.joinToString(".")
+ val url = "https://$host:443"
+
+ assertTrue(url.length > 256)
+ assertEquals(
+ url,
+ UiBidiFormatter(isRtlContext = false).standaloneTechnical(url),
+ )
+ }
+
+ @Test
+ fun `rtl context does not wrap or reverse standalone technical values`() {
+ val value = "com.example.extension.ReferenceService"
+ val formatted = UiBidiFormatter(isRtlContext = true)
+ .standaloneTechnical("com.example\u202E.extension\u2066.ReferenceService\u2069")
+
+ assertEquals(value, formatted)
+ assertFalse(formatted.any(::isBidiControl))
+ }
+
+ @Test
+ fun `standalone technical values remove non bidi dangerous characters`() {
+ val formatted = UiBidiFormatter(isRtlContext = true)
+ .standaloneTechnical("https://api\u0000\t\n\u2028\u2029.example.test:443")
+
+ assertEquals("https://api.example.test:443", formatted)
+ assertFalse(formatted.any(Char::isISOControl))
+ }
+
+ private fun isBidiControl(character: Char): Boolean =
+ character.code in 0x202A..0x202E ||
+ character.code in 0x2066..0x2069 ||
+ character.code == 0x061C ||
+ character.code == 0x200E ||
+ character.code == 0x200F
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/common/connect/VirtualNumberKeyboardPolicyTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/common/connect/VirtualNumberKeyboardPolicyTest.kt
new file mode 100644
index 000000000..8fe1f5272
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/common/connect/VirtualNumberKeyboardPolicyTest.kt
@@ -0,0 +1,19 @@
+package com.m3u.smartphone.ui.common.connect
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class VirtualNumberKeyboardPolicyTest {
+ @Test
+ fun `digit keys remain enabled until the sixth digit`() {
+ assertTrue(areRemoteDigitKeysEnabled(codeLength = 0))
+ assertTrue(areRemoteDigitKeysEnabled(codeLength = 5))
+ }
+
+ @Test
+ fun `digit keys are disabled at and beyond the six digit limit`() {
+ assertFalse(areRemoteDigitKeysEnabled(codeLength = 6))
+ assertFalse(areRemoteDigitKeysEnabled(codeLength = 7))
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/components/SnackHostTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/components/SnackHostTest.kt
new file mode 100644
index 000000000..ce93e3b19
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/components/SnackHostTest.kt
@@ -0,0 +1,39 @@
+package com.m3u.smartphone.ui.material.components
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SnackHostTest {
+ @Test
+ fun `accessibility recommendation can extend the message`() {
+ assertEquals(
+ expected = 12_000L,
+ actual = calculateSnackTimeoutMillis(
+ originalTimeoutMillis = 3_000L,
+ recommendedTimeoutMillis = 12_000L,
+ ),
+ )
+ }
+
+ @Test
+ fun `recommendation never shortens the message`() {
+ assertEquals(
+ expected = 3_000L,
+ actual = calculateSnackTimeoutMillis(
+ originalTimeoutMillis = 3_000L,
+ recommendedTimeoutMillis = 1_000L,
+ ),
+ )
+ }
+
+ @Test
+ fun `infinite recommendation remains infinite`() {
+ assertEquals(
+ expected = Long.MAX_VALUE,
+ actual = calculateSnackTimeoutMillis(
+ originalTimeoutMillis = 3_000L,
+ recommendedTimeoutMillis = Long.MAX_VALUE,
+ ),
+ )
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/components/mask/MaskStateTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/components/mask/MaskStateTest.kt
new file mode 100644
index 000000000..407419c14
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/components/mask/MaskStateTest.kt
@@ -0,0 +1,39 @@
+package com.m3u.smartphone.ui.material.components.mask
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class MaskStateTest {
+ @Test
+ fun `recommended duration never shortens the default`() {
+ assertEquals(
+ expected = 4L,
+ actual = calculateRecommendedMaskDurationSeconds(
+ baseDurationSeconds = 4L,
+ recommendedDurationMillis = 2_000L,
+ ),
+ )
+ }
+
+ @Test
+ fun `recommended duration rounds partial seconds up`() {
+ assertEquals(
+ expected = 7L,
+ actual = calculateRecommendedMaskDurationSeconds(
+ baseDurationSeconds = 4L,
+ recommendedDurationMillis = 6_001L,
+ ),
+ )
+ }
+
+ @Test
+ fun `infinite recommendation disables automatic timeout`() {
+ assertEquals(
+ expected = Long.MAX_VALUE,
+ actual = calculateRecommendedMaskDurationSeconds(
+ baseDurationSeconds = 4L,
+ recommendedDurationMillis = Long.MAX_VALUE,
+ ),
+ )
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/BlursTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/BlursTest.kt
new file mode 100644
index 000000000..31bab6961
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/BlursTest.kt
@@ -0,0 +1,45 @@
+package com.m3u.smartphone.ui.material.ktx
+
+import androidx.compose.ui.unit.LayoutDirection
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class BlursTest {
+ @Test
+ fun `logical horizontal edges resolve to their physical ltr edges`() {
+ assertEquals(
+ Edge.Start,
+ resolvePhysicalEdge(Edge.Start, LayoutDirection.Ltr),
+ )
+ assertEquals(
+ Edge.End,
+ resolvePhysicalEdge(Edge.End, LayoutDirection.Ltr),
+ )
+ }
+
+ @Test
+ fun `logical horizontal edges mirror in rtl`() {
+ assertEquals(
+ Edge.End,
+ resolvePhysicalEdge(Edge.Start, LayoutDirection.Rtl),
+ )
+ assertEquals(
+ Edge.Start,
+ resolvePhysicalEdge(Edge.End, LayoutDirection.Rtl),
+ )
+ }
+
+ @Test
+ fun `vertical edges do not change with layout direction`() {
+ LayoutDirection.entries.forEach { layoutDirection ->
+ assertEquals(
+ Edge.Top,
+ resolvePhysicalEdge(Edge.Top, layoutDirection),
+ )
+ assertEquals(
+ Edge.Bottom,
+ resolvePhysicalEdge(Edge.Bottom, layoutDirection),
+ )
+ }
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/PaddingValuesTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/PaddingValuesTest.kt
new file mode 100644
index 000000000..0286e4b78
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/PaddingValuesTest.kt
@@ -0,0 +1,30 @@
+package com.m3u.smartphone.ui.material.ktx
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class PaddingValuesTest {
+ private val asymmetric = PaddingValues(start = 11.dp, end = 29.dp)
+
+ @Test
+ fun `end keeps logical end padding in ltr`() {
+ val result = asymmetric.only(WindowInsetsSides.End, LayoutDirection.Ltr)
+
+ assertEquals(0.dp, result.calculateStartPadding(LayoutDirection.Ltr))
+ assertEquals(29.dp, result.calculateEndPadding(LayoutDirection.Ltr))
+ }
+
+ @Test
+ fun `end keeps logical end padding in rtl`() {
+ val result = asymmetric.only(WindowInsetsSides.End, LayoutDirection.Rtl)
+
+ assertEquals(0.dp, result.calculateStartPadding(LayoutDirection.Rtl))
+ assertEquals(29.dp, result.calculateEndPadding(LayoutDirection.Rtl))
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/SourceReferenceFormatterTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/SourceReferenceFormatterTest.kt
new file mode 100644
index 000000000..43e555d74
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/SourceReferenceFormatterTest.kt
@@ -0,0 +1,54 @@
+package com.m3u.smartphone.ui.material.ktx
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class SourceReferenceFormatterTest {
+ @Test
+ fun `network references expose only their origin`() {
+ val reference =
+ "https://viewer:secret@example.com:8443/private-token/playlist.m3u" +
+ "?username=viewer&password=secret#token"
+
+ val displayed = reference.safeSourceReference()
+
+ assertEquals("https://example.com:8443", displayed)
+ listOf("viewer", "secret", "private-token", "playlist.m3u", "username")
+ .forEach { sensitiveValue ->
+ assertTrue(sensitiveValue !in displayed.orEmpty())
+ }
+ }
+
+ @Test
+ fun `long user info is removed before display text is bounded`() {
+ val secret = "s".repeat(600)
+
+ val displayed =
+ "https://viewer:$secret@example.com/list.m3u".safeSourceReference()
+
+ assertEquals("https://example.com", displayed)
+ assertTrue(secret.take(100) !in displayed.orEmpty())
+ }
+
+ @Test
+ fun `local references expose only a bounded file name`() {
+ assertEquals(
+ "channels.m3u",
+ "content://media/document/folder/channels.m3u?grant=secret"
+ .safeSourceReference(),
+ )
+ assertEquals(
+ "guide.xml",
+ "file:///private/folder/guide.xml".safeSourceReference(),
+ )
+ }
+
+ @Test
+ fun `opaque and malformed references stay hidden`() {
+ assertNull("provider:private-account".safeSourceReference())
+ assertNull("relative/private-token/playlist.m3u".safeSourceReference())
+ assertNull(" \u202E ".safeSourceReference())
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/ThemeColorSchemeTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/ThemeColorSchemeTest.kt
new file mode 100644
index 000000000..90a72cf99
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/material/ktx/ThemeColorSchemeTest.kt
@@ -0,0 +1,173 @@
+package com.m3u.smartphone.ui.material.ktx
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import com.m3u.core.foundation.architecture.preferences.ThemePreset
+import com.m3u.core.foundation.architecture.preferences.ThemeStyle
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
+
+class ThemeColorSchemeTest {
+ @Test
+ fun `warm editorial theme uses paper and ink surfaces`() {
+ val light = createAppColorScheme(
+ argb = ThemePreset.WARM_EDITORIAL_SEED,
+ isDark = false,
+ themeStyle = ThemeStyle.WARM_EDITORIAL,
+ )
+ val dark = createAppColorScheme(
+ argb = ThemePreset.WARM_EDITORIAL_SEED,
+ isDark = true,
+ themeStyle = ThemeStyle.WARM_EDITORIAL,
+ )
+
+ assertEquals(Color(0xFFFAF9F5), light.background)
+ assertEquals(Color(0xFF141413), dark.background)
+ assertNotEquals(light.surface, light.surfaceContainer)
+ assertNotEquals(dark.surface, dark.surfaceContainer)
+ }
+
+ @Test
+ fun `warm editorial semantic text pairs meet normal text contrast`() {
+ listOf(false, true).forEach { isDark ->
+ createAppColorScheme(
+ argb = ThemePreset.WARM_EDITORIAL_SEED,
+ isDark = isDark,
+ themeStyle = ThemeStyle.WARM_EDITORIAL,
+ ).assertSemanticContrast()
+ }
+ }
+
+ @Test
+ fun `material style keeps using the selected seed`() {
+ val generic = createScheme(
+ argb = ThemePreset.WARM_EDITORIAL_SEED,
+ isDark = false,
+ )
+ val resolved = createAppColorScheme(
+ argb = ThemePreset.WARM_EDITORIAL_SEED,
+ isDark = false,
+ themeStyle = ThemeStyle.MATERIAL,
+ )
+
+ assertEquals(generic.primary, resolved.primary)
+ assertEquals(generic.secondary, resolved.secondary)
+ assertEquals(generic.background, resolved.background)
+ assertEquals(generic.surfaceContainer, resolved.surfaceContainer)
+ }
+
+ @Test
+ fun `all app schemes provide contrast safe fixed color roles`() {
+ listOf(ThemeStyle.MATERIAL, ThemeStyle.WARM_EDITORIAL).forEach { style ->
+ listOf(
+ ThemePreset.DEFAULT_MATERIAL_SEED,
+ ThemePreset.WARM_EDITORIAL_SEED,
+ 0xCE5B73,
+ ).forEach { seed ->
+ listOf(false, true).forEach { isDark ->
+ createAppColorScheme(
+ argb = seed,
+ isDark = isDark,
+ themeStyle = style,
+ ).assertFixedRoles()
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `raw color preview always chooses contrast safe black or white`() {
+ listOf(
+ Color.Black,
+ Color.White,
+ Color(0xFFD97757),
+ Color(0xFF777777),
+ Color(0xFF53653F),
+ ).forEach { background ->
+ val foreground = contrastingContentColor(background)
+ assertTrue(contrastRatio(foreground, background) >= MINIMUM_NORMAL_TEXT_CONTRAST)
+ }
+ }
+}
+
+private fun ColorScheme.assertSemanticContrast() {
+ val pairs = listOf(
+ onPrimary to primary,
+ onPrimaryContainer to primaryContainer,
+ onSecondary to secondary,
+ onSecondaryContainer to secondaryContainer,
+ onTertiary to tertiary,
+ onTertiaryContainer to tertiaryContainer,
+ onBackground to background,
+ onSurface to surface,
+ onSurfaceVariant to surfaceVariant,
+ inverseOnSurface to inverseSurface,
+ onError to error,
+ onErrorContainer to errorContainer,
+ )
+
+ pairs.forEach { (foreground, background) ->
+ val contrast = contrastRatio(foreground, background)
+ assertTrue(
+ actual = contrast >= MINIMUM_NORMAL_TEXT_CONTRAST,
+ message = "$foreground on $background has only $contrast:1 contrast",
+ )
+ }
+}
+
+private fun contrastRatio(first: Color, second: Color): Float {
+ val firstLuminance = first.luminance()
+ val secondLuminance = second.luminance()
+ return (max(firstLuminance, secondLuminance) + 0.05f) /
+ (min(firstLuminance, secondLuminance) + 0.05f)
+}
+
+private fun ColorScheme.assertFixedRoles() {
+ val colors = listOf(
+ primaryFixed,
+ primaryFixedDim,
+ onPrimaryFixed,
+ onPrimaryFixedVariant,
+ secondaryFixed,
+ secondaryFixedDim,
+ onSecondaryFixed,
+ onSecondaryFixedVariant,
+ tertiaryFixed,
+ tertiaryFixedDim,
+ onTertiaryFixed,
+ onTertiaryFixedVariant,
+ )
+ colors.forEach { color ->
+ assertNotEquals(Color.Unspecified, color)
+ }
+
+ val pairs = listOf(
+ onPrimaryFixed to primaryFixed,
+ onPrimaryFixed to primaryFixedDim,
+ onPrimaryFixedVariant to primaryFixed,
+ onPrimaryFixedVariant to primaryFixedDim,
+ onSecondaryFixed to secondaryFixed,
+ onSecondaryFixed to secondaryFixedDim,
+ onSecondaryFixedVariant to secondaryFixed,
+ onSecondaryFixedVariant to secondaryFixedDim,
+ onTertiaryFixed to tertiaryFixed,
+ onTertiaryFixed to tertiaryFixedDim,
+ onTertiaryFixedVariant to tertiaryFixed,
+ onTertiaryFixedVariant to tertiaryFixedDim,
+ )
+ pairs.forEach { (foreground, background) ->
+ val contrast = contrastRatio(foreground, background)
+ assertTrue(
+ actual = contrast >= MINIMUM_NORMAL_TEXT_CONTRAST,
+ message = "$foreground on $background has only $contrast:1 contrast",
+ )
+ }
+ assertEquals(primary, surfaceTint)
+}
+
+private const val MINIMUM_NORMAL_TEXT_CONTRAST = 4.5f
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/navigation/AppNavigationPolicyTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/navigation/AppNavigationPolicyTest.kt
new file mode 100644
index 000000000..72cc5a19c
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/navigation/AppNavigationPolicyTest.kt
@@ -0,0 +1,370 @@
+package com.m3u.smartphone.ui.navigation
+
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class AppNavigationPolicyTest {
+ @Test
+ fun `window widths below 600 dp use an overlaid bottom navigation`() {
+ assertEquals(AppNavigationMode.BottomOverlay, resolveAppNavigationMode(320.dp))
+ assertEquals(AppNavigationMode.BottomOverlay, resolveAppNavigationMode(599.dp))
+ }
+
+ @Test
+ fun `window widths at or above 600 dp use a side rail`() {
+ assertEquals(AppNavigationMode.SideRail, resolveAppNavigationMode(600.dp))
+ assertEquals(AppNavigationMode.SideRail, resolveAppNavigationMode(840.dp))
+ }
+
+ @Test
+ fun `bottom edge blur is retained only for the side rail layout`() {
+ assertFalse(shouldShowBottomEdgeBlur(AppNavigationMode.BottomOverlay))
+ assertTrue(shouldShowBottomEdgeBlur(AppNavigationMode.SideRail))
+ }
+
+ @Test
+ fun `bottom navigation is shown only on an unobstructed compact top-level route`() {
+ assertTrue(
+ shouldShowBottomNavigation(
+ mode = AppNavigationMode.BottomOverlay,
+ isTopLevelRoute = true,
+ isSearchActive = false,
+ isImeVisible = false,
+ )
+ )
+
+ assertFalse(
+ shouldShowBottomNavigation(
+ mode = AppNavigationMode.BottomOverlay,
+ isTopLevelRoute = false,
+ isSearchActive = false,
+ isImeVisible = false,
+ )
+ )
+ assertFalse(
+ shouldShowBottomNavigation(
+ mode = AppNavigationMode.BottomOverlay,
+ isTopLevelRoute = true,
+ isSearchActive = true,
+ isImeVisible = false,
+ )
+ )
+ assertFalse(
+ shouldShowBottomNavigation(
+ mode = AppNavigationMode.BottomOverlay,
+ isTopLevelRoute = true,
+ isSearchActive = false,
+ isImeVisible = true,
+ )
+ )
+ assertFalse(
+ shouldShowBottomNavigation(
+ mode = AppNavigationMode.SideRail,
+ isTopLevelRoute = true,
+ isSearchActive = false,
+ isImeVisible = false,
+ )
+ )
+ }
+
+ @Test
+ fun `nested tasks use contextual chrome in compact and rail layouts`() {
+ assertFalse(
+ shouldShowContextualTopBar(
+ isRootPlaylistConfiguration = false,
+ isNestedDetailVisible = false,
+ )
+ )
+ assertTrue(
+ shouldShowContextualTopBar(
+ isRootPlaylistConfiguration = false,
+ isNestedDetailVisible = true,
+ )
+ )
+ assertTrue(
+ shouldShowContextualTopBar(
+ isRootPlaylistConfiguration = true,
+ isNestedDetailVisible = false,
+ )
+ )
+ }
+
+ @Test
+ fun `remote control action is limited to unobstructed top-level content`() {
+ assertTrue(
+ shouldShowRemoteControlAction(
+ remoteControlEnabled = true,
+ isSearchActive = false,
+ isImeVisible = false,
+ isRootPlaylistConfiguration = false,
+ isNestedDetailVisible = false,
+ )
+ )
+ assertFalse(
+ shouldShowRemoteControlAction(
+ remoteControlEnabled = true,
+ isSearchActive = false,
+ isImeVisible = false,
+ isRootPlaylistConfiguration = false,
+ isNestedDetailVisible = true,
+ )
+ )
+ assertFalse(
+ shouldShowRemoteControlAction(
+ remoteControlEnabled = true,
+ isSearchActive = true,
+ isImeVisible = false,
+ isRootPlaylistConfiguration = false,
+ isNestedDetailVisible = false,
+ )
+ )
+ assertFalse(
+ shouldShowRemoteControlAction(
+ remoteControlEnabled = true,
+ isSearchActive = false,
+ isImeVisible = true,
+ isRootPlaylistConfiguration = false,
+ isNestedDetailVisible = false,
+ )
+ )
+ assertFalse(
+ shouldShowRemoteControlAction(
+ remoteControlEnabled = true,
+ isSearchActive = false,
+ isImeVisible = false,
+ isRootPlaylistConfiguration = true,
+ isNestedDetailVisible = false,
+ )
+ )
+ assertFalse(
+ shouldShowRemoteControlAction(
+ remoteControlEnabled = false,
+ isSearchActive = false,
+ isImeVisible = false,
+ isRootPlaylistConfiguration = false,
+ isNestedDetailVisible = false,
+ )
+ )
+ }
+
+ @Test
+ fun `backdrop capture follows glass support and the full visibility transition`() {
+ assertFalse(
+ shouldCaptureNavigationBackdrop(
+ mode = AppNavigationMode.BottomOverlay,
+ supportsBackdropEffects = false,
+ isNavigationCurrentlyVisible = true,
+ isNavigationTargetVisible = true,
+ )
+ )
+ assertTrue(
+ shouldCaptureNavigationBackdrop(
+ mode = AppNavigationMode.BottomOverlay,
+ supportsBackdropEffects = true,
+ isNavigationCurrentlyVisible = false,
+ isNavigationTargetVisible = true,
+ )
+ )
+ assertTrue(
+ shouldCaptureNavigationBackdrop(
+ mode = AppNavigationMode.BottomOverlay,
+ supportsBackdropEffects = true,
+ isNavigationCurrentlyVisible = true,
+ isNavigationTargetVisible = false,
+ )
+ )
+ assertFalse(
+ shouldCaptureNavigationBackdrop(
+ mode = AppNavigationMode.BottomOverlay,
+ supportsBackdropEffects = true,
+ isNavigationCurrentlyVisible = false,
+ isNavigationTargetVisible = false,
+ )
+ )
+ assertFalse(
+ shouldCaptureNavigationBackdrop(
+ mode = AppNavigationMode.SideRail,
+ supportsBackdropEffects = true,
+ isNavigationCurrentlyVisible = true,
+ isNavigationTargetVisible = true,
+ )
+ )
+ }
+
+ @Test
+ fun `bottom navigation keeps its clearance through the complete exit transition`() {
+ assertTrue(
+ shouldReserveBottomNavigationSpace(
+ mode = AppNavigationMode.BottomOverlay,
+ isNavigationCurrentlyVisible = true,
+ isNavigationTargetVisible = false,
+ )
+ )
+ assertTrue(
+ shouldReserveBottomNavigationSpace(
+ mode = AppNavigationMode.BottomOverlay,
+ isNavigationCurrentlyVisible = false,
+ isNavigationTargetVisible = true,
+ )
+ )
+ assertFalse(
+ shouldReserveBottomNavigationSpace(
+ mode = AppNavigationMode.BottomOverlay,
+ isNavigationCurrentlyVisible = false,
+ isNavigationTargetVisible = false,
+ )
+ )
+ assertFalse(
+ shouldReserveBottomNavigationSpace(
+ mode = AppNavigationMode.SideRail,
+ isNavigationCurrentlyVisible = true,
+ isNavigationTargetVisible = true,
+ )
+ )
+ }
+
+ @Test
+ fun `floating navigation width follows its item count instead of filling the phone`() {
+ assertEquals(
+ 236.dp,
+ calculateFloatingNavigationWidth(
+ containerWidth = 432.dp,
+ itemCount = 3,
+ )
+ )
+ assertEquals(
+ 200.dp,
+ calculateFloatingNavigationWidth(
+ containerWidth = 240.dp,
+ itemCount = 3,
+ )
+ )
+ }
+
+ @Test
+ fun `trailing remote action keeps compact navigation touch targets usable`() {
+ val navigationWidth = calculateFloatingNavigationDockNavigationWidth(
+ containerWidth = 320.dp,
+ itemCount = 3,
+ trailingActionVisible = true,
+ )
+
+ assertEquals(204.dp, navigationWidth)
+ assertEquals(280.dp, navigationWidth + 76.dp)
+ assertTrue((navigationWidth - 8.dp) / 3 >= 48.dp)
+ assertEquals(
+ 236.dp,
+ calculateFloatingNavigationDockNavigationWidth(
+ containerWidth = 320.dp,
+ itemCount = 3,
+ trailingActionVisible = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `compact top-level content clears the measured navigation and both 12 dp gaps`() {
+ assertEquals(
+ 120.dp,
+ calculateContentBottomPadding(
+ mode = AppNavigationMode.BottomOverlay,
+ isTopLevelRoute = true,
+ safeBottomInset = 24.dp,
+ measuredNavigationHeight = 72.dp,
+ )
+ )
+ }
+
+ @Test
+ fun `compact top-level content follows the measured navigation height`() {
+ val shorterNavigation = calculateContentBottomPadding(
+ mode = AppNavigationMode.BottomOverlay,
+ isTopLevelRoute = true,
+ safeBottomInset = 8.dp,
+ measuredNavigationHeight = 64.dp,
+ )
+ val tallerNavigation = calculateContentBottomPadding(
+ mode = AppNavigationMode.BottomOverlay,
+ isTopLevelRoute = true,
+ safeBottomInset = 8.dp,
+ measuredNavigationHeight = 88.dp,
+ )
+
+ assertEquals(96.dp, shorterNavigation)
+ assertEquals(120.dp, tallerNavigation)
+ }
+
+ @Test
+ fun `compact detail content keeps regular spacing above the safe bottom inset`() {
+ assertEquals(
+ 40.dp,
+ calculateContentBottomPadding(
+ mode = AppNavigationMode.BottomOverlay,
+ isTopLevelRoute = false,
+ safeBottomInset = 24.dp,
+ measuredNavigationHeight = 80.dp,
+ )
+ )
+ }
+
+ @Test
+ fun `rail content clears a visible floating utility`() {
+ assertEquals(
+ 24.dp,
+ calculateContentBottomPadding(
+ mode = AppNavigationMode.SideRail,
+ isTopLevelRoute = true,
+ safeBottomInset = 24.dp,
+ measuredNavigationHeight = 80.dp,
+ )
+ )
+ assertEquals(
+ 108.dp,
+ calculateContentBottomPadding(
+ mode = AppNavigationMode.SideRail,
+ isTopLevelRoute = true,
+ safeBottomInset = 24.dp,
+ measuredNavigationHeight = 80.dp,
+ floatingUtilityHeight = 56.dp,
+ )
+ )
+ }
+
+ @Test
+ fun `compact layers use both safe edges while rail content uses only the trailing edge`() {
+ val compact = calculateLayoutPadding(
+ mode = AppNavigationMode.BottomOverlay,
+ safeStartInset = 20.dp,
+ safeEndInset = 12.dp,
+ )
+ val rail = calculateLayoutPadding(
+ mode = AppNavigationMode.SideRail,
+ safeStartInset = 20.dp,
+ safeEndInset = 12.dp,
+ )
+
+ assertEquals(20.dp, compact.calculateStartPadding(LayoutDirection.Ltr))
+ assertEquals(12.dp, compact.calculateEndPadding(LayoutDirection.Ltr))
+ assertEquals(0.dp, rail.calculateStartPadding(LayoutDirection.Ltr))
+ assertEquals(12.dp, rail.calculateEndPadding(LayoutDirection.Ltr))
+ }
+
+ @Test
+ fun `compact remote action shares the navigation clearance`() {
+ assertEquals(
+ 120.dp,
+ calculateContentBottomPadding(
+ mode = AppNavigationMode.BottomOverlay,
+ isTopLevelRoute = true,
+ safeBottomInset = 24.dp,
+ measuredNavigationHeight = 72.dp,
+ )
+ )
+ }
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/navigation/FloatingNavigationGlassTokensTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/navigation/FloatingNavigationGlassTokensTest.kt
new file mode 100644
index 000000000..a7eeb657a
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/navigation/FloatingNavigationGlassTokensTest.kt
@@ -0,0 +1,97 @@
+package com.m3u.smartphone.ui.navigation
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.graphics.luminance
+import com.m3u.core.foundation.architecture.preferences.ThemePreset
+import com.m3u.core.foundation.architecture.preferences.ThemeStyle
+import com.m3u.smartphone.ui.material.ktx.createAppColorScheme
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class FloatingNavigationGlassTokensTest {
+ @Test
+ fun `light glass stays translucent while its boundary separates from app backgrounds`() {
+ listOf(
+ ThemePreset.DEFAULT_MATERIAL_SEED to ThemeStyle.MATERIAL,
+ ThemePreset.WARM_EDITORIAL_SEED to ThemeStyle.WARM_EDITORIAL,
+ ).forEach { (seed, style) ->
+ val scheme = createAppColorScheme(
+ argb = seed,
+ isDark = false,
+ themeStyle = style,
+ )
+ val tokens = resolveFloatingNavigationGlassTokens(
+ colorScheme = scheme,
+ useBackdropEffects = true,
+ )
+ val shell = tokens.surfaceColor.compositeOver(scheme.background)
+ val boundary = tokens.outlineColor.compositeOver(shell)
+
+ assertEquals(scheme.surfaceContainerLowest, tokens.surfaceColor.copy(alpha = 1f))
+ assertEquals(0.40f, tokens.surfaceColor.alpha)
+ assertEquals(0.75f, tokens.highlightAlpha)
+ assertEquals(0.10f, tokens.shadowColor.alpha, absoluteTolerance = 0.005f)
+ assertTrue(
+ actual = contrastRatio(boundary, scheme.background) >= 2f,
+ message = "$style floating navigation boundary is not distinct enough",
+ )
+ }
+ }
+
+ @Test
+ fun `dark glass keeps a restrained container tint and stronger shadow`() {
+ val scheme = createAppColorScheme(
+ argb = ThemePreset.WARM_EDITORIAL_SEED,
+ isDark = true,
+ themeStyle = ThemeStyle.WARM_EDITORIAL,
+ )
+ val tokens = resolveFloatingNavigationGlassTokens(
+ colorScheme = scheme,
+ useBackdropEffects = true,
+ )
+
+ assertEquals(scheme.surfaceContainer, tokens.surfaceColor.copy(alpha = 1f))
+ assertEquals(scheme.outlineVariant, tokens.outlineColor.copy(alpha = 1f))
+ assertEquals(0.40f, tokens.surfaceColor.alpha)
+ assertEquals(0.38f, tokens.highlightAlpha)
+ assertEquals(0.20f, tokens.shadowColor.alpha, absoluteTolerance = 0.005f)
+ assertEquals(Color.White, tokens.idleIndicatorColor.copy(alpha = 1f))
+ }
+
+ @Test
+ fun `fallback glass is opaque enough without changing the shared chrome roles`() {
+ val scheme = createAppColorScheme(
+ argb = ThemePreset.DEFAULT_MATERIAL_SEED,
+ isDark = false,
+ themeStyle = ThemeStyle.MATERIAL,
+ )
+ val backdropTokens = resolveFloatingNavigationGlassTokens(
+ colorScheme = scheme,
+ useBackdropEffects = true,
+ )
+ val fallbackTokens = resolveFloatingNavigationGlassTokens(
+ colorScheme = scheme,
+ useBackdropEffects = false,
+ )
+
+ assertEquals(
+ expected = 0.94f,
+ actual = fallbackTokens.surfaceColor.alpha,
+ absoluteTolerance = 0.005f,
+ )
+ assertEquals(backdropTokens.outlineColor, fallbackTokens.outlineColor)
+ assertEquals(backdropTokens.shadowColor, fallbackTokens.shadowColor)
+ assertEquals(backdropTokens.idleIndicatorColor, fallbackTokens.idleIndicatorColor)
+ }
+}
+
+private fun contrastRatio(first: Color, second: Color): Float {
+ val firstLuminance = first.luminance()
+ val secondLuminance = second.luminance()
+ return (max(firstLuminance, secondLuminance) + 0.05f) /
+ (min(firstLuminance, secondLuminance) + 0.05f)
+}
diff --git a/app/smartphone/src/test/java/com/m3u/smartphone/ui/navigation/FloatingNavigationMotionTest.kt b/app/smartphone/src/test/java/com/m3u/smartphone/ui/navigation/FloatingNavigationMotionTest.kt
new file mode 100644
index 000000000..97d3aaeec
--- /dev/null
+++ b/app/smartphone/src/test/java/com/m3u/smartphone/ui/navigation/FloatingNavigationMotionTest.kt
@@ -0,0 +1,195 @@
+package com.m3u.smartphone.ui.navigation
+
+import kotlinx.coroutines.Job
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class FloatingNavigationMotionTest {
+ @Test
+ fun `invalidating a settle cancels its job and retires its generation`() {
+ val controller = FloatingNavigationSettleController()
+ val firstJob = Job()
+ controller.job = firstJob
+
+ val firstGeneration = controller.invalidate()
+ assertFalse(firstJob.isActive)
+ assertTrue(controller.isCurrent(firstGeneration))
+
+ val secondJob = Job()
+ controller.job = secondJob
+ val secondGeneration = controller.invalidate()
+
+ assertFalse(secondJob.isActive)
+ assertFalse(controller.isCurrent(firstGeneration))
+ assertTrue(controller.isCurrent(secondGeneration))
+ }
+
+ @Test
+ fun `indicator physical position mirrors destination slots in rtl`() {
+ assertEquals(
+ 4f,
+ resolveFloatingNavigationIndicatorPhysicalX(
+ position = 0f,
+ itemWidthPx = 76f,
+ navigationWidthPx = 236f,
+ innerPaddingPx = 4f,
+ isRtl = false,
+ ),
+ )
+ assertEquals(
+ 156f,
+ resolveFloatingNavigationIndicatorPhysicalX(
+ position = 0f,
+ itemWidthPx = 76f,
+ navigationWidthPx = 236f,
+ innerPaddingPx = 4f,
+ isRtl = true,
+ ),
+ )
+ assertEquals(
+ 4f,
+ resolveFloatingNavigationIndicatorPhysicalX(
+ position = 2f,
+ itemWidthPx = 76f,
+ navigationWidthPx = 236f,
+ innerPaddingPx = 4f,
+ isRtl = true,
+ ),
+ )
+ }
+
+ @Test
+ fun `drag follows the pointer with resistance and bounded overscroll`() {
+ assertEquals(
+ 1.485f,
+ applyFloatingNavigationDrag(
+ desiredPosition = 1f,
+ deltaItems = 0.5f,
+ itemCount = 3,
+ ),
+ absoluteTolerance = 0.0001f,
+ )
+ assertEquals(
+ 2.285f,
+ applyFloatingNavigationDrag(
+ desiredPosition = 2.17f,
+ deltaItems = 0.5f,
+ itemCount = 3,
+ ),
+ absoluteTolerance = 0.0001f,
+ )
+ assertEquals(
+ 2.38f,
+ applyFloatingNavigationDrag(
+ desiredPosition = 2.4f,
+ deltaItems = 2f,
+ itemCount = 3,
+ ),
+ )
+ }
+
+ @Test
+ fun `release projects velocity but adds at most one slot from the nearest anchor`() {
+ assertEquals(
+ 2,
+ resolveFloatingNavigationReleaseTarget(
+ desiredPosition = 0.6f,
+ velocityPxPerSecond = 1000f,
+ itemWidthPx = 100f,
+ itemCount = 4,
+ ),
+ )
+ assertEquals(
+ 0,
+ resolveFloatingNavigationReleaseTarget(
+ desiredPosition = 0.4f,
+ velocityPxPerSecond = -1000f,
+ itemWidthPx = 100f,
+ itemCount = 4,
+ ),
+ )
+ }
+
+ @Test
+ fun `idle morph is the identity regardless of residual velocity`() {
+ assertEquals(
+ FloatingNavigationIndicatorMorph(
+ scaleX = 1f,
+ scaleY = 1f,
+ leadOffsetFraction = 0f,
+ refractionProgress = 0f,
+ pressureProgress = 0f,
+ ),
+ resolveFloatingNavigationIndicatorMorph(
+ interactionProgress = 0f,
+ velocityItemsPerSecond = 100f,
+ ),
+ )
+ }
+
+ @Test
+ fun `stationary press expands evenly and enables refraction`() {
+ val morph = resolveFloatingNavigationIndicatorMorph(
+ interactionProgress = 1f,
+ velocityItemsPerSecond = 0f,
+ )
+
+ assertTrue(morph.scaleX > 1f)
+ assertEquals(morph.scaleX, morph.scaleY, absoluteTolerance = 0.0001f)
+ assertEquals(0f, morph.leadOffsetFraction)
+ assertTrue(morph.refractionProgress in 0f..1f)
+ assertEquals(1f, morph.pressureProgress)
+ }
+
+ @Test
+ fun `mid gesture stretches with speed and reverses only the optical lead`() {
+ val forward = resolveFloatingNavigationIndicatorMorph(
+ interactionProgress = 0.5f,
+ velocityItemsPerSecond = 3.25f,
+ )
+ val reverse = resolveFloatingNavigationIndicatorMorph(
+ interactionProgress = 0.5f,
+ velocityItemsPerSecond = -3.25f,
+ )
+
+ assertTrue(forward.scaleX > forward.scaleY)
+ assertEquals(forward.scaleX, reverse.scaleX, absoluteTolerance = 0.0001f)
+ assertEquals(forward.scaleY, reverse.scaleY, absoluteTolerance = 0.0001f)
+ assertEquals(
+ forward.refractionProgress,
+ reverse.refractionProgress,
+ absoluteTolerance = 0.0001f,
+ )
+ assertEquals(
+ forward.leadOffsetFraction,
+ -reverse.leadOffsetFraction,
+ absoluteTolerance = 0.0001f,
+ )
+ assertEquals(0.5f, forward.pressureProgress, absoluteTolerance = 0.0001f)
+ }
+
+ @Test
+ fun `out of range and non finite motion inputs remain bounded and finite`() {
+ val overflow = resolveFloatingNavigationIndicatorMorph(
+ interactionProgress = Float.POSITIVE_INFINITY,
+ velocityItemsPerSecond = Float.NEGATIVE_INFINITY,
+ )
+ val invalid = resolveFloatingNavigationIndicatorMorph(
+ interactionProgress = Float.NaN,
+ velocityItemsPerSecond = Float.NaN,
+ )
+
+ assertTrue(overflow.scaleX.isFinite())
+ assertTrue(overflow.scaleY.isFinite())
+ assertTrue(overflow.scaleX in 1f..2f)
+ assertTrue(overflow.scaleY in 1f..2f)
+ assertTrue(overflow.leadOffsetFraction in -0.1f..0.1f)
+ assertTrue(overflow.refractionProgress in 0f..1f)
+ assertEquals(1f, overflow.pressureProgress)
+ assertEquals(1f, invalid.scaleX)
+ assertEquals(1f, invalid.scaleY)
+ assertEquals(0f, invalid.pressureProgress)
+ }
+}
diff --git a/app/smartphone/src/testDebug/java/com/m3u/smartphone/startup/DebugDefaultLibraryManifestTest.kt b/app/smartphone/src/testDebug/java/com/m3u/smartphone/startup/DebugDefaultLibraryManifestTest.kt
new file mode 100644
index 000000000..92f26fdf1
--- /dev/null
+++ b/app/smartphone/src/testDebug/java/com/m3u/smartphone/startup/DebugDefaultLibraryManifestTest.kt
@@ -0,0 +1,145 @@
+package com.m3u.smartphone.startup
+
+import java.io.File
+import java.security.MessageDigest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class DebugDefaultLibraryManifestTest {
+ @Test
+ fun `committed debug assets satisfy their manifest`() {
+ val manifestFile = debugAsset("manifest.json")
+ val manifest = DebugDefaultLibraryManifestParser.parse(
+ manifestFile.readText()
+ )
+ val playlistBytes = debugAsset("playlist.m3u").readBytes()
+ val actualSha256 = MessageDigest.getInstance("SHA-256")
+ .digest(playlistBytes)
+ .joinToString(separator = "") { byte ->
+ "%02x".format(byte.toInt() and 0xff)
+ }
+
+ assertEquals(manifest.playlistSha256, actualSha256)
+ DebugDefaultLibraryManifestParser.validatePlaylist(
+ rawPlaylist = playlistBytes.decodeToString(),
+ manifest = manifest,
+ )
+ }
+
+ @Test
+ fun `valid manifest and public playlist are accepted`() {
+ val manifest = DebugDefaultLibraryManifestParser.parse(validManifest())
+
+ DebugDefaultLibraryManifestParser.validatePlaylist(
+ rawPlaylist = """
+ #EXTM3U
+ #EXTINF:-1 tvg-id="sample.one" group-title="Samples",Sample one
+ https://media.example.test/one.m3u8
+ #EXTINF:-1 tvg-id="sample.two" group-title="Samples",Sample two
+ https://cdn.example.test/two.mp4
+ """.trimIndent(),
+ manifest = manifest,
+ )
+
+ assertEquals(
+ setOf("sample.one", "sample.two"),
+ manifest.expectedChannelIds,
+ )
+ }
+
+ @Test
+ fun `playlist asset cannot escape its debug directory`() {
+ assertFailsWith {
+ DebugDefaultLibraryManifestParser.parse(
+ validManifest(
+ playlistAsset = "default-library/../private.m3u",
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `credential-bearing media URL is rejected`() {
+ val manifest = DebugDefaultLibraryManifestParser.parse(validManifest())
+
+ assertFailsWith {
+ DebugDefaultLibraryManifestParser.validatePlaylist(
+ rawPlaylist = """
+ #EXTM3U
+ #EXTINF:-1 tvg-id="sample.one",Sample one
+ https://user:password@media.example.test/one.m3u8
+ #EXTINF:-1 tvg-id="sample.two",Sample two
+ https://cdn.example.test/two.mp4
+ """.trimIndent(),
+ manifest = manifest,
+ )
+ }
+ }
+
+ @Test
+ fun `signed query URL is rejected`() {
+ val manifest = DebugDefaultLibraryManifestParser.parse(validManifest())
+
+ assertFailsWith {
+ DebugDefaultLibraryManifestParser.validatePlaylist(
+ rawPlaylist = """
+ #EXTM3U
+ #EXTINF:-1 tvg-id="sample.one",Sample one
+ https://media.example.test/one.m3u8?token=secret
+ #EXTINF:-1 tvg-id="sample.two",Sample two
+ https://cdn.example.test/two.mp4
+ """.trimIndent(),
+ manifest = manifest,
+ )
+ }
+ }
+
+ @Test
+ fun `playlist directives cannot carry hidden credentials`() {
+ val manifest = DebugDefaultLibraryManifestParser.parse(validManifest())
+
+ assertFailsWith {
+ DebugDefaultLibraryManifestParser.validatePlaylist(
+ rawPlaylist = """
+ #EXTM3U
+ #EXTINF:-1 tvg-id="sample.one",Sample one
+ #EXTVLCOPT:http-referrer=https://private.example.test
+ https://media.example.test/one.m3u8
+ #EXTINF:-1 tvg-id="sample.two",Sample two
+ https://cdn.example.test/two.mp4
+ """.trimIndent(),
+ manifest = manifest,
+ )
+ }
+ }
+
+ private fun debugAsset(name: String): File {
+ val candidates = listOf(
+ File("src/debug/assets/default-library/$name"),
+ File("app/smartphone/src/debug/assets/default-library/$name"),
+ )
+ return candidates.firstOrNull(File::isFile)
+ ?: error(
+ "Debug default library asset not found from " +
+ File(".").absoluteFile.normalize().path
+ )
+ }
+
+ private fun validManifest(
+ playlistAsset: String = "default-library/playlist.m3u",
+ ): String = """
+ {
+ "schemaVersion": 1,
+ "revision": "test.1",
+ "title": "Test streams",
+ "playlistAsset": "$playlistAsset",
+ "playlistSha256": "${"a".repeat(64)}",
+ "expectedChannelIds": ["sample.one", "sample.two"],
+ "allowedMediaHosts": [
+ "media.example.test",
+ "cdn.example.test"
+ ]
+ }
+ """.trimIndent()
+}
diff --git a/app/tv/build.gradle.kts b/app/tv/build.gradle.kts
index cb3318eaa..9ce24e4bc 100644
--- a/app/tv/build.gradle.kts
+++ b/app/tv/build.gradle.kts
@@ -1,6 +1,5 @@
plugins {
alias(libs.plugins.com.android.application)
- alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.com.google.dagger.hilt.android)
alias(libs.plugins.com.google.devtools.ksp)
@@ -12,7 +11,7 @@ val m3uMockServerUrl = providers.gradleProperty("m3uMockServerUrl").orElse("http
android {
namespace = "com.m3u.tv"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
applicationId = "com.m3u.tv"
minSdk = 26
@@ -24,6 +23,9 @@ android {
testInstrumentationRunnerArguments["m3uMockServerUrl"] = m3uMockServerUrl.get()
}
buildTypes {
+ debug {
+ isPseudoLocalesEnabled = true
+ }
release {
isMinifyEnabled = true
isShrinkResources = true
@@ -37,7 +39,6 @@ android {
)
}
}
- aaptOptions.cruncherEnabled = false
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -49,20 +50,15 @@ android {
packaging {
resources.excludes += "META-INF/**"
}
- applicationVariants.all {
- outputs
- .map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
- .forEach { output ->
- output.outputFileName = "tv-${versionName}.apk"
- }
- }
}
tasks.matching { task ->
task.name.startsWith("connected") && task.name.endsWith("AndroidTest")
}.configureEach {
dependsOn(":testing:mock-server:startMockServer")
+ dependsOn(":testing:extension-reference:installDebug")
finalizedBy(":testing:mock-server:stopMockServer")
+ finalizedBy(":testing:extension-reference:uninstallDebug")
}
hilt {
@@ -78,6 +74,7 @@ baselineProfile {
dependencies {
implementation(project(":core:foundation"))
implementation(project(":data"))
+ implementation(project(":extension:api"))
// business
implementation(project(":business:foryou"))
implementation(project(":business:favorite"))
@@ -128,12 +125,15 @@ dependencies {
implementation(libs.androidx.media3.ui.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.airbnb.lottie.compose)
+ implementation(libs.kotlinx.serialization.json)
implementation(libs.minabox)
implementation(libs.net.mm2d.mmupnp.mmupnp)
implementation(libs.haze)
implementation(libs.haze.materials)
+ testImplementation(kotlin("test-junit"))
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.runner)
+ androidTestImplementation(libs.androidx.test.uiautomator.uiautomator)
}
diff --git a/app/tv/src/androidTest/java/com/m3u/testing/TvExternalExtensionAuthorizationTest.kt b/app/tv/src/androidTest/java/com/m3u/testing/TvExternalExtensionAuthorizationTest.kt
new file mode 100644
index 000000000..1a57b4041
--- /dev/null
+++ b/app/tv/src/androidTest/java/com/m3u/testing/TvExternalExtensionAuthorizationTest.kt
@@ -0,0 +1,294 @@
+package com.m3u.testing
+
+import android.os.SystemClock
+import android.view.View
+import android.view.accessibility.AccessibilityNodeInfo
+import androidx.datastore.preferences.core.edit
+import androidx.test.core.app.ActivityScenario
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
+import com.m3u.core.foundation.architecture.preferences.settings
+import com.m3u.i18n.R.string
+import com.m3u.tv.MainActivity
+import java.util.regex.Pattern
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class TvExternalExtensionAuthorizationTest {
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val context = instrumentation.targetContext
+ private val device = UiDevice.getInstance(instrumentation)
+
+ @Before
+ fun enableExternalExtensions() {
+ runBlocking {
+ context.settings.edit { preferences ->
+ preferences[PreferencesKeys.EXTERNAL_EXTENSIONS] = true
+ }
+ }
+ }
+
+ @After
+ fun restoreExternalExtensionPreference() {
+ runBlocking {
+ context.settings.edit { preferences ->
+ preferences[PreferencesKeys.EXTERNAL_EXTENSIONS] = false
+ }
+ }
+ }
+
+ @Test
+ fun authorizationFocusesCancelBeforeTheApprovalAfterPermissionDetails() {
+ ActivityScenario.launch(MainActivity::class.java).use {
+ val home = context.getString(string.tv_home_title)
+ val settings = context.getString(string.tv_settings_title)
+ val externalExtensions =
+ context.getString(string.feat_setting_external_extensions)
+ val enable = context.getString(string.feat_setting_extension_enable)
+ val cancel = context.getString(android.R.string.cancel)
+ val confirmationTitle =
+ context.getString(string.feat_setting_extension_confirm_title)
+ val capabilities =
+ context.getString(string.feat_setting_extension_requested_capabilities)
+ val origins = context.getString(string.feat_setting_extension_network_origins)
+
+ focusSettingsDestination(home = home, settings = settings)
+ assertTrue(device.pressDPadCenter())
+ assertTrue(pressTowardsContent(home))
+ assertTrue(
+ "DPad did not reach the external extension developer mode switch",
+ moveFocusDownToAction(externalExtensions),
+ )
+ assertTrue(
+ "DPad did not reach the reference extension enable action",
+ moveFocusDownToAction(enable),
+ )
+ assertTrue(
+ "Reference extension was not discovered",
+ waitForLabel(REFERENCE_EXTENSION_NAME),
+ )
+ assertTrue(
+ "Reference extension enable action lost focus: ${accessibilitySnapshot()}",
+ waitForFocusedAction(enable),
+ )
+
+ assertTrue(device.pressDPadCenter())
+ device.waitForIdle()
+ assertTrue(
+ "Authorization confirmation did not open: ${accessibilitySnapshot()}",
+ waitForLabel(confirmationTitle),
+ )
+ assertTrue(
+ "Cancel must receive initial authorization focus",
+ waitForFocusedAction(cancel),
+ )
+ assertFalse(
+ "Approval must not receive initial authorization focus",
+ isActionFocused(enable),
+ )
+ assertTrue(
+ "DPad could not reach requested capabilities before approval",
+ moveFocusDownToLabel(capabilities),
+ )
+ assertFalse(
+ "Approval became focused before requested capabilities were reviewed",
+ isActionFocused(enable),
+ )
+ assertTrue(
+ "DPad could not reach requested network origins before approval",
+ moveFocusDownToLabel(origins),
+ )
+ assertFalse(
+ "Approval became focused before requested network origins were reviewed",
+ isActionFocused(enable),
+ )
+ assertTrue(
+ "DPad could not reach approval after the permission details",
+ moveFocusDownToAction(enable),
+ )
+
+ assertTrue(device.pressBack())
+ assertTrue(
+ "Back did not return to the reference extension",
+ waitForLabel(REFERENCE_EXTENSION_NAME),
+ )
+ }
+ }
+
+ private fun focusSettingsDestination(
+ home: String,
+ settings: String,
+ ) {
+ val destinations = listOf(
+ home,
+ context.getString(string.tv_library_title),
+ context.getString(string.tv_favorites_title),
+ settings,
+ )
+ assertTrue(device.wait(Until.hasObject(By.desc(exact(home))), UI_TIMEOUT_MILLIS))
+ var railHasFocus = destinations.any(::isDescriptionFocused)
+ repeat(MAX_DPAD_STEPS) {
+ if (!railHasFocus) {
+ assertTrue(pressTowardsNavigationRail(home))
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ railHasFocus = destinations.any(::isDescriptionFocused)
+ }
+ }
+ assertTrue(
+ "DPad could not return to the navigation rail",
+ railHasFocus,
+ )
+ repeat(destinations.size) {
+ assertTrue(device.pressDPadUp())
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ }
+ assertTrue(
+ "DPad did not reach $home: ${accessibilitySnapshot()}",
+ waitForFocusedLabel(home),
+ )
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ destinations.drop(1).forEach { destination ->
+ assertTrue(device.pressDPadDown())
+ assertTrue(
+ "DPad did not reach $destination: ${accessibilitySnapshot()}",
+ waitForFocusedLabel(destination),
+ )
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ }
+ }
+
+ private fun moveFocusDownToAction(label: String): Boolean {
+ repeat(MAX_DPAD_STEPS) {
+ if (waitForFocusedAction(label, ACTION_POLL_MILLIS)) return true
+ if (!device.pressDPadDown()) return false
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ }
+ return waitForFocusedAction(label, ACTION_POLL_MILLIS)
+ }
+
+ private fun moveFocusDownToLabel(label: String): Boolean {
+ repeat(MAX_DPAD_STEPS) {
+ if (waitForFocusedLabel(label, ACTION_POLL_MILLIS)) return true
+ if (!device.pressDPadDown()) return false
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ }
+ return waitForFocusedLabel(label, ACTION_POLL_MILLIS)
+ }
+
+ private fun waitForFocusedAction(
+ label: String,
+ timeoutMillis: Long = UI_TIMEOUT_MILLIS,
+ ): Boolean = waitForAccessibilityNode(timeoutMillis) { node ->
+ node.isFocused && node.isClickable && node.hasOwnLabel(label)
+ } != null
+
+ private fun isActionFocused(label: String): Boolean =
+ waitForFocusedAction(label, ACTION_POLL_MILLIS)
+
+ private fun waitForFocusedLabel(
+ label: String,
+ timeoutMillis: Long = UI_TIMEOUT_MILLIS,
+ ): Boolean =
+ waitForAccessibilityNode(timeoutMillis) { node ->
+ node.isFocused && node.hasOwnLabel(label)
+ } != null
+
+ private fun waitForLabel(label: String): Boolean =
+ waitForAccessibilityNode { node -> node.hasOwnLabel(label) } != null
+
+ private fun isDescriptionFocused(description: String): Boolean =
+ device.hasObject(By.desc(exact(description)).focused(true))
+
+ private fun pressTowardsNavigationRail(home: String): Boolean =
+ if (navigationRailIsOnLeft(home)) {
+ device.pressDPadLeft()
+ } else {
+ device.pressDPadRight()
+ }
+
+ private fun pressTowardsContent(home: String): Boolean =
+ if (navigationRailIsOnLeft(home)) {
+ device.pressDPadRight()
+ } else {
+ device.pressDPadLeft()
+ }
+
+ private fun navigationRailIsOnLeft(home: String): Boolean {
+ val destination = device.findObject(By.desc(exact(home)))
+ ?: error("TV navigation rail is not visible")
+ val isRtl = context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
+ val railIsOnLeft = destination.visibleBounds.centerX() < device.displayWidth / 2
+ assertTrue("TV navigation rail does not match layout direction", railIsOnLeft != isRtl)
+ return railIsOnLeft
+ }
+
+ private fun waitForAccessibilityNode(
+ timeoutMillis: Long = UI_TIMEOUT_MILLIS,
+ predicate: (AccessibilityNodeInfo) -> Boolean,
+ ): AccessibilityNodeInfo? {
+ val deadline = SystemClock.uptimeMillis() + timeoutMillis
+ while (SystemClock.uptimeMillis() < deadline) {
+ instrumentation.uiAutomation.rootInActiveWindow
+ ?.firstNode(predicate)
+ ?.let { return it }
+ SystemClock.sleep(ACCESSIBILITY_POLL_MILLIS)
+ }
+ return null
+ }
+
+ private fun AccessibilityNodeInfo.firstNode(
+ predicate: (AccessibilityNodeInfo) -> Boolean,
+ ): AccessibilityNodeInfo? {
+ if (predicate(this)) return this
+ repeat(childCount) { index ->
+ getChild(index)?.firstNode(predicate)?.let { return it }
+ }
+ return null
+ }
+
+ private fun accessibilitySnapshot(): String = buildList {
+ fun collect(node: AccessibilityNodeInfo) {
+ val label = node.contentDescription?.toString()
+ ?: node.text?.toString()
+ if (!label.isNullOrBlank()) {
+ add(
+ "${if (node.isFocused) "focused " else ""}" +
+ "${if (node.isClickable) "clickable " else ""}" +
+ label,
+ )
+ }
+ repeat(node.childCount) { index ->
+ node.getChild(index)?.let(::collect)
+ }
+ }
+ instrumentation.uiAutomation.rootInActiveWindow?.let(::collect)
+ }.take(MAX_SNAPSHOT_LABELS).joinToString(separator = " | ")
+
+ private fun AccessibilityNodeInfo.hasOwnLabel(expected: String): Boolean =
+ listOfNotNull(contentDescription?.toString(), text?.toString()).any { label ->
+ label.equals(expected, ignoreCase = true) ||
+ label.startsWith("$expected. ", ignoreCase = true)
+ }
+
+ private fun exact(value: String): Pattern = Pattern.compile(
+ Pattern.quote(value),
+ Pattern.CASE_INSENSITIVE,
+ )
+
+ private companion object {
+ const val REFERENCE_EXTENSION_NAME = "Reference Provider"
+ const val MAX_DPAD_STEPS = 48
+ const val UI_TIMEOUT_MILLIS = 15_000L
+ const val ACTION_POLL_MILLIS = 300L
+ const val ACCESSIBILITY_POLL_MILLIS = 100L
+ const val DPAD_SETTLE_MILLIS = 150L
+ const val MAX_SNAPSHOT_LABELS = 80
+ }
+}
diff --git a/app/tv/src/androidTest/java/com/m3u/testing/TvProviderAccessibilityTest.kt b/app/tv/src/androidTest/java/com/m3u/testing/TvProviderAccessibilityTest.kt
new file mode 100644
index 000000000..93ee3f9e8
--- /dev/null
+++ b/app/tv/src/androidTest/java/com/m3u/testing/TvProviderAccessibilityTest.kt
@@ -0,0 +1,284 @@
+package com.m3u.testing
+
+import android.os.Build
+import android.os.SystemClock
+import android.view.View
+import android.view.accessibility.AccessibilityNodeInfo
+import androidx.test.core.app.ActivityScenario
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import com.m3u.i18n.R.string
+import com.m3u.tv.MainActivity
+import java.util.regex.Pattern
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TvProviderAccessibilityTest {
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val context = instrumentation.targetContext
+ private val device = UiDevice.getInstance(instrumentation)
+
+ @Test
+ fun dpadBackFromBuiltInProviderFormRestoresTheProviderEntry() {
+ ActivityScenario.launch(MainActivity::class.java).use {
+ val homeDescription = context.getString(string.tv_home_title)
+ val statusDescription = context.getString(string.tv_settings_title)
+ val providerLabel = context.getString(string.feat_setting_data_source_emby)
+ val externalExtensionsLabel =
+ context.getString(string.feat_setting_external_extensions)
+
+ assertNavigationRailMatchesLayoutDirection(homeDescription)
+ focusNavigationDestination(
+ homeDescription = homeDescription,
+ statusDescription = statusDescription,
+ )
+ assertTrue(device.pressDPadCenter())
+ assertTrue(
+ "The built-in provider did not expose an accessibility label",
+ waitForAccessibilityLabel(providerLabel),
+ )
+ assertTrue(pressTowardsContent(homeDescription))
+ assertTrue(
+ "DPad navigation did not reach the $providerLabel action",
+ moveFocusDownToAccessibilityAction(providerLabel),
+ )
+ assertFocusedActionContract(providerLabel)
+
+ assertTrue(device.pressDPadCenter())
+ assertTrue(
+ device.wait(
+ Until.hasObject(By.clazz(EDIT_TEXT_CLASS).focused(true)),
+ UI_TIMEOUT_MILLIS,
+ )
+ )
+ assertTrue(
+ "The provider kind group did not expose a selected radio button",
+ waitForAccessibilityNode { node ->
+ node.className?.toString() == RADIO_BUTTON_CLASS &&
+ node.isCheckable &&
+ node.isAccessibilityChecked()
+ } != null,
+ )
+
+ closeProviderForm()
+
+ assertFocusedActionContract(providerLabel)
+ assertFalse(device.hasObject(By.desc(exact(homeDescription)).focused(true)))
+ assertFalse(device.hasObject(By.desc(exact(statusDescription)).focused(true)))
+
+ assertTrue(
+ "DPad navigation did not reach the external extensions switch",
+ moveFocusDownToAccessibilityAction(externalExtensionsLabel),
+ )
+ val extensionsSwitch =
+ waitForFocusedAccessibilityAction(externalExtensionsLabel)
+ ?: error("The external extensions switch lost focus")
+ assertTrue(
+ "The external extensions control does not expose switch state",
+ extensionsSwitch.isCheckable,
+ )
+ }
+ }
+
+ private fun focusNavigationDestination(
+ homeDescription: String,
+ statusDescription: String,
+ ) {
+ val railDescriptions = listOf(
+ context.getString(string.tv_home_title),
+ context.getString(string.tv_library_title),
+ context.getString(string.tv_favorites_title),
+ statusDescription,
+ )
+ assertTrue(
+ device.wait(
+ Until.hasObject(By.desc(exact(homeDescription))),
+ UI_TIMEOUT_MILLIS,
+ )
+ )
+ var railHasFocus = railDescriptions.any(::isDescriptionFocused)
+ repeat(MAX_DPAD_STEPS) {
+ if (!railHasFocus) {
+ assertTrue(pressTowardsNavigationRail(homeDescription))
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ railHasFocus = railDescriptions.any(::isDescriptionFocused)
+ }
+ }
+ assertTrue(
+ "DPad navigation could not return to the navigation rail",
+ railHasFocus,
+ )
+ repeat(railDescriptions.size) {
+ assertTrue(device.pressDPadUp())
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ }
+ assertTrue(waitForFocusedLabel(homeDescription))
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ railDescriptions.drop(1).forEach { expectedDescription ->
+ assertTrue(device.pressDPadDown())
+ assertTrue(
+ "DPad navigation did not reach $expectedDescription",
+ waitForFocusedLabel(expectedDescription),
+ )
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ }
+ assertTrue(waitForFocusedLabel(statusDescription))
+ }
+
+ private fun isDescriptionFocused(description: String): Boolean =
+ device.hasObject(By.desc(exact(description)).focused(true))
+
+ private fun pressTowardsNavigationRail(homeDescription: String): Boolean =
+ if (navigationRailIsOnLeft(homeDescription)) {
+ device.pressDPadLeft()
+ } else {
+ device.pressDPadRight()
+ }
+
+ private fun pressTowardsContent(homeDescription: String): Boolean =
+ if (navigationRailIsOnLeft(homeDescription)) {
+ device.pressDPadRight()
+ } else {
+ device.pressDPadLeft()
+ }
+
+ private fun navigationRailIsOnLeft(homeDescription: String): Boolean {
+ val homeDestination = device.findObject(By.desc(exact(homeDescription)))
+ ?: error("The navigation rail is not visible")
+ return homeDestination.visibleBounds.centerX() < device.displayWidth / 2
+ }
+
+ private fun assertNavigationRailMatchesLayoutDirection(homeDescription: String) {
+ assertTrue(
+ device.wait(
+ Until.hasObject(By.desc(exact(homeDescription))),
+ UI_TIMEOUT_MILLIS,
+ )
+ )
+ val isRtl =
+ context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
+ assertEquals(
+ "The TV navigation rail must mirror with the app layout direction",
+ !isRtl,
+ navigationRailIsOnLeft(homeDescription),
+ )
+ }
+
+ /*
+ * An existing account can insert reauthentication actions before Emby.
+ * Walking focused actions keeps this test independent of that fixture state.
+ */
+ private fun moveFocusDownToAccessibilityAction(label: String): Boolean {
+ repeat(MAX_DPAD_STEPS) {
+ if (waitForFocusedAccessibilityAction(label, ACTION_POLL_MILLIS) != null) {
+ return true
+ }
+ if (!device.pressDPadDown()) return false
+ SystemClock.sleep(DPAD_SETTLE_MILLIS)
+ }
+ return waitForFocusedAccessibilityAction(label, ACTION_POLL_MILLIS) != null
+ }
+
+ private fun assertFocusedActionContract(label: String) {
+ val focusedAction = waitForFocusedAccessibilityAction(label)
+ ?: error("Focus did not land on the $label action")
+ assertTrue(
+ "$label is focused but is not clickable",
+ focusedAction.isClickable,
+ )
+ assertTrue(
+ "$label does not expose Accessibility ACTION_CLICK",
+ focusedAction.actionList.any { action ->
+ action.id == AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK.id
+ },
+ )
+ }
+
+ private fun waitForFocusedAccessibilityAction(
+ label: String,
+ timeoutMillis: Long = UI_TIMEOUT_MILLIS,
+ ): AccessibilityNodeInfo? = waitForAccessibilityNode(timeoutMillis) { node ->
+ node.isFocused &&
+ node.isClickable &&
+ node.hasOwnLabel(label)
+ }
+
+ private fun AccessibilityNodeInfo.isAccessibilityChecked(): Boolean =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
+ checked == AccessibilityNodeInfo.CHECKED_STATE_TRUE
+ } else {
+ @Suppress("DEPRECATION")
+ isChecked
+ }
+
+ private fun AccessibilityNodeInfo.hasOwnLabel(expected: String): Boolean =
+ contentDescription?.toString()?.equals(expected, ignoreCase = true) == true ||
+ text?.toString()?.equals(expected, ignoreCase = true) == true
+
+ private fun waitForAccessibilityNode(
+ timeoutMillis: Long = UI_TIMEOUT_MILLIS,
+ predicate: (AccessibilityNodeInfo) -> Boolean,
+ ): AccessibilityNodeInfo? {
+ val deadline = SystemClock.uptimeMillis() + timeoutMillis
+ while (SystemClock.uptimeMillis() < deadline) {
+ val match = instrumentation.uiAutomation.rootInActiveWindow
+ ?.firstNode(predicate)
+ if (match != null) return match
+ SystemClock.sleep(ACCESSIBILITY_POLL_MILLIS)
+ }
+ return null
+ }
+
+ private fun AccessibilityNodeInfo.firstNode(
+ predicate: (AccessibilityNodeInfo) -> Boolean,
+ ): AccessibilityNodeInfo? {
+ if (predicate(this)) return this
+ repeat(childCount) { index ->
+ val child = getChild(index) ?: return@repeat
+ child.firstNode(predicate)?.let { match -> return match }
+ }
+ return null
+ }
+
+ private fun closeProviderForm() {
+ val editText = By.clazz(EDIT_TEXT_CLASS)
+ repeat(MAX_BACK_PRESSES_TO_CLOSE_FORM) {
+ if (!device.hasObject(editText)) return
+ assertTrue(device.pressBack())
+ if (device.wait(Until.gone(editText), BACK_SETTLE_MILLIS)) return
+ }
+ assertFalse(
+ "Provider form remained open after Back",
+ device.hasObject(editText),
+ )
+ }
+
+ private fun waitForFocusedLabel(label: String): Boolean =
+ waitForAccessibilityNode { node ->
+ node.isFocused && node.hasOwnLabel(label)
+ } != null
+
+ private fun waitForAccessibilityLabel(label: String): Boolean =
+ waitForAccessibilityNode { node -> node.hasOwnLabel(label) } != null
+
+ private fun exact(value: String): Pattern = Pattern.compile(
+ Pattern.quote(value),
+ Pattern.CASE_INSENSITIVE,
+ )
+
+ private companion object {
+ const val MAX_BACK_PRESSES_TO_CLOSE_FORM = 2
+ const val MAX_DPAD_STEPS = 12
+ const val UI_TIMEOUT_MILLIS = 15_000L
+ const val ACTION_POLL_MILLIS = 300L
+ const val BACK_SETTLE_MILLIS = 1_000L
+ const val ACCESSIBILITY_POLL_MILLIS = 100L
+ const val DPAD_SETTLE_MILLIS = 150L
+ const val EDIT_TEXT_CLASS = "android.widget.EditText"
+ const val RADIO_BUTTON_CLASS = "android.widget.RadioButton"
+ }
+}
diff --git a/app/tv/src/main/AndroidManifest.xml b/app/tv/src/main/AndroidManifest.xml
index e42ef5dc9..06828d731 100644
--- a/app/tv/src/main/AndroidManifest.xml
+++ b/app/tv/src/main/AndroidManifest.xml
@@ -2,6 +2,17 @@
+
+
+
+
+
+
+
+
+
@@ -23,8 +34,11 @@
android:name=".M3UApplication"
android:allowBackup="true"
android:banner="@mipmap/ic_launcher"
+ android:dataExtractionRules="@xml/data_extraction_rules"
+ android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:localeConfig="@xml/locales_config"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.M3U">
@@ -54,4 +68,4 @@
-
\ No newline at end of file
+
diff --git a/app/tv/src/main/java/com/m3u/tv/App.kt b/app/tv/src/main/java/com/m3u/tv/App.kt
index a158f2d3b..ce4798709 100644
--- a/app/tv/src/main/java/com/m3u/tv/App.kt
+++ b/app/tv/src/main/java/com/m3u/tv/App.kt
@@ -1,5 +1,6 @@
package com.m3u.tv
+import android.content.Intent
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
@@ -19,10 +20,18 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -30,10 +39,10 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Text
import com.m3u.data.tv.model.keyCode
+import com.m3u.i18n.R.string
@Composable
fun App(
- onBackPressed: () -> Unit,
viewModel: TvHomeViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -43,6 +52,10 @@ fun App(
val playbackState by viewModel.playbackState.collectAsStateWithLifecycle()
val remoteControlCode by viewModel.remoteControlCode.collectAsStateWithLifecycle()
val view = LocalView.current
+ val localeTag = LocalConfiguration.current.locales[0].toLanguageTag()
+ val context = LocalContext.current
+ val diagnosticsShareTitle = stringResource(string.feat_setting_extension_diagnostics_share_title)
+ val currentDiagnosticsShareTitle by rememberUpdatedState(diagnosticsShareTitle)
var destination by remember { mutableStateOf(TvDestination.Home) }
var surface by remember { mutableStateOf(TvSurface.Browse) }
val closePlayer = {
@@ -50,11 +63,17 @@ fun App(
surface = TvSurface.Browse
}
- BackHandler {
- if (surface == TvSurface.Player) {
- closePlayer()
- } else {
- onBackPressed()
+ val backTarget = tvAppBackTarget(
+ playerVisible = surface == TvSurface.Player,
+ providerSubscriptionVisible = state.providerSubscriptionForm != null,
+ extensionSettingsVisible = state.extensionSettings != null,
+ )
+ BackHandler(enabled = backTarget != TvAppBackTarget.ACTIVITY) {
+ when (backTarget) {
+ TvAppBackTarget.PLAYER -> closePlayer()
+ TvAppBackTarget.PROVIDER_SUBSCRIPTION -> viewModel.closeProviderSubscription()
+ TvAppBackTarget.EXTENSION_SETTINGS -> viewModel.closeExtensionSettings()
+ TvAppBackTarget.ACTIVITY -> Unit
}
}
@@ -64,6 +83,22 @@ fun App(
view.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, direction.keyCode))
}
}
+ LaunchedEffect(viewModel, localeTag) {
+ viewModel.updateLocale(localeTag)
+ }
+ LaunchedEffect(viewModel, context) {
+ viewModel.extensionDiagnostics.collect { payload ->
+ context.startActivity(
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).apply {
+ type = "application/json"
+ putExtra(Intent.EXTRA_TEXT, payload)
+ },
+ currentDiagnosticsShareTitle,
+ )
+ )
+ }
+ }
Box(
modifier = Modifier
@@ -93,7 +128,35 @@ fun App(
onPlayRecent = {
viewModel.playRecent()
surface = TvSurface.Player
- }
+ },
+ onExternalExtensionsEnabled = viewModel::setExternalExtensionsEnabled,
+ onEnableExtension = viewModel::enableExtensionPlugin,
+ onReauthorizeExtension = viewModel::reauthorizeExtensionPlugin,
+ onDisableExtension = viewModel::disableExtensionPlugin,
+ onRevokeExtension = viewModel::revokeExtensionPlugin,
+ onClearExtensionData = viewModel::clearExtensionData,
+ onExportExtensionDiagnostics = viewModel::exportExtensionDiagnostics,
+ onOpenExtensionSettings = { extensionId ->
+ viewModel.openExtensionSettings(extensionId, localeTag)
+ },
+ onCloseExtensionSettings = viewModel::closeExtensionSettings,
+ onUpdateExtensionSetting = { sectionId, fieldKey, editToken, value ->
+ viewModel.updateExtensionSetting(
+ sectionId,
+ fieldKey,
+ editToken,
+ value,
+ localeTag,
+ )
+ },
+ onRefreshProviders = viewModel::refreshSubscriptionProviders,
+ onOpenProviderSubscription = viewModel::openProviderSubscription,
+ onReauthenticateProvider = viewModel::reauthenticateProviderAccount,
+ onCloseProviderSubscription = viewModel::closeProviderSubscription,
+ onUpdateProviderTitle = viewModel::updateProviderSubscriptionTitle,
+ onSelectProviderKind = viewModel::selectProviderKind,
+ onUpdateProviderSetting = viewModel::updateProviderSetting,
+ onSubmitProviderSubscription = viewModel::submitProviderSubscription,
)
}
@@ -114,8 +177,12 @@ fun App(
}
remoteControlCode?.let { code ->
+ val displayCode = code.toString().padStart(6, '0')
+ val spokenCode = displayCode.toCharArray().joinToString(separator = " ")
+ val pairingCodeDescription =
+ stringResource(string.ui_remote_control_pairing_code, spokenCode)
Text(
- text = code.toString().padStart(6, '0'),
+ text = displayCode,
color = TvColors.TextPrimary,
fontFamily = TvFonts.Body,
fontSize = 28.sp,
@@ -125,7 +192,11 @@ fun App(
.padding(24.dp)
.background(TvColors.Surface.copy(alpha = 0.86f), RoundedCornerShape(8.dp))
.padding(horizontal = 18.dp, vertical = 10.dp)
+ .clearAndSetSemantics {
+ contentDescription = pairingCodeDescription
+ liveRegion = LiveRegionMode.Polite
+ }
)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/M3UApplication.kt b/app/tv/src/main/java/com/m3u/tv/M3UApplication.kt
index 0d5080100..52cc19b7a 100644
--- a/app/tv/src/main/java/com/m3u/tv/M3UApplication.kt
+++ b/app/tv/src/main/java/com/m3u/tv/M3UApplication.kt
@@ -3,6 +3,12 @@ package com.m3u.tv
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
+import androidx.work.WorkManager
+import com.m3u.data.worker.ExtensionPluginBootstrapWorker
+import com.m3u.data.worker.PersistedUriPermissionCleanupWorker
+import com.m3u.data.worker.ProviderCredentialRecoveryWorker
+import com.m3u.data.worker.ProviderSessionCleanupWorker
+import com.m3u.data.worker.initializePersistedUriPermissionLeases
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@@ -11,6 +17,19 @@ class M3UApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
+ override fun onCreate() {
+ super.onCreate()
+ initializePersistedUriPermissionLeases(this)
+ PersistedUriPermissionCleanupWorker.enqueueRecovery(
+ WorkManager.getInstance(this)
+ )
+ ProviderCredentialRecoveryWorker.enqueue(WorkManager.getInstance(this))
+ ProviderSessionCleanupWorker.enqueue(
+ workManager = WorkManager.getInstance(this),
+ )
+ ExtensionPluginBootstrapWorker.enqueue(WorkManager.getInstance(this))
+ }
+
override val workManagerConfiguration: Configuration by lazy {
Configuration.Builder()
.setWorkerFactory(workerFactory)
diff --git a/app/tv/src/main/java/com/m3u/tv/MainActivity.kt b/app/tv/src/main/java/com/m3u/tv/MainActivity.kt
index f8ff37546..c268af20a 100644
--- a/app/tv/src/main/java/com/m3u/tv/MainActivity.kt
+++ b/app/tv/src/main/java/com/m3u/tv/MainActivity.kt
@@ -36,7 +36,7 @@ class MainActivity : ComponentActivity() {
)
) {
Box(Modifier.background(MaterialTheme.colorScheme.background)) {
- App(onBackPressed = onBackPressedDispatcher::onBackPressed)
+ App()
}
}
}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvBidiFormatter.kt b/app/tv/src/main/java/com/m3u/tv/TvBidiFormatter.kt
new file mode 100644
index 000000000..a746f5962
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/TvBidiFormatter.kt
@@ -0,0 +1,72 @@
+package com.m3u.tv
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.core.text.BidiFormatter
+import androidx.core.text.TextDirectionHeuristicsCompat
+import java.text.BreakIterator
+import java.util.Locale
+
+private const val TV_READABLE_SEGMENT_GRAPHEME_LIMIT = 128
+
+@Composable
+internal fun rememberTvBidiFormatter(): TvBidiFormatter {
+ val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+ return remember(isRtl) { TvBidiFormatter(isRtl) }
+}
+
+internal class TvBidiFormatter(isRtlContext: Boolean) {
+ private val formatter = BidiFormatter.getInstance(isRtlContext)
+
+ fun natural(value: String): String = formatter.unicodeWrap(value.withoutBidiControls())
+
+ fun ltr(value: String): String = formatter.unicodeWrap(
+ value.withoutBidiControls(),
+ TextDirectionHeuristicsCompat.LTR,
+ )
+}
+
+internal fun String.withoutBidiControls(): String = filterNot { character ->
+ character == '\u061C' ||
+ character == '\u200E' ||
+ character == '\u200F' ||
+ character.code in 0x202A..0x202E ||
+ character.code in 0x2066..0x2069
+}
+
+/**
+ * Splits extension-owned text before applying direction isolation.
+ *
+ * BidiFormatter adds paired control characters. Splitting an already wrapped
+ * value can leave one Text node with an opening control and another with its
+ * closing control, so the transform must always run on each completed segment.
+ */
+internal fun String.tvReadableSegments(
+ transform: (String) -> String = { it },
+): List {
+ if (isEmpty()) return listOf(transform(""))
+
+ val iterator = BreakIterator.getCharacterInstance(Locale.getDefault()).apply {
+ setText(this@tvReadableSegments)
+ }
+ val segments = mutableListOf()
+ var segmentStart = iterator.first()
+ var boundary = iterator.next()
+ var graphemeCount = 0
+
+ while (boundary != BreakIterator.DONE) {
+ graphemeCount++
+ if (graphemeCount >= TV_READABLE_SEGMENT_GRAPHEME_LIMIT) {
+ segments += transform(substring(segmentStart, boundary))
+ segmentStart = boundary
+ graphemeCount = 0
+ }
+ boundary = iterator.next()
+ }
+ if (segmentStart < length) {
+ segments += transform(substring(segmentStart))
+ }
+ return segments.ifEmpty { listOf(transform(this)) }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvComponents.kt b/app/tv/src/main/java/com/m3u/tv/TvComponents.kt
index b718fd4ee..d9d5418c8 100644
--- a/app/tv/src/main/java/com/m3u/tv/TvComponents.kt
+++ b/app/tv/src/main/java/com/m3u/tv/TvComponents.kt
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -48,22 +49,41 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.disabled
+import androidx.compose.ui.semantics.error
+import androidx.compose.ui.semantics.onClick as semanticsOnClick
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.toggleableState
+import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.zIndex
import coil.compose.AsyncImage
import com.m3u.core.foundation.util.basic.title
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.Playlist
import com.m3u.data.database.model.isSeries
import com.m3u.data.database.model.isVod
+import com.m3u.i18n.R.plurals
import com.m3u.i18n.R.string
+import java.util.Locale
@Composable
fun TvBackdrop(channel: Channel?) {
+ val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(Modifier.fillMaxSize()) {
AsyncImage(
model = channel?.cover,
@@ -81,9 +101,13 @@ fun TvBackdrop(channel: Channel?) {
.fillMaxSize()
.background(
Brush.horizontalGradient(
- 0f to TvColors.Background,
- 0.58f to TvColors.Background.copy(alpha = 0.92f),
- 1f to TvColors.Background.copy(alpha = 0.72f)
+ *tvLeadingGradientColorStops(
+ isRtl = isRtl,
+ leading = TvColors.Background,
+ middle = TvColors.Background.copy(alpha = 0.92f),
+ trailing = TvColors.Background.copy(alpha = 0.72f),
+ middlePosition = 0.58f,
+ ).toTypedArray()
)
)
)
@@ -123,12 +147,15 @@ private fun RailItem(
FocusFrame(
onClick = onClick,
selected = selected,
+ selectionState = selected,
+ semanticsLabel = destination.label(),
shape = RoundedCornerShape(16.dp),
+ semanticRole = Role.Tab,
modifier = Modifier.size(64.dp)
) { focused ->
Icon(
imageVector = destination.icon,
- contentDescription = destination.label(),
+ contentDescription = null,
tint = if (selected || focused) TvColors.OnFocus else TvColors.TextSecondary,
modifier = Modifier
.align(Alignment.Center)
@@ -150,12 +177,18 @@ fun FocusFrame(
onClick: () -> Unit,
modifier: Modifier = Modifier,
selected: Boolean = false,
+ selectionState: Boolean? = null,
enabled: Boolean = true,
+ focusableWhenDisabled: Boolean = false,
shape: RoundedCornerShape = RoundedCornerShape(8.dp),
focusRequester: FocusRequester? = null,
focusedScale: Float = 1.08f,
focusedBorderWidth: Dp = 4.dp,
focusedBorderColor: Color = Color.White,
+ semanticRole: Role? = Role.Button,
+ semanticsLabel: String? = null,
+ semanticsError: String? = null,
+ toggleState: ToggleableState? = null,
onFocus: () -> Unit = {},
onKey: (KeyEvent) -> Boolean = { false },
content: @Composable BoxScope.(focused: Boolean) -> Unit
@@ -167,6 +200,7 @@ fun FocusFrame(
)
Box(
modifier = modifier
+ .zIndex(if (focused) 1f else 0f)
.scale(scale)
.shadow(
elevation = if (focused && enabled) 18.dp else 0.dp,
@@ -177,7 +211,7 @@ fun FocusFrame(
.background(
when {
focused && enabled -> TvColors.Focus
- selected -> TvColors.Focus.copy(alpha = 0.72f)
+ selected && enabled -> TvColors.Focus.copy(alpha = 0.72f)
else -> TvColors.Surface.copy(alpha = 0.86f)
}
)
@@ -210,8 +244,56 @@ fun FocusFrame(
else -> false
}
}
- .clickable(onClick = onClick)
+ .clickable(
+ role = null,
+ onClick = onClick,
+ )
.focusable()
+ } else if (focusableWhenDisabled) {
+ Modifier.focusable()
+ } else {
+ Modifier
+ }
+ )
+ .then(
+ if (semanticRole != null && semanticsLabel != null) {
+ Modifier.clearAndSetSemantics {
+ contentDescription = semanticsLabel
+ selectionState?.let { isSelected ->
+ this.selected = isSelected
+ }
+ toggleState?.let { state ->
+ toggleableState = state
+ }
+ semanticsError?.let { message ->
+ error(message)
+ }
+ role = semanticRole
+ if (enabled) {
+ semanticsOnClick {
+ onClick()
+ true
+ }
+ } else {
+ disabled()
+ }
+ }
+ } else if (semanticRole != null) {
+ Modifier.semantics(mergeDescendants = true) {
+ selectionState?.let { isSelected ->
+ this.selected = isSelected
+ }
+ toggleState?.let { state ->
+ toggleableState = state
+ }
+ semanticsError?.let { message ->
+ error(message)
+ }
+ role = semanticRole
+ if (!enabled) {
+ disabled()
+ }
+ }
} else {
Modifier
}
@@ -233,11 +315,12 @@ fun TvIconActionButton(
onClick = onClick,
shape = RoundedCornerShape(28.dp),
focusRequester = focusRequester,
+ semanticsLabel = contentDescription,
modifier = modifier.size(56.dp)
) { focused ->
Icon(
imageVector = icon,
- contentDescription = contentDescription,
+ contentDescription = null,
tint = if (focused) TvColors.OnFocus else TvColors.TextPrimary,
modifier = Modifier
.align(Alignment.Center)
@@ -253,49 +336,89 @@ fun TvActionButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
+ focusableWhenDisabled: Boolean = false,
focusRequester: FocusRequester? = null,
- showTextWhenUnfocused: Boolean = true
+ showTextWhenUnfocused: Boolean = true,
+ selected: Boolean? = null,
+ checked: Boolean? = null,
+ semanticRole: Role = Role.Button,
+ semanticsLabel: String? = null,
+ semanticsError: String? = null,
+ supportingText: String? = null,
) {
FocusFrame(
onClick = onClick,
- selected = false,
+ selected = selected == true || checked == true,
+ selectionState = selected.takeIf { checked == null },
enabled = enabled,
+ focusableWhenDisabled = focusableWhenDisabled,
shape = RoundedCornerShape(24.dp),
focusRequester = focusRequester,
- modifier = modifier.height(48.dp)
+ focusedScale = 1.04f,
+ semanticRole = semanticRole,
+ semanticsLabel = semanticsLabel ?: listOfNotNull(text, supportingText)
+ .joinToString(separator = ". "),
+ semanticsError = semanticsError,
+ toggleState = checked?.let { isChecked ->
+ if (isChecked) ToggleableState.On else ToggleableState.Off
+ },
+ modifier = modifier.heightIn(min = 48.dp),
) { focused ->
val showText = focused || showTextWhenUnfocused
+ val active = focused || selected == true || checked == true
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.align(Alignment.Center)
- .padding(horizontal = if (showText) 16.dp else 12.dp)
+ .padding(
+ horizontal = if (showText) 16.dp else 12.dp,
+ vertical = 8.dp,
+ )
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = when {
!enabled -> TvColors.TextMuted
- focused -> TvColors.OnFocus
+ active -> TvColors.OnFocus
else -> TvColors.TextPrimary
},
modifier = Modifier.size(24.dp)
)
if (showText) {
- Text(
- text = text,
- color = when {
- !enabled -> TvColors.TextMuted
- focused -> TvColors.OnFocus
- else -> TvColors.TextPrimary
- },
- fontSize = 14.sp,
- fontWeight = FontWeight.SemiBold,
- fontFamily = TvFonts.Body,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ val primaryColor = when {
+ !enabled -> TvColors.TextMuted
+ active -> TvColors.OnFocus
+ else -> TvColors.TextPrimary
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = text,
+ color = primaryColor,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ fontFamily = TvFonts.Body,
+ maxLines = 4,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.clearAndSetSemantics {},
+ )
+ supportingText?.let { supportingLabel ->
+ Text(
+ text = supportingLabel,
+ color = when {
+ !enabled -> TvColors.TextMuted
+ active -> TvColors.OnFocus.copy(alpha = 0.78f)
+ else -> TvColors.TextSecondary
+ },
+ fontSize = 12.sp,
+ fontFamily = TvFonts.Body,
+ maxLines = 1,
+ overflow = TextOverflow.MiddleEllipsis,
+ modifier = Modifier.clearAndSetSemantics {},
+ )
+ }
+ }
}
}
}
@@ -317,7 +440,7 @@ fun SectionTitle(
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
fontFamily = TvFonts.Body,
- maxLines = 1,
+ maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
@@ -325,7 +448,7 @@ fun SectionTitle(
color = TvColors.TextSecondary,
fontSize = 14.sp,
fontFamily = TvFonts.Body,
- maxLines = 2,
+ maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
@@ -370,7 +493,7 @@ fun ChannelCard(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.fillMaxWidth()
- .height(32.dp)
+ .heightIn(min = 32.dp)
) {
Text(
text = channel.title.title(),
@@ -428,10 +551,12 @@ fun PlaylistCard(
modifier: Modifier = Modifier,
focusRequester: FocusRequester? = null
) {
+ val largeTextLayout = tvLargeTextLayout(LocalDensity.current.fontScale)
FocusFrame(
onClick = onClick,
selected = selected,
- modifier = modifier,
+ selectionState = selected,
+ modifier = modifier.heightIn(min = largeTextLayout.playlistCardMinHeightDp.dp),
focusRequester = focusRequester,
shape = RoundedCornerShape(12.dp)
) { focused ->
@@ -465,7 +590,7 @@ fun PlaylistCard(
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
fontFamily = TvFonts.Body,
- maxLines = 1,
+ maxLines = if (largeTextLayout.stackEmptyLibrary) 2 else 1,
overflow = TextOverflow.Ellipsis
)
Text(
@@ -473,7 +598,7 @@ fun PlaylistCard(
color = secondaryTextColor,
fontSize = 12.sp,
fontFamily = TvFonts.Body,
- maxLines = 1,
+ maxLines = if (largeTextLayout.stackEmptyLibrary) 2 else 1,
overflow = TextOverflow.Ellipsis
)
}
@@ -491,6 +616,7 @@ fun MetricTile(
FocusFrame(
onClick = {},
enabled = false,
+ semanticRole = null,
modifier = modifier,
shape = RoundedCornerShape(12.dp)
) {
@@ -563,17 +689,17 @@ fun InfoPill(
Box(
contentAlignment = Alignment.CenterStart,
modifier = modifier
- .height(minHeight)
+ .heightIn(min = minHeight)
.clip(RoundedCornerShape(20.dp))
.background(Color.White.copy(alpha = 0.08f))
- .padding(horizontal = 16.dp)
+ .padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = text,
color = TvColors.TextSecondary,
fontSize = 13.sp,
fontFamily = TvFonts.Body,
- maxLines = 1,
+ maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
@@ -588,7 +714,7 @@ fun playlistLabel(playlist: Playlist, count: Int): String {
val type = when {
playlist.isSeries -> stringResource(string.tv_playlist_type_series)
playlist.isVod -> stringResource(string.tv_playlist_type_vod)
- else -> playlist.source.value.uppercase()
+ else -> playlist.source.value.uppercase(Locale.ROOT)
}
- return stringResource(string.tv_playlist_label, type, count)
-}
\ No newline at end of file
+ return pluralStringResource(plurals.tv_playlist_label, count, type, count)
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvHomeViewModel.kt b/app/tv/src/main/java/com/m3u/tv/TvHomeViewModel.kt
index 3993e5758..07ca069b6 100644
--- a/app/tv/src/main/java/com/m3u/tv/TvHomeViewModel.kt
+++ b/app/tv/src/main/java/com/m3u/tv/TvHomeViewModel.kt
@@ -4,25 +4,55 @@ import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.Player
+import com.m3u.business.setting.ExtensionSettingsOperationQueue
+import com.m3u.business.setting.ProviderDiscoveryState
+import com.m3u.business.setting.ProviderSubscriptionForm
+import com.m3u.business.setting.ProviderSubscriptionFormBuildResult
+import com.m3u.business.setting.supports
+import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
+import com.m3u.core.foundation.architecture.preferences.Settings
+import com.m3u.core.foundation.architecture.preferences.set
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.DataSource
import com.m3u.data.database.model.Playlist
import com.m3u.data.repository.channel.ChannelRepository
+import com.m3u.data.repository.extension.ExtensionSettingEditToken
+import com.m3u.data.repository.extension.ExtensionSettingUpdateResult
+import com.m3u.data.repository.extension.ExtensionSettingsConfiguration
+import com.m3u.data.repository.extension.ExtensionSettingsRepository
import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.data.repository.plugin.ExtensionPluginRepository
+import com.m3u.data.repository.plugin.InstalledPlugin
+import com.m3u.data.repository.plugin.PluginAuthorizationToken
+import com.m3u.data.repository.plugin.PluginDataClearResult
+import com.m3u.data.repository.plugin.PluginEnableResult
+import com.m3u.data.repository.provider.DiscoveredSubscriptionProvider
+import com.m3u.data.repository.provider.ProviderAccountSummary
+import com.m3u.data.repository.provider.ProviderDiscoveryException
+import com.m3u.data.repository.provider.SubscriptionProviderRepository
import com.m3u.data.repository.tv.TvRepository
import com.m3u.data.service.DPadReactionService
import com.m3u.data.service.MediaCommand
import com.m3u.data.service.PlayerManager
+import com.m3u.extension.api.ExtensionId
+import com.m3u.extension.api.subscription.SubscriptionProviderDescriptor
import dagger.hilt.android.lifecycle.HiltViewModel
+import java.util.Locale
import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
@Immutable
data class TvUiState(
@@ -32,22 +62,46 @@ data class TvUiState(
val channels: List = emptyList(),
val favorites: List = emptyList(),
val recent: Channel? = null,
- val loadingChannels: Boolean = false
+ val loadingChannels: Boolean = false,
+ val externalExtensionsEnabled: Boolean = false,
+ val extensionPlugins: List = emptyList(),
+ val extensionSettings: ExtensionSettingsConfiguration? = null,
+ val extensionPluginOperationFailed: Boolean = false,
+ val providerDiscoveryState: ProviderDiscoveryState = ProviderDiscoveryState.Loading,
+ val providerAccounts: List = emptyList(),
+ val providerSubscriptionForm: ProviderSubscriptionForm? = null,
+ val providerSubscriptionDescriptor: SubscriptionProviderDescriptor? = null,
+ val providerSubscriptionUnavailable: Boolean = false,
+ val providerSubscriptionTitle: String = "",
+ val providerSubscriptionInProgress: Boolean = false,
+ val providerSubscriptionFeedback: TvProviderSubscriptionFeedback? = null,
) {
val channelCount: Int get() = counts.values.sum()
val heroChannel: Channel? get() = recent ?: channels.firstOrNull()
}
+sealed interface TvProviderSubscriptionFeedback {
+ data object InvalidSettings : TvProviderSubscriptionFeedback
+ data object Failed : TvProviderSubscriptionFeedback
+ data class Added(val channelCount: Int) : TvProviderSubscriptionFeedback
+}
+
@HiltViewModel
class TvHomeViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository,
private val channelRepository: ChannelRepository,
private val playerManager: PlayerManager,
+ private val extensionPluginRepository: ExtensionPluginRepository,
+ private val extensionSettingsRepository: ExtensionSettingsRepository,
+ private val subscriptionProviderRepository: SubscriptionProviderRepository,
+ private val settings: Settings,
tvRepository: TvRepository,
dPadReactionService: DPadReactionService
) : ViewModel() {
private val _state = MutableStateFlow(TvUiState())
val state: StateFlow = _state.asStateFlow()
+ private val _extensionDiagnostics = MutableSharedFlow(extraBufferCapacity = 1)
+ val extensionDiagnostics = _extensionDiagnostics.asSharedFlow()
val player: StateFlow = playerManager.player
val currentChannel: StateFlow = playerManager.channel
@@ -56,11 +110,70 @@ class TvHomeViewModel @Inject constructor(
val remoteControlCode: StateFlow = tvRepository.broadcastCodeOnTv
val remoteDirections = dPadReactionService.incoming
private var loadChannelsJob: Job? = null
+ private var providerDiscoveryJob: Job? = null
+ private var providerDiscoveryGeneration: Long = 0L
+ private var providerReauthenticationJob: Job? = null
+ private var providerSubscriptionJob: Job? = null
+ private var providerLocaleTag: String? = null
+ private var extensionSettingsLoadJob: Job? = null
+ private var extensionSettingsRequestedId: ExtensionId? = null
+ @Volatile
+ private var sortLocale: Locale = Locale.getDefault()
+
+ fun updateLocale(localeTag: String) {
+ val requestedLocaleTag = localeTag.trim().takeIf(String::isNotEmpty)
+ val providerLocaleChanged = providerLocaleTag != requestedLocaleTag
+ providerLocaleTag = requestedLocaleTag
+ val locale = requestedLocaleTag
+ ?.let(Locale::forLanguageTag)
+ ?: Locale.getDefault()
+ if (locale != sortLocale) {
+ sortLocale = locale
+ _state.update { state ->
+ state.copy(
+ playlists = state.playlists.sortedWith(
+ localeAwareComparator(
+ primarySelector = Playlist::title,
+ locale = locale,
+ )
+ ),
+ channels = state.channels.sortedWith(
+ localeAwareComparator(
+ primarySelector = Channel::category,
+ secondarySelector = Channel::title,
+ locale = locale,
+ )
+ ),
+ )
+ }
+ }
+ if (providerLocaleChanged) {
+ startSubscriptionProviderDiscovery(requestedLocaleTag)
+ extensionSettingsRequestedId?.value?.let { extensionId ->
+ openExtensionSettings(extensionId, requestedLocaleTag)
+ }
+ }
+ }
+ private var extensionSettingsGeneration = 0L
+ private var extensionSettingsUpdateGeneration = 0L
+ private val extensionSettingsOperationQueue = ExtensionSettingsOperationQueue(
+ scope = viewModelScope,
+ onFailure = {
+ _state.update {
+ it.copy(
+ extensionPluginOperationFailed = true,
+ )
+ }
+ },
+ )
init {
observePlaylists()
observeFavorites()
observeRecent()
+ observeExternalExtensions()
+ observeProviderAccounts()
+ refreshSubscriptionProviders()
}
fun selectPlaylist(playlist: Playlist) {
@@ -102,6 +215,533 @@ class TvHomeViewModel @Inject constructor(
playerManager.release()
}
+ fun setExternalExtensionsEnabled(enabled: Boolean) {
+ viewModelScope.launch { settings[PreferencesKeys.EXTERNAL_EXTENSIONS] = enabled }
+ }
+
+ fun enableExtensionPlugin(
+ packageName: String,
+ serviceName: String,
+ authorizationToken: PluginAuthorizationToken,
+ ) {
+ viewModelScope.launch {
+ updateExtensionPluginResult(
+ extensionPluginRepository.enable(
+ packageName,
+ serviceName,
+ authorizationToken,
+ )
+ )
+ refreshExtensionPlugins()
+ }
+ }
+
+ fun reauthorizeExtensionPlugin(
+ packageName: String,
+ serviceName: String,
+ authorizationToken: PluginAuthorizationToken,
+ ) {
+ viewModelScope.launch {
+ updateExtensionPluginResult(
+ extensionPluginRepository.reauthorize(
+ packageName,
+ serviceName,
+ authorizationToken,
+ )
+ )
+ refreshExtensionPlugins()
+ }
+ }
+
+ fun disableExtensionPlugin(extensionId: String) {
+ closeExtensionSettingsIfActive(extensionId)
+ viewModelScope.launch {
+ extensionPluginRepository.disable(extensionId)
+ refreshExtensionPlugins()
+ }
+ }
+
+ fun revokeExtensionPlugin(
+ packageName: String,
+ serviceName: String,
+ extensionId: String?,
+ ) {
+ if (extensionId == null) {
+ reportExtensionOperationFailure()
+ return
+ }
+ closeExtensionSettingsIfActive(extensionId)
+ extensionSettingsOperationQueue.launchDestructive(extensionId) {
+ extensionPluginRepository.revoke(packageName, serviceName)
+ _state.update {
+ it.copy(
+ extensionPluginOperationFailed = false,
+ )
+ }
+ refreshExtensionPlugins()
+ }
+ }
+
+ fun openExtensionSettings(extensionId: String, localeTag: String?) {
+ val requestedExtensionId = ExtensionId(extensionId)
+ val generation = ++extensionSettingsGeneration
+ extensionSettingsLoadJob?.cancel()
+ extensionSettingsRequestedId = requestedExtensionId
+ _state.update { it.copy(extensionSettings = null) }
+ extensionSettingsLoadJob = extensionSettingsOperationQueue.launchOperation(extensionId) {
+ val configuration = withContext(Dispatchers.IO) {
+ extensionSettingsRepository.configuration(
+ requestedExtensionId,
+ localeTag,
+ TV_SETTINGS_SURFACE,
+ )
+ }
+ if (generation == extensionSettingsGeneration) {
+ _state.update {
+ it.copy(
+ extensionSettings = configuration,
+ extensionPluginOperationFailed = false,
+ )
+ }
+ }
+ }
+ }
+
+ fun closeExtensionSettings() {
+ extensionSettingsGeneration++
+ extensionSettingsLoadJob?.cancel()
+ extensionSettingsLoadJob = null
+ extensionSettingsRequestedId = null
+ _state.update { it.copy(extensionSettings = null) }
+ }
+
+ private fun closeExtensionSettingsIfActive(extensionId: String) {
+ if (
+ state.value.extensionSettings?.extensionId?.value == extensionId ||
+ extensionSettingsRequestedId?.value == extensionId
+ ) {
+ closeExtensionSettings()
+ }
+ }
+
+ fun clearExtensionData(
+ packageName: String,
+ serviceName: String,
+ extensionId: String?,
+ ) {
+ if (extensionId == null) {
+ reportExtensionOperationFailure()
+ return
+ }
+ closeExtensionSettingsIfActive(extensionId)
+ extensionSettingsOperationQueue.launchDestructive(extensionId) {
+ when (
+ val result = withContext(Dispatchers.IO) {
+ extensionPluginRepository.clearData(packageName, serviceName)
+ }
+ ) {
+ is PluginDataClearResult.Cleared -> {
+ _state.update {
+ it.copy(
+ extensionPluginOperationFailed = false,
+ )
+ }
+ }
+ is PluginDataClearResult.Rejected -> {
+ _state.update {
+ it.copy(
+ extensionPluginOperationFailed = true,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ fun exportExtensionDiagnostics(extensionId: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ extensionPluginRepository.diagnostics(extensionId)?.let { payload ->
+ _extensionDiagnostics.emit(payload)
+ }
+ }
+ }
+
+ fun updateExtensionSetting(
+ sectionId: String,
+ fieldKey: String,
+ editToken: ExtensionSettingEditToken,
+ rawValue: String?,
+ localeTag: String?,
+ ) {
+ val extensionId = state.value.extensionSettings?.extensionId ?: return
+ val generation = extensionSettingsGeneration
+ val updateGeneration = ++extensionSettingsUpdateGeneration
+ extensionSettingsOperationQueue.launchUpdate(extensionId.value) update@{
+ val result = withContext(Dispatchers.IO) {
+ val update = extensionSettingsRepository.update(
+ extensionId,
+ sectionId,
+ fieldKey,
+ editToken,
+ rawValue,
+ )
+ TvExtensionSettingsRefreshResult(
+ configuration = extensionSettingsRepository.configuration(
+ extensionId,
+ localeTag,
+ TV_SETTINGS_SURFACE,
+ ),
+ rejected = update is ExtensionSettingUpdateResult.Rejected,
+ )
+ }
+ if (
+ generation != extensionSettingsGeneration ||
+ updateGeneration != extensionSettingsUpdateGeneration ||
+ state.value.extensionSettings?.extensionId != extensionId
+ ) {
+ return@update
+ }
+ _state.update {
+ it.copy(
+ extensionSettings = result.configuration,
+ extensionPluginOperationFailed = result.rejected,
+ )
+ }
+ }
+ }
+
+ fun refreshSubscriptionProviders() {
+ startSubscriptionProviderDiscovery(providerLocaleTag)
+ }
+
+ private fun startSubscriptionProviderDiscovery(localeTag: String?): Job {
+ val previousJob = providerDiscoveryJob
+ val generation = ++providerDiscoveryGeneration
+ return viewModelScope.launch {
+ previousJob?.cancelAndJoin()
+ try {
+ loadSubscriptionProviders(localeTag)
+ } finally {
+ if (providerDiscoveryGeneration == generation) {
+ providerDiscoveryJob = null
+ }
+ }
+ }.also { job -> providerDiscoveryJob = job }
+ }
+
+ private suspend fun awaitLatestSubscriptionProviderDiscovery(
+ forceRefresh: Boolean,
+ ) {
+ var awaitedJob = if (forceRefresh) {
+ startSubscriptionProviderDiscovery(providerLocaleTag)
+ } else {
+ providerDiscoveryJob ?: startSubscriptionProviderDiscovery(providerLocaleTag)
+ }
+ while (true) {
+ awaitedJob.join()
+ val latestJob = providerDiscoveryJob
+ if (latestJob == null || latestJob === awaitedJob) return
+ awaitedJob = latestJob
+ }
+ }
+
+ private suspend fun loadSubscriptionProviders(
+ localeTag: String?,
+ ): List? {
+ val previousDiscoveryState = state.value.providerDiscoveryState
+ val previousProviderUnavailable = state.value.providerSubscriptionUnavailable
+ _state.update { current ->
+ current.copy(
+ providerDiscoveryState = ProviderDiscoveryState.Loading,
+ providerSubscriptionUnavailable = false,
+ )
+ }
+ return try {
+ val providers = withContext(Dispatchers.IO) {
+ subscriptionProviderRepository.discoverProviders(localeTag)
+ }
+ val discoveryState = if (providers.isEmpty()) {
+ ProviderDiscoveryState.Empty
+ } else {
+ ProviderDiscoveryState.Ready(providers)
+ }
+ _state.update { current ->
+ val form = current.providerSubscriptionForm
+ val matchingProvider = form?.let { activeForm ->
+ providers.firstOrNull { provider ->
+ provider.descriptor.providerId == activeForm.providerId &&
+ provider.descriptor.variants.any { variant ->
+ variant.kind == activeForm.providerKind
+ }
+ }
+ }
+ current.copy(
+ providerDiscoveryState = discoveryState,
+ providerSubscriptionForm = matchingProvider
+ ?.descriptor
+ ?.let { descriptor -> requireNotNull(form).updateDescriptor(descriptor) }
+ ?: form,
+ providerSubscriptionDescriptor = when {
+ form == null -> null
+ matchingProvider != null -> matchingProvider.descriptor
+ else -> current.providerSubscriptionDescriptor
+ },
+ providerSubscriptionUnavailable = form != null && !discoveryState.supports(form),
+ )
+ }
+ providers
+ } catch (cancelled: CancellationException) {
+ _state.update { current ->
+ if (current.providerDiscoveryState is ProviderDiscoveryState.Loading) {
+ current.copy(
+ providerDiscoveryState = previousDiscoveryState,
+ providerSubscriptionUnavailable = previousProviderUnavailable,
+ )
+ } else {
+ current
+ }
+ }
+ throw cancelled
+ } catch (error: Exception) {
+ _state.update { current ->
+ current.copy(
+ providerDiscoveryState = ProviderDiscoveryState.Failed(
+ failureCount = (error as? ProviderDiscoveryException)?.failureCount,
+ ),
+ providerSubscriptionUnavailable =
+ current.providerSubscriptionForm != null,
+ )
+ }
+ null
+ }
+ }
+
+ fun openProviderSubscription(providerId: String, providerKind: String) {
+ val descriptor = currentProviders().firstOrNull { provider ->
+ provider.descriptor.providerId.value == providerId
+ }?.descriptor ?: return
+ val kind = descriptor.variants.firstOrNull { variant ->
+ variant.kind.value == providerKind && variant.userSelectable
+ }?.kind ?: return
+ _state.update { state ->
+ state.copy(
+ providerSubscriptionForm = ProviderSubscriptionForm.create(descriptor, kind),
+ providerSubscriptionDescriptor = descriptor,
+ providerSubscriptionUnavailable = false,
+ providerSubscriptionTitle = descriptor.displayName,
+ providerSubscriptionFeedback = null,
+ )
+ }
+ }
+
+ fun reauthenticateProviderAccount(playlistUrl: String) {
+ val account = state.value.providerAccounts.firstOrNull { summary ->
+ summary.playlistUrl == playlistUrl && summary.requiresReauthentication
+ } ?: return
+ providerReauthenticationJob?.cancel()
+ providerReauthenticationJob = viewModelScope.launch {
+ awaitLatestSubscriptionProviderDiscovery(forceRefresh = false)
+ var descriptor = currentProviders().providerFor(account)?.descriptor
+ if (descriptor == null) {
+ awaitLatestSubscriptionProviderDiscovery(forceRefresh = true)
+ descriptor = currentProviders().providerFor(account)?.descriptor
+ }
+ if (descriptor == null) {
+ _state.update {
+ it.copy(providerSubscriptionFeedback = TvProviderSubscriptionFeedback.Failed)
+ }
+ return@launch
+ }
+ _state.update { current ->
+ current.copy(
+ providerSubscriptionForm = ProviderSubscriptionForm.createForReauthentication(
+ descriptor = descriptor,
+ account = account,
+ ),
+ providerSubscriptionDescriptor = descriptor,
+ providerSubscriptionUnavailable = false,
+ providerSubscriptionTitle = account.playlistTitle,
+ providerSubscriptionFeedback = null,
+ )
+ }
+ }
+ }
+
+ fun closeProviderSubscription() {
+ if (state.value.providerSubscriptionInProgress) return
+ _state.update { current ->
+ current.copy(
+ providerSubscriptionForm = null,
+ providerSubscriptionDescriptor = null,
+ providerSubscriptionUnavailable = false,
+ providerSubscriptionTitle = "",
+ providerSubscriptionFeedback = null,
+ )
+ }
+ }
+
+ fun updateProviderSubscriptionTitle(title: String) {
+ _state.update {
+ it.copy(providerSubscriptionTitle = title, providerSubscriptionFeedback = null)
+ }
+ }
+
+ fun selectProviderKind(kindValue: String) {
+ val currentState = state.value
+ if (currentState.providerSubscriptionUnavailable) return
+ val form = currentState.providerSubscriptionForm ?: return
+ val descriptor = currentState.providerSubscriptionDescriptor
+ ?.takeIf { it.providerId == form.providerId }
+ ?: return
+ val kind = descriptor.variants.firstOrNull { variant ->
+ variant.kind.value == kindValue
+ }?.kind ?: return
+ if (kind == form.providerKind) return
+ _state.update { current ->
+ current.copy(
+ providerSubscriptionForm = ProviderSubscriptionForm.create(descriptor, kind),
+ providerSubscriptionDescriptor = descriptor,
+ providerSubscriptionFeedback = null,
+ )
+ }
+ }
+
+ fun updateProviderSetting(fieldKey: String, value: String?) {
+ _state.update { current ->
+ current.copy(
+ providerSubscriptionForm = current.providerSubscriptionForm?.update(fieldKey, value),
+ providerSubscriptionFeedback = null,
+ )
+ }
+ }
+
+ fun submitProviderSubscription() {
+ if (providerSubscriptionJob?.isActive == true) return
+ val current = state.value
+ val form = current.providerSubscriptionForm ?: return
+ if (
+ current.providerSubscriptionUnavailable ||
+ !current.providerDiscoveryState.supports(form)
+ ) {
+ return
+ }
+ if (current.providerSubscriptionTitle.isBlank()) {
+ _state.update {
+ it.copy(providerSubscriptionFeedback = TvProviderSubscriptionFeedback.InvalidSettings)
+ }
+ return
+ }
+ val buildResult = runCatching {
+ form.buildRequest(
+ title = current.providerSubscriptionTitle,
+ stageCredential = subscriptionProviderRepository::stageCredential,
+ )
+ }.getOrElse {
+ _state.update {
+ it.copy(providerSubscriptionFeedback = TvProviderSubscriptionFeedback.Failed)
+ }
+ return
+ }
+ when (val result = buildResult) {
+ is ProviderSubscriptionFormBuildResult.Invalid -> {
+ _state.update {
+ it.copy(
+ providerSubscriptionForm = result.form,
+ providerSubscriptionFeedback = TvProviderSubscriptionFeedback.InvalidSettings,
+ )
+ }
+ }
+
+ is ProviderSubscriptionFormBuildResult.Ready -> {
+ providerSubscriptionJob = viewModelScope.launch {
+ _state.update {
+ it.copy(
+ providerSubscriptionInProgress = true,
+ providerSubscriptionFeedback = null,
+ )
+ }
+ try {
+ val subscription = withContext(Dispatchers.IO) {
+ subscriptionProviderRepository.subscribe(result.request)
+ }
+ _state.update {
+ it.copy(
+ providerSubscriptionForm = null,
+ providerSubscriptionDescriptor = null,
+ providerSubscriptionUnavailable = false,
+ providerSubscriptionTitle = "",
+ providerSubscriptionInProgress = false,
+ providerSubscriptionFeedback = TvProviderSubscriptionFeedback.Added(
+ subscription.channelCount
+ ),
+ )
+ }
+ } catch (cancelled: CancellationException) {
+ _state.update { it.copy(providerSubscriptionInProgress = false) }
+ throw cancelled
+ } catch (_: Exception) {
+ _state.update {
+ it.copy(
+ providerSubscriptionInProgress = false,
+ providerSubscriptionFeedback = TvProviderSubscriptionFeedback.Failed,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun observeExternalExtensions() {
+ viewModelScope.launch {
+ settings.data
+ .map { preferences -> preferences[PreferencesKeys.EXTERNAL_EXTENSIONS] ?: false }
+ .collect { enabled ->
+ _state.update { it.copy(externalExtensionsEnabled = enabled) }
+ refreshExtensionPlugins()
+ }
+ }
+ }
+
+ private fun refreshExtensionPlugins() {
+ viewModelScope.launch {
+ val plugins = withContext(Dispatchers.IO) {
+ extensionPluginRepository.installedPlugins()
+ }
+ _state.update { it.copy(extensionPlugins = plugins) }
+ refreshSubscriptionProviders()
+ }
+ }
+
+ private fun observeProviderAccounts() {
+ viewModelScope.launch {
+ subscriptionProviderRepository.observeAccountSummaries().collect { accounts ->
+ _state.update { it.copy(providerAccounts = accounts) }
+ }
+ }
+ }
+
+ private fun currentProviders(): List =
+ (state.value.providerDiscoveryState as? ProviderDiscoveryState.Ready)
+ ?.providers
+ .orEmpty()
+
+ private fun updateExtensionPluginResult(result: PluginEnableResult) {
+ _state.update { state ->
+ state.copy(
+ extensionPluginOperationFailed = result is PluginEnableResult.Rejected,
+ )
+ }
+ }
+
+ private fun reportExtensionOperationFailure() {
+ _state.update {
+ it.copy(
+ extensionPluginOperationFailed = true,
+ )
+ }
+ }
+
private fun observePlaylists() {
viewModelScope.launch {
playlistRepository
@@ -111,7 +751,12 @@ class TvHomeViewModel @Inject constructor(
val state = _state.value
val playlists = counts.keys
.filterNot { it.source == DataSource.EPG }
- .sortedBy { it.title.lowercase() }
+ .sortedWith(
+ localeAwareComparator(
+ primarySelector = Playlist::title,
+ locale = sortLocale,
+ )
+ )
val previous = state.selectedPlaylist
val selected = previous
?.let { active -> playlists.firstOrNull { it.url == active.url } }
@@ -158,8 +803,11 @@ class TvHomeViewModel @Inject constructor(
.getByPlaylistUrl(url)
.filterNot { it.hidden }
.sortedWith(
- compareBy { it.category.lowercase() }
- .thenBy { it.title.lowercase() }
+ localeAwareComparator(
+ primarySelector = Channel::category,
+ secondarySelector = Channel::title,
+ locale = sortLocale,
+ )
)
_state.update { state ->
if (state.selectedPlaylist?.url == url) {
@@ -176,4 +824,22 @@ class TvHomeViewModel @Inject constructor(
private fun Map.countFor(url: String): Int? =
entries.firstOrNull { it.key.url == url }?.value
-}
\ No newline at end of file
+
+ private companion object {
+ const val TV_SETTINGS_SURFACE = "tv"
+ }
+}
+
+private data class TvExtensionSettingsRefreshResult(
+ val configuration: ExtensionSettingsConfiguration?,
+ val rejected: Boolean,
+)
+
+private fun List.providerFor(
+ account: ProviderAccountSummary,
+): DiscoveredSubscriptionProvider? = singleOrNull { provider ->
+ provider.descriptor.providerId == account.providerId &&
+ provider.descriptor.variants.any { variant ->
+ variant.kind == account.providerKind
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvLocaleSort.kt b/app/tv/src/main/java/com/m3u/tv/TvLocaleSort.kt
new file mode 100644
index 000000000..5ad0a8a03
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/TvLocaleSort.kt
@@ -0,0 +1,35 @@
+package com.m3u.tv
+
+import java.text.Collator
+import java.util.Locale
+
+/**
+ * Builds a case-insensitive, locale-aware comparator for user-visible text.
+ *
+ * Kotlin's [sortedWith] is stable, so items whose primary and secondary labels
+ * collate equally retain their repository order.
+ */
+internal fun localeAwareComparator(
+ primarySelector: (T) -> String,
+ secondarySelector: ((T) -> String)? = null,
+ locale: Locale = Locale.getDefault(),
+): Comparator {
+ val collator = Collator.getInstance(locale).apply {
+ strength = Collator.SECONDARY
+ decomposition = Collator.CANONICAL_DECOMPOSITION
+ }
+ return Comparator { left, right ->
+ val primaryComparison = collator.compare(
+ primarySelector(left),
+ primarySelector(right),
+ )
+ if (primaryComparison != 0 || secondarySelector == null) {
+ primaryComparison
+ } else {
+ collator.compare(
+ secondarySelector(left),
+ secondarySelector(right),
+ )
+ }
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvScreens.kt b/app/tv/src/main/java/com/m3u/tv/TvScreens.kt
index fa56e95ef..f70cf6bef 100644
--- a/app/tv/src/main/java/com/m3u/tv/TvScreens.kt
+++ b/app/tv/src/main/java/com/m3u/tv/TvScreens.kt
@@ -1,12 +1,17 @@
package com.m3u.tv
+import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.border
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
@@ -14,20 +19,26 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay
import androidx.compose.material.icons.rounded.Favorite
+import androidx.compose.material.icons.rounded.Extension
+import androidx.compose.material.icons.rounded.Block
+import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.VideoLibrary
@@ -37,30 +48,79 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.error
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.password
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.m3u.business.setting.ExtensionSettingInputError
+import com.m3u.business.setting.ProviderDiscoveryState
+import com.m3u.business.setting.ProviderSettingFieldError
+import com.m3u.business.setting.ProviderSubscriptionForm
+import com.m3u.business.setting.extensionSettingInputError
+import com.m3u.business.setting.normalizedExtensionSettingValue
+import com.m3u.business.setting.supports
import com.m3u.core.foundation.util.basic.title
import com.m3u.data.database.model.Channel
import com.m3u.data.database.model.Playlist
+import com.m3u.data.repository.extension.ExtensionSettingEditToken
+import com.m3u.data.repository.extension.ExtensionSettingsConfiguration
+import com.m3u.data.repository.provider.ProviderAccountSummary
+import com.m3u.data.repository.provider.SubscriptionProviderExecutionKind
+import com.m3u.data.repository.plugin.InstalledPlugin
+import com.m3u.data.repository.plugin.PluginAuthorizationToken
+import com.m3u.extension.api.ExtensionCapabilityIds
+import com.m3u.extension.api.ExtensionSettingField
+import com.m3u.extension.api.ExtensionSettingKeys
+import com.m3u.extension.api.ExtensionSettingType
+import com.m3u.extension.api.ExtensionState
import com.m3u.i18n.R.string
+import com.m3u.i18n.R.plurals
import kotlinx.coroutines.yield
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.contentOrNull
@Composable
fun TvBrowsePane(
@@ -70,14 +130,32 @@ fun TvBrowsePane(
onPlaylist: (Playlist) -> Unit,
onRefresh: () -> Unit,
onPlay: (Channel) -> Unit,
- onPlayRecent: () -> Unit
+ onPlayRecent: () -> Unit,
+ onExternalExtensionsEnabled: (Boolean) -> Unit,
+ onEnableExtension: (String, String, PluginAuthorizationToken) -> Unit,
+ onReauthorizeExtension: (String, String, PluginAuthorizationToken) -> Unit,
+ onDisableExtension: (String) -> Unit,
+ onRevokeExtension: (String, String, String?) -> Unit,
+ onClearExtensionData: (String, String, String?) -> Unit,
+ onExportExtensionDiagnostics: (String) -> Unit,
+ onOpenExtensionSettings: (String) -> Unit,
+ onCloseExtensionSettings: () -> Unit,
+ onUpdateExtensionSetting: (String, String, ExtensionSettingEditToken, String?) -> Unit,
+ onRefreshProviders: () -> Unit,
+ onOpenProviderSubscription: (String, String) -> Unit,
+ onReauthenticateProvider: (String) -> Unit,
+ onCloseProviderSubscription: () -> Unit,
+ onUpdateProviderTitle: (String) -> Unit,
+ onSelectProviderKind: (String) -> Unit,
+ onUpdateProviderSetting: (String, String?) -> Unit,
+ onSubmitProviderSubscription: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
- if (state.playlists.isEmpty()) {
+ if (state.playlists.isEmpty() && destination != TvDestination.Status) {
EmptyLibraryScreen()
} else {
when (destination) {
@@ -103,7 +181,27 @@ fun TvBrowsePane(
onPlay = onPlay
)
- TvDestination.Status -> StatusScreen(state)
+ TvDestination.Status -> StatusScreen(
+ state = state,
+ onExternalExtensionsEnabled = onExternalExtensionsEnabled,
+ onEnableExtension = onEnableExtension,
+ onReauthorizeExtension = onReauthorizeExtension,
+ onDisableExtension = onDisableExtension,
+ onRevokeExtension = onRevokeExtension,
+ onClearExtensionData = onClearExtensionData,
+ onExportExtensionDiagnostics = onExportExtensionDiagnostics,
+ onOpenExtensionSettings = onOpenExtensionSettings,
+ onCloseExtensionSettings = onCloseExtensionSettings,
+ onUpdateExtensionSetting = onUpdateExtensionSetting,
+ onRefreshProviders = onRefreshProviders,
+ onOpenProviderSubscription = onOpenProviderSubscription,
+ onReauthenticateProvider = onReauthenticateProvider,
+ onCloseProviderSubscription = onCloseProviderSubscription,
+ onUpdateProviderTitle = onUpdateProviderTitle,
+ onSelectProviderKind = onSelectProviderKind,
+ onUpdateProviderSetting = onUpdateProviderSetting,
+ onSubmitProviderSubscription = onSubmitProviderSubscription,
+ )
}
}
}
@@ -191,7 +289,6 @@ private fun HomeScreen(
onClick = { onPlaylist(playlist) },
modifier = Modifier
.widthIn(min = 256.dp, max = 336.dp)
- .height(144.dp)
)
}
}
@@ -210,6 +307,8 @@ private fun FeaturedCarouselPane(
onPlayRecent: () -> Unit,
onPlay: (Channel) -> Unit
) {
+ val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+ val largeTextLayout = tvLargeTextLayout(LocalDensity.current.fontScale)
val primaryHeroAction = {
if (channel == null) {
onOpenLibrary()
@@ -219,15 +318,21 @@ private fun FeaturedCarouselPane(
onPlay(channel)
}
}
- var selectedAction by remember(channel?.id) { mutableStateOf(HeroAction.Primary) }
+ var selectedAction by remember(channel?.id) { mutableStateOf(TvHeroAction.PRIMARY) }
val secondaryAvailable = channel != null
- val selectedHeroAction = if (secondaryAvailable) selectedAction else HeroAction.Primary
+ val selectedHeroAction = if (secondaryAvailable) selectedAction else TvHeroAction.PRIMARY
+ val openLibraryLabel = stringResource(string.tv_action_open_library)
+ val primaryActionLabel = if (channel == null) {
+ openLibraryLabel
+ } else {
+ stringResource(string.tv_action_resume)
+ }
FocusFrame(
onClick = {
when (selectedHeroAction) {
- HeroAction.Primary -> primaryHeroAction()
- HeroAction.Secondary -> onOpenLibrary()
+ TvHeroAction.PRIMARY -> primaryHeroAction()
+ TvHeroAction.SECONDARY -> onOpenLibrary()
}
},
shape = RoundedCornerShape(16.dp),
@@ -237,7 +342,7 @@ private fun FeaturedCarouselPane(
focusedBorderColor = Color.Transparent,
modifier = Modifier
.fillMaxWidth()
- .height(if (channel == null) 280.dp else 288.dp)
+ .heightIn(min = largeTextLayout.heroMinHeightDp.dp)
.focusProperties { down = nextFocusRequester },
onKey = { event ->
if (event.type != KeyEventType.KeyDown || !secondaryAvailable) {
@@ -245,12 +350,24 @@ private fun FeaturedCarouselPane(
} else {
when (event.key) {
Key.DirectionLeft -> {
- selectedAction = HeroAction.Primary
- true
+ tvHeroActionAfterHorizontalMove(
+ current = selectedHeroAction,
+ direction = TvHorizontalDirection.LEFT,
+ isRtl = isRtl,
+ )?.let { action ->
+ selectedAction = action
+ true
+ } ?: false
}
Key.DirectionRight -> {
- selectedAction = HeroAction.Secondary
- true
+ tvHeroActionAfterHorizontalMove(
+ current = selectedHeroAction,
+ direction = TvHorizontalDirection.RIGHT,
+ isRtl = isRtl,
+ )?.let { action ->
+ selectedAction = action
+ true
+ } ?: false
}
else -> false
}
@@ -278,9 +395,13 @@ private fun FeaturedCarouselPane(
.fillMaxSize()
.background(
Brush.horizontalGradient(
- 0f to Color.Black.copy(alpha = 0.92f),
- 0.48f to Color.Black.copy(alpha = 0.72f),
- 1f to Color.Transparent
+ *tvLeadingGradientColorStops(
+ isRtl = isRtl,
+ leading = Color.Black.copy(alpha = 0.92f),
+ middle = Color.Black.copy(alpha = 0.72f),
+ trailing = Color.Transparent,
+ middlePosition = 0.48f,
+ ).toTypedArray()
)
)
)
@@ -293,7 +414,7 @@ private fun FeaturedCarouselPane(
verticalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier
.align(Alignment.CenterStart)
- .fillMaxWidth(0.54f)
+ .fillMaxWidth(largeTextLayout.heroTextWidthFraction)
) {
Text(
text = channel?.title?.title() ?: stringResource(string.tv_home_title),
@@ -318,23 +439,23 @@ private fun FeaturedCarouselPane(
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
if (channel == null) {
HeroActionChip(
- text = stringResource(string.tv_action_open_library),
+ text = openLibraryLabel,
icon = Icons.AutoMirrored.Rounded.PlaylistPlay,
selected = heroFocused,
expanded = heroFocused
)
} else {
HeroActionChip(
- text = stringResource(string.tv_action_resume),
+ text = primaryActionLabel,
icon = Icons.Rounded.PlayArrow,
- selected = heroFocused && selectedHeroAction == HeroAction.Primary,
- expanded = heroFocused && selectedHeroAction == HeroAction.Primary
+ selected = heroFocused && selectedHeroAction == TvHeroAction.PRIMARY,
+ expanded = heroFocused && selectedHeroAction == TvHeroAction.PRIMARY
)
HeroActionChip(
- text = stringResource(string.tv_action_open_library),
+ text = openLibraryLabel,
icon = Icons.AutoMirrored.Rounded.PlaylistPlay,
- selected = heroFocused && selectedHeroAction == HeroAction.Secondary,
- expanded = heroFocused && selectedHeroAction == HeroAction.Secondary
+ selected = heroFocused && selectedHeroAction == TvHeroAction.SECONDARY,
+ expanded = heroFocused && selectedHeroAction == TvHeroAction.SECONDARY
)
}
}
@@ -344,11 +465,6 @@ private fun FeaturedCarouselPane(
}
}
-private enum class HeroAction {
- Primary,
- Secondary
-}
-
@Composable
private fun HeroActionChip(
text: String,
@@ -360,7 +476,7 @@ private fun HeroActionChip(
horizontalArrangement = Arrangement.spacedBy(if (expanded) 8.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
- .height(48.dp)
+ .heightIn(min = 48.dp)
.clip(RoundedCornerShape(24.dp))
.background(if (selected) TvColors.Focus else TvColors.Surface.copy(alpha = 0.86f))
.border(
@@ -370,7 +486,10 @@ private fun HeroActionChip(
),
RoundedCornerShape(24.dp)
)
- .padding(horizontal = if (expanded) 16.dp else 12.dp)
+ .padding(
+ horizontal = if (expanded) 16.dp else 12.dp,
+ vertical = 8.dp,
+ )
) {
Icon(
imageVector = icon,
@@ -437,7 +556,6 @@ private fun LibraryScreen(
focusRequester = if (playlist.url == focusTarget?.url) playlistFocusRequester else null,
modifier = Modifier
.widthIn(min = 256.dp, max = 336.dp)
- .height(144.dp)
)
}
}
@@ -459,7 +577,11 @@ private fun LibraryScreen(
overflow = TextOverflow.Ellipsis
)
Text(
- text = stringResource(string.tv_channel_count, state.channels.size),
+ text = pluralStringResource(
+ plurals.tv_channel_count,
+ state.channels.size,
+ state.channels.size,
+ ),
color = TvColors.TextSecondary,
fontSize = 14.sp,
fontFamily = TvFonts.Body,
@@ -516,29 +638,412 @@ private fun ChannelGridScreen(
}
}
+private sealed interface TvStatusReturnFocusTarget {
+ data class Provider(
+ val providerId: String,
+ val providerKind: String,
+ ) : TvStatusReturnFocusTarget
+
+ data class Reauthentication(
+ val playlistUrl: String,
+ val providerId: String,
+ val providerKind: String,
+ ) : TvStatusReturnFocusTarget
+
+ data class Plugin(
+ val packageName: String,
+ val serviceName: String,
+ val action: TvExtensionPluginAction,
+ ) : TvStatusReturnFocusTarget
+}
+
@Composable
-private fun StatusScreen(state: TvUiState) {
- Column(
- verticalArrangement = Arrangement.spacedBy(24.dp),
- modifier = Modifier
- .fillMaxSize()
- .padding(start = 48.dp, top = 48.dp, end = 64.dp, bottom = 48.dp)
+private fun StatusScreen(
+ state: TvUiState,
+ onExternalExtensionsEnabled: (Boolean) -> Unit,
+ onEnableExtension: (String, String, PluginAuthorizationToken) -> Unit,
+ onReauthorizeExtension: (String, String, PluginAuthorizationToken) -> Unit,
+ onDisableExtension: (String) -> Unit,
+ onRevokeExtension: (String, String, String?) -> Unit,
+ onClearExtensionData: (String, String, String?) -> Unit,
+ onExportExtensionDiagnostics: (String) -> Unit,
+ onOpenExtensionSettings: (String) -> Unit,
+ onCloseExtensionSettings: () -> Unit,
+ onUpdateExtensionSetting: (String, String, ExtensionSettingEditToken, String?) -> Unit,
+ onRefreshProviders: () -> Unit,
+ onOpenProviderSubscription: (String, String) -> Unit,
+ onReauthenticateProvider: (String) -> Unit,
+ onCloseProviderSubscription: () -> Unit,
+ onUpdateProviderTitle: (String) -> Unit,
+ onSelectProviderKind: (String) -> Unit,
+ onUpdateProviderSetting: (String, String?) -> Unit,
+ onSubmitProviderSubscription: () -> Unit,
+) {
+ val bidiFormatter = rememberTvBidiFormatter()
+ val density = LocalDensity.current
+ val largeTextLayout = tvLargeTextLayout(density.fontScale)
+ val focusRestoreOffsetPx = with(density) { 28.dp.roundToPx() }
+ var pendingTrust by remember { mutableStateOf(null) }
+ var pendingReauthorization by remember { mutableStateOf(false) }
+ var pendingRevoke by remember { mutableStateOf(null) }
+ var pendingClear by remember { mutableStateOf(null) }
+ val returnFocusRequester = remember { FocusRequester() }
+ var returnFocusTarget by remember {
+ mutableStateOf(null)
+ }
+ var pluginPanelCancelled by remember { mutableStateOf(false) }
+ var restoreWithoutPanelRequested by remember { mutableStateOf(false) }
+ var panelWasVisible by remember { mutableStateOf(false) }
+ val statusListState = rememberLazyListState()
+ val extensionOperationFailedMessage =
+ stringResource(string.feat_setting_extension_operation_failed)
+ val panelIsVisible =
+ pendingRevoke != null ||
+ pendingClear != null ||
+ pendingTrust != null ||
+ state.extensionSettings != null ||
+ state.providerSubscriptionForm != null
+ val reauthenticationAccounts =
+ state.providerAccounts.filter(ProviderAccountSummary::requiresReauthentication)
+ val providerFeedbackVisible = state.providerSubscriptionFeedback != null
+ val providerDiscoveryItemCount = when (val discovery = state.providerDiscoveryState) {
+ ProviderDiscoveryState.Loading,
+ ProviderDiscoveryState.Empty,
+ -> 1
+
+ is ProviderDiscoveryState.Failed -> 2
+ is ProviderDiscoveryState.Ready -> discovery.providers.sumOf { provider ->
+ provider.descriptor.variants.count { variant -> variant.userSelectable }
+ }
+ }
+ val developerModeItemIndex = tvExtensionDeveloperModeItemIndex(
+ providerFeedbackVisible = providerFeedbackVisible,
+ reauthenticationCount = reauthenticationAccounts.size,
+ providerDiscoveryItemCount = providerDiscoveryItemCount,
+ extensionErrorVisible = state.extensionPluginOperationFailed,
+ )
+ val selectableProviderVariants =
+ (state.providerDiscoveryState as? ProviderDiscoveryState.Ready)
+ ?.providers
+ ?.flatMap { provider ->
+ provider.descriptor.variants
+ .filter { variant -> variant.userSelectable }
+ .map { variant ->
+ provider.descriptor.providerId.value to variant.kind.value
+ }
+ }
+ .orEmpty()
+ fun providerVariantItemIndex(providerId: String, providerKind: String): Int? {
+ val variantIndex = selectableProviderVariants.indexOfFirst { (id, kind) ->
+ id == providerId && kind == providerKind
+ }
+ return variantIndex.takeIf { it >= 0 }?.let { index ->
+ tvProviderVariantItemIndex(
+ providerFeedbackVisible = providerFeedbackVisible,
+ reauthenticationCount = reauthenticationAccounts.size,
+ providerVariantIndex = index,
+ )
+ }
+ }
+ val reauthenticationReturnTarget =
+ returnFocusTarget as? TvStatusReturnFocusTarget.Reauthentication
+ val reauthenticationReturnFocusAnchor =
+ reauthenticationReturnTarget?.let { target ->
+ tvProviderReauthenticationFocusAnchor(
+ subscriptionSucceeded =
+ state.providerSubscriptionFeedback is TvProviderSubscriptionFeedback.Added,
+ accountActionVisible = reauthenticationAccounts.any { account ->
+ account.playlistUrl == target.playlistUrl
+ },
+ )
+ }
+ val providerReturnItemIndex = when (val target = returnFocusTarget) {
+ is TvStatusReturnFocusTarget.Provider -> providerVariantItemIndex(
+ providerId = target.providerId,
+ providerKind = target.providerKind,
+ )
+
+ is TvStatusReturnFocusTarget.Reauthentication -> {
+ if (
+ reauthenticationReturnFocusAnchor ==
+ TvProviderReauthenticationFocusAnchor.ACCOUNT_ACTION
+ ) {
+ val reauthenticationIndex =
+ reauthenticationAccounts.indexOfFirst { account ->
+ account.playlistUrl == target.playlistUrl
+ }
+ tvProviderReauthenticationItemIndex(
+ providerFeedbackVisible = providerFeedbackVisible,
+ reauthenticationIndex = reauthenticationIndex,
+ )
+ } else {
+ providerVariantItemIndex(
+ providerId = target.providerId,
+ providerKind = target.providerKind,
+ )
+ }
+ }
+
+ else -> null
+ }
+ val pluginReturnTarget =
+ returnFocusTarget as? TvStatusReturnFocusTarget.Plugin
+ val pluginReturnPluginIndex =
+ pluginReturnTarget
+ ?.let { target ->
+ state.extensionPlugins.indexOfFirst { candidate ->
+ candidate.packageName == target.packageName &&
+ candidate.serviceName == target.serviceName
+ }
+ }
+ ?.takeIf { pluginIndex -> pluginIndex >= 0 }
+ val pluginReturnPlugin =
+ pluginReturnPluginIndex?.let(state.extensionPlugins::get)
+ val pluginReturnItemIndex =
+ pluginReturnPluginIndex?.let { pluginIndex ->
+ developerModeItemIndex + 1 + pluginIndex
+ }
+ val pluginReturnSourceActionAvailable =
+ pluginReturnTarget?.let { target ->
+ pluginReturnPlugin
+ ?.tvActionAvailability()
+ ?.isActionAvailable(target.action)
+ } == true
+
+ LaunchedEffect(
+ panelIsVisible,
+ restoreWithoutPanelRequested,
+ returnFocusTarget,
+ pluginPanelCancelled,
+ developerModeItemIndex,
+ providerReturnItemIndex,
+ pluginReturnItemIndex,
+ pluginReturnSourceActionAvailable,
) {
- SectionTitle(
- title = stringResource(string.tv_settings_title),
- subtitle = stringResource(string.tv_settings_subtitle)
+ val shouldRestoreAfterPanel = shouldRestoreTvStatusFocus(
+ panelWasVisible = panelWasVisible,
+ panelIsVisible = panelIsVisible,
+ hasReturnTarget = returnFocusTarget != null,
)
- Row(
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- modifier = Modifier.fillMaxWidth()
+ if (
+ shouldRestoreAfterPanel ||
+ (!panelIsVisible && restoreWithoutPanelRequested)
+ ) {
+ restoreWithoutPanelRequested = true
+ if (providerReturnItemIndex != null) {
+ statusListState.scrollToItem(
+ providerReturnItemIndex,
+ scrollOffset = -focusRestoreOffsetPx,
+ )
+ } else {
+ val pluginTarget =
+ returnFocusTarget as? TvStatusReturnFocusTarget.Plugin
+ if (pluginTarget != null) {
+ when (tvExtensionPluginReturnFocusAnchor(
+ action = pluginTarget.action,
+ panelCancelled = pluginPanelCancelled,
+ )) {
+ TvExtensionPluginReturnFocusAnchor.DEVELOPER_MODE ->
+ statusListState.scrollToItem(
+ developerModeItemIndex,
+ scrollOffset = -focusRestoreOffsetPx,
+ )
+
+ TvExtensionPluginReturnFocusAnchor.SOURCE_ACTION ->
+ pluginReturnItemIndex?.let { itemIndex ->
+ statusListState.scrollToItem(
+ itemIndex,
+ scrollOffset = -focusRestoreOffsetPx,
+ )
+ }
+ }
+ }
+ }
+ repeat(2) { withFrameNanos { } }
+ var restored = false
+ repeat(3) {
+ if (!restored) {
+ restored = runCatching {
+ returnFocusRequester.requestFocus()
+ }.getOrDefault(false)
+ if (!restored) withFrameNanos { }
+ }
+ }
+ if (restored) {
+ restoreWithoutPanelRequested = false
+ returnFocusTarget = null
+ pluginPanelCancelled = false
+ }
+ }
+ panelWasVisible = panelIsVisible
+ }
+ LaunchedEffect(state.externalExtensionsEnabled) {
+ if (!state.externalExtensionsEnabled) {
+ pendingTrust = null
+ pendingReauthorization = false
+ pendingRevoke = null
+ pendingClear = null
+ returnFocusTarget = null
+ pluginPanelCancelled = false
+ onCloseExtensionSettings()
+ }
+ }
+
+ pendingRevoke?.let { plugin ->
+ ExtensionForgetConfirmation(
+ plugin = plugin,
+ onConfirm = {
+ pluginPanelCancelled = false
+ pendingRevoke = null
+ onRevokeExtension(
+ plugin.packageName,
+ plugin.serviceName,
+ plugin.extensionId,
+ )
+ },
+ onCancel = {
+ pluginPanelCancelled = true
+ pendingRevoke = null
+ },
+ )
+ return
+ }
+
+ pendingClear?.let { plugin ->
+ ExtensionClearConfirmation(
+ plugin = plugin,
+ onConfirm = {
+ pluginPanelCancelled = false
+ pendingClear = null
+ onClearExtensionData(
+ plugin.packageName,
+ plugin.serviceName,
+ plugin.extensionId,
+ )
+ },
+ onCancel = {
+ pluginPanelCancelled = true
+ pendingClear = null
+ },
+ )
+ return
+ }
+
+ state.extensionSettings?.let { configuration ->
+ ExtensionSettingsPanel(
+ configuration = configuration,
+ operationError = extensionOperationFailedMessage.takeIf {
+ state.extensionPluginOperationFailed
+ },
+ onClose = onCloseExtensionSettings,
+ onUpdate = onUpdateExtensionSetting,
+ )
+ return
+ }
+
+ state.providerSubscriptionForm?.let { form ->
+ val descriptor = state.providerSubscriptionDescriptor
+ ?.takeIf { candidate -> candidate.providerId == form.providerId }
+ val providerAvailability = tvProviderFormAvailability(
+ discoveryLoading = state.providerDiscoveryState is ProviderDiscoveryState.Loading,
+ providerSupported = state.providerDiscoveryState.supports(form),
+ providerMarkedUnavailable = state.providerSubscriptionUnavailable,
+ )
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ contentPadding = PaddingValues(start = 48.dp, top = 48.dp, end = 64.dp, bottom = 48.dp),
+ modifier = Modifier.fillMaxSize().focusGroup(),
) {
+ item {
+ ProviderSubscriptionPanel(
+ form = form,
+ providerName = descriptor?.displayName
+ ?: form.providerId.value,
+ variants = descriptor?.variants
+ ?.filter { variant ->
+ variant.userSelectable || variant.kind == form.providerKind
+ }
+ ?.map { variant ->
+ variant.kind.value to variant.displayName
+ }
+ .orEmpty()
+ .ifEmpty {
+ listOf(form.providerKind.value to form.providerKind.value)
+ },
+ title = state.providerSubscriptionTitle,
+ inProgress = state.providerSubscriptionInProgress,
+ providerAvailability = providerAvailability,
+ feedback = state.providerSubscriptionFeedback,
+ onClose = onCloseProviderSubscription,
+ onTitleChange = onUpdateProviderTitle,
+ onKind = onSelectProviderKind,
+ onSetting = onUpdateProviderSetting,
+ onRetry = onRefreshProviders,
+ onSubmit = onSubmitProviderSubscription,
+ )
+ }
+ }
+ return
+ }
+
+ pendingTrust?.takeIf { state.externalExtensionsEnabled }?.let { plugin ->
+ ExtensionAuthorizationConfirmation(
+ plugin = plugin,
+ reauthorization = pendingReauthorization,
+ onConfirm = {
+ pluginPanelCancelled = false
+ val reauthorize = pendingReauthorization
+ pendingTrust = null
+ pendingReauthorization = false
+ plugin.authorizationToken?.let { authorizationToken ->
+ if (reauthorize) {
+ onReauthorizeExtension(
+ plugin.packageName,
+ plugin.serviceName,
+ authorizationToken,
+ )
+ } else {
+ onEnableExtension(
+ plugin.packageName,
+ plugin.serviceName,
+ authorizationToken,
+ )
+ }
+ }
+ },
+ onCancel = {
+ pluginPanelCancelled = true
+ pendingTrust = null
+ pendingReauthorization = false
+ },
+ )
+ return
+ }
+
+ LazyColumn(
+ state = statusListState,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ contentPadding = PaddingValues(start = 48.dp, top = 48.dp, end = 64.dp, bottom = 48.dp),
+ modifier = Modifier.fillMaxSize().focusGroup(),
+ ) {
+ item {
+ SectionTitle(
+ title = stringResource(string.tv_settings_title),
+ subtitle = stringResource(string.tv_settings_subtitle)
+ )
+ }
+ item {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
MetricTile(
title = stringResource(string.tv_metric_playlists),
value = state.playlists.size.toString(),
icon = Icons.Rounded.VideoLibrary,
modifier = Modifier
.weight(1f)
- .height(136.dp)
+ .heightIn(min = largeTextLayout.metricTileMinHeightDp.dp)
)
MetricTile(
title = stringResource(string.tv_metric_channels),
@@ -546,7 +1051,7 @@ private fun StatusScreen(state: TvUiState) {
icon = Icons.AutoMirrored.Rounded.PlaylistPlay,
modifier = Modifier
.weight(1f)
- .height(136.dp)
+ .heightIn(min = largeTextLayout.metricTileMinHeightDp.dp)
)
MetricTile(
title = stringResource(string.tv_metric_favorites),
@@ -554,130 +1059,1789 @@ private fun StatusScreen(state: TvUiState) {
icon = Icons.Rounded.Favorite,
modifier = Modifier
.weight(1f)
- .height(136.dp)
+ .heightIn(min = largeTextLayout.metricTileMinHeightDp.dp)
)
}
- }
-}
+ }
+ item {
+ SectionTitle(
+ title = stringResource(string.feat_setting_data_source_provider),
+ subtitle = stringResource(string.feat_setting_playlist_management),
+ )
+ }
+ state.providerSubscriptionFeedback?.let { feedback ->
+ item {
+ ProviderSubscriptionFeedback(feedback)
+ }
+ }
+ items(
+ items = reauthenticationAccounts,
+ key = { account -> "reauth:${account.playlistUrl}" },
+ ) { account ->
+ val focusTarget = TvStatusReturnFocusTarget.Reauthentication(
+ playlistUrl = account.playlistUrl,
+ providerId = account.providerId.value,
+ providerKind = account.providerKind.value,
+ )
+ ProviderReauthenticationCard(
+ account = account,
+ focusRequester = returnFocusRequester.takeIf {
+ returnFocusTarget == focusTarget &&
+ reauthenticationReturnFocusAnchor ==
+ TvProviderReauthenticationFocusAnchor.ACCOUNT_ACTION
+ },
+ onReauthenticate = {
+ returnFocusTarget = focusTarget
+ onReauthenticateProvider(account.playlistUrl)
+ },
+ )
+ }
+ when (val discovery = state.providerDiscoveryState) {
+ ProviderDiscoveryState.Loading -> item {
+ Text(
+ stringResource(string.feat_setting_provider_discovery_loading),
+ color = TvColors.TextSecondary,
+ modifier = Modifier.semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ }
-@Composable
-private fun ContentRow(
- channels: List,
- onPlay: (Channel) -> Unit,
- onFocused: (Channel) -> Unit = {},
- firstItemFocusRequester: FocusRequester? = null
-) {
- LazyRow(
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- contentPadding = PaddingValues(start = 48.dp, top = 8.dp, end = 48.dp, bottom = 8.dp),
- modifier = Modifier.focusGroup()
- ) {
- itemsIndexed(channels, key = { _, channel -> channel.id }) { index, channel ->
- ChannelCard(
- channel = channel,
- onPlay = { onPlay(channel) },
- onFocused = { onFocused(channel) },
- focusRequester = firstItemFocusRequester.takeIf { index == 0 },
- compact = true,
- modifier = Modifier
- .widthIn(min = 104.dp, max = 120.dp)
- .aspectRatio(2f / 3f)
+ ProviderDiscoveryState.Empty -> item {
+ Text(
+ stringResource(string.feat_setting_provider_discovery_empty),
+ color = TvColors.TextSecondary,
+ modifier = Modifier.semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ }
+
+ is ProviderDiscoveryState.Failed -> {
+ item {
+ val message =
+ stringResource(string.feat_setting_provider_discovery_failed)
+ Text(
+ message,
+ color = TvColors.Danger,
+ modifier = Modifier.semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ }
+ item {
+ TvActionButton(
+ text = stringResource(string.feat_setting_provider_discovery_retry),
+ icon = Icons.Rounded.Refresh,
+ onClick = onRefreshProviders,
+ )
+ }
+ }
+
+ is ProviderDiscoveryState.Ready -> {
+ discovery.providers.forEach { provider ->
+ val selectableVariants = provider.descriptor.variants.filter { variant ->
+ variant.userSelectable
+ }
+ selectableVariants.forEach { variant ->
+ item(key = "provider:${provider.descriptor.providerId.value}:${variant.kind.value}") {
+ val isExternal =
+ provider.executionKind ==
+ SubscriptionProviderExecutionKind.EXTERNAL
+ val presentation = tvProviderChoicePresentation(
+ providerId = provider.descriptor.providerId.value,
+ providerDisplayName = provider.descriptor.displayName,
+ variantDisplayName = variant.displayName,
+ external = isExternal,
+ )
+ val visualVariantName = bidiFormatter.natural(
+ presentation.variantName,
+ )
+ val visualProviderLabel =
+ presentation.providerName?.let { providerName ->
+ stringResource(
+ string.feat_setting_provider_source_with_provider,
+ visualVariantName,
+ bidiFormatter.natural(providerName),
+ )
+ } ?: visualVariantName
+ val semanticProviderLabel =
+ presentation.providerName?.let { providerName ->
+ stringResource(
+ string.feat_setting_provider_source_with_provider,
+ bidiFormatter.natural(presentation.variantName),
+ bidiFormatter.natural(providerName),
+ )
+ } ?: bidiFormatter.natural(presentation.variantName)
+ val visualProviderId = bidiFormatter.ltr(
+ provider.descriptor.providerId.value,
+ )
+ val semanticProviderId = bidiFormatter.ltr(
+ provider.descriptor.providerId.value,
+ )
+ val focusTarget = TvStatusReturnFocusTarget.Provider(
+ providerId = provider.descriptor.providerId.value,
+ providerKind = variant.kind.value,
+ )
+ val reauthenticationTarget = returnFocusTarget
+ as? TvStatusReturnFocusTarget.Reauthentication
+ val restoresReauthentication =
+ reauthenticationTarget?.let { target ->
+ target.providerId ==
+ provider.descriptor.providerId.value &&
+ target.providerKind == variant.kind.value
+ } == true &&
+ reauthenticationReturnFocusAnchor ==
+ TvProviderReauthenticationFocusAnchor.PROVIDER_VARIANT
+ TvActionButton(
+ text = visualProviderLabel,
+ supportingText = visualProviderId.takeIf { isExternal },
+ icon = Icons.Rounded.Extension,
+ semanticsLabel = if (isExternal) {
+ stringResource(
+ string.feat_setting_provider_choice_with_identifier_description,
+ semanticProviderLabel,
+ semanticProviderId,
+ )
+ } else {
+ semanticProviderLabel
+ },
+ focusRequester = returnFocusRequester.takeIf {
+ returnFocusTarget == focusTarget ||
+ restoresReauthentication
+ },
+ onClick = {
+ returnFocusTarget = focusTarget
+ onOpenProviderSubscription(
+ provider.descriptor.providerId.value,
+ variant.kind.value,
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+ item {
+ SectionTitle(
+ title = stringResource(string.feat_setting_extension_plugins),
+ subtitle = stringResource(string.tv_extensions_subtitle),
+ )
+ }
+ if (state.extensionPluginOperationFailed) {
+ item {
+ Text(
+ extensionOperationFailedMessage,
+ color = TvColors.Danger,
+ fontSize = 16.sp,
+ modifier = Modifier.semantics {
+ error(extensionOperationFailedMessage)
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ }
+ }
+ item(key = "extensions:developer-mode") {
+ val pluginFocusTarget =
+ returnFocusTarget as? TvStatusReturnFocusTarget.Plugin
+ val restoresPluginMutation = pluginFocusTarget?.let { target ->
+ tvExtensionPluginReturnFocusAnchor(
+ action = target.action,
+ panelCancelled = pluginPanelCancelled,
+ ) ==
+ TvExtensionPluginReturnFocusAnchor.DEVELOPER_MODE
+ } == true
+ TvActionButton(
+ text = stringResource(
+ if (state.externalExtensionsEnabled) {
+ string.tv_extensions_disable_developer_mode
+ } else {
+ string.tv_extensions_enable_developer_mode
+ }
+ ),
+ icon = if (state.externalExtensionsEnabled) Icons.Rounded.Block else Icons.Rounded.Extension,
+ checked = state.externalExtensionsEnabled,
+ semanticRole = Role.Switch,
+ semanticsLabel = stringResource(string.feat_setting_external_extensions),
+ focusRequester = returnFocusRequester.takeIf {
+ restoresPluginMutation
+ },
+ onClick = { onExternalExtensionsEnabled(!state.externalExtensionsEnabled) },
)
}
+ if (state.externalExtensionsEnabled && state.extensionPlugins.isEmpty()) {
+ item { Text(stringResource(string.feat_setting_extension_no_plugins), color = TvColors.TextSecondary) }
+ }
+ if (state.externalExtensionsEnabled) {
+ items(
+ items = state.extensionPlugins,
+ key = { plugin -> "${plugin.packageName}/${plugin.serviceName}" },
+ ) { plugin ->
+ val pluginFocusTarget = (returnFocusTarget as? TvStatusReturnFocusTarget.Plugin)
+ ?.takeIf { target ->
+ target.packageName == plugin.packageName &&
+ target.serviceName == plugin.serviceName
+ }
+ fun setPluginReturnFocus(action: TvExtensionPluginAction) {
+ pluginPanelCancelled = false
+ returnFocusTarget = TvStatusReturnFocusTarget.Plugin(
+ packageName = plugin.packageName,
+ serviceName = plugin.serviceName,
+ action = action,
+ )
+ }
+ val restoresSourceAction = pluginFocusTarget?.let { target ->
+ tvExtensionPluginReturnFocusAnchor(
+ action = target.action,
+ panelCancelled = pluginPanelCancelled,
+ ) ==
+ TvExtensionPluginReturnFocusAnchor.SOURCE_ACTION
+ } == true
+ ExtensionPluginCard(
+ plugin = plugin,
+ restoreFocusAction = pluginFocusTarget?.action.takeIf {
+ restoresSourceAction
+ },
+ restoreFocusRequester = returnFocusRequester.takeIf {
+ restoresSourceAction
+ },
+ onEnable = {
+ setPluginReturnFocus(TvExtensionPluginAction.ENABLE)
+ pendingTrust = plugin
+ },
+ onReauthorize = {
+ setPluginReturnFocus(TvExtensionPluginAction.REAUTHORIZE)
+ pendingReauthorization = true
+ pendingTrust = plugin
+ },
+ onDisable = {
+ setPluginReturnFocus(TvExtensionPluginAction.DISABLE)
+ restoreWithoutPanelRequested = true
+ plugin.extensionId?.let(onDisableExtension)
+ },
+ onRevoke = {
+ setPluginReturnFocus(TvExtensionPluginAction.REVOKE)
+ pendingRevoke = plugin
+ },
+ onOpenSettings = {
+ setPluginReturnFocus(TvExtensionPluginAction.SETTINGS)
+ plugin.extensionId?.let(onOpenExtensionSettings)
+ },
+ onClearData = {
+ setPluginReturnFocus(TvExtensionPluginAction.CLEAR_DATA)
+ pendingClear = plugin
+ },
+ onExportDiagnostics = {
+ plugin.extensionId?.let(onExportExtensionDiagnostics)
+ },
+ )
+ }
+ }
}
}
@Composable
-private fun ChannelGrid(
- channels: List,
- onPlay: (Channel) -> Unit,
- modifier: Modifier = Modifier.fillMaxSize(),
- firstItemFocusRequester: FocusRequester? = null
+private fun ProviderSubscriptionPanel(
+ form: ProviderSubscriptionForm,
+ providerName: String,
+ variants: List>,
+ title: String,
+ inProgress: Boolean,
+ providerAvailability: TvProviderFormAvailability,
+ feedback: TvProviderSubscriptionFeedback?,
+ onClose: () -> Unit,
+ onTitleChange: (String) -> Unit,
+ onKind: (String) -> Unit,
+ onSetting: (String, String?) -> Unit,
+ onRetry: () -> Unit,
+ onSubmit: () -> Unit,
) {
- LazyVerticalGrid(
- columns = GridCells.Adaptive(168.dp),
- horizontalArrangement = Arrangement.spacedBy(16.dp),
+ val bidiFormatter = rememberTvBidiFormatter()
+ val initialFocusRequester = remember { FocusRequester() }
+ val titleField = ExtensionSettingField(
+ key = "playlist_title",
+ label = stringResource(string.feat_setting_placeholder_title),
+ type = ExtensionSettingType.TEXT,
+ required = true,
+ )
+ LaunchedEffect(form.providerId, form.providerKind) {
+ repeat(2) { withFrameNanos { } }
+ initialFocusRequester.requestFocus()
+ }
+ Column(
+ modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
- contentPadding = PaddingValues(bottom = 32.dp),
- modifier = modifier.focusGroup()
) {
- itemsIndexed(channels, key = { _, channel -> channel.id }) { index, channel ->
- ChannelCard(
- channel = channel,
- onPlay = { onPlay(channel) },
- focusRequester = firstItemFocusRequester.takeIf { index == 0 }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Top,
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = bidiFormatter.natural(providerName),
+ color = TvColors.TextPrimary,
+ fontSize = 24.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = variants.firstOrNull { (kind, _) -> kind == form.providerKind.value }
+ ?.second
+ ?.let(bidiFormatter::natural)
+ .orEmpty(),
+ color = TvColors.TextSecondary,
+ fontSize = 14.sp,
+ )
+ }
+ TvActionButton(
+ text = stringResource(android.R.string.cancel),
+ icon = Icons.Rounded.Block,
+ enabled = !inProgress,
+ onClick = onClose,
+ )
+ }
+ if (variants.size > 1) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.selectableGroup(),
+ ) {
+ variants.forEach { (kind, label) ->
+ TvActionButton(
+ text = bidiFormatter.natural(label),
+ icon = if (kind == form.providerKind.value) {
+ Icons.Rounded.CheckCircle
+ } else {
+ Icons.Rounded.Extension
+ },
+ enabled = !inProgress &&
+ providerAvailability == TvProviderFormAvailability.AVAILABLE,
+ selected = kind == form.providerKind.value,
+ semanticRole = Role.RadioButton,
+ semanticsLabel = bidiFormatter.natural(label),
+ onClick = { onKind(kind) },
+ )
+ }
+ }
+ }
+ val titleError = if (
+ feedback == TvProviderSubscriptionFeedback.InvalidSettings && title.isBlank()
+ ) {
+ providerFieldErrorMessage(ProviderSettingFieldError.REQUIRED)
+ } else {
+ null
+ }
+ TvExtensionSettingControl(
+ field = titleField,
+ rawValue = title,
+ secretConfigured = false,
+ focusRequester = initialFocusRequester,
+ accessibilityError = titleError,
+ onDraftChange = onTitleChange,
+ onUpdate = { value -> onTitleChange(value.orEmpty()) },
+ )
+ titleError?.let { ProviderFieldError(it) }
+ form.fields.forEach { field ->
+ val fieldError = field.error?.let { providerFieldErrorMessage(it) }
+ TvExtensionSettingControl(
+ field = field.definition,
+ rawValue = field.input ?: field.value.orEmpty(),
+ secretConfigured = false,
+ focusRequester = null,
+ accessibilityError = fieldError,
+ onDraftChange = { value -> onSetting(field.definition.key, value) },
+ onUpdate = { value -> onSetting(field.definition.key, value) },
)
+ if (field.isUsingDefault) {
+ Text(
+ text = stringResource(string.feat_setting_provider_value_default),
+ color = TvColors.TextSecondary,
+ fontSize = 14.sp,
+ )
+ }
+ fieldError?.let { ProviderFieldError(it) }
+ }
+ when (providerAvailability) {
+ TvProviderFormAvailability.AVAILABLE -> Unit
+
+ TvProviderFormAvailability.LOADING -> {
+ Text(
+ text = stringResource(string.feat_setting_provider_discovery_loading),
+ color = TvColors.TextSecondary,
+ fontSize = 16.sp,
+ modifier = Modifier.semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ }
+
+ TvProviderFormAvailability.UNAVAILABLE -> {
+ Text(
+ text = stringResource(string.feat_setting_provider_selected_unavailable),
+ color = TvColors.Danger,
+ fontSize = 16.sp,
+ modifier = Modifier.semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ TvActionButton(
+ text = stringResource(string.feat_setting_provider_discovery_retry),
+ icon = Icons.Rounded.Refresh,
+ enabled = !inProgress,
+ onClick = onRetry,
+ )
+ }
}
+ feedback?.let { ProviderSubscriptionFeedback(it) }
+ TvActionButton(
+ text = stringResource(
+ if (inProgress) {
+ string.feat_setting_label_subscribing
+ } else {
+ string.feat_setting_label_subscribe
+ }
+ ),
+ icon = Icons.Rounded.CheckCircle,
+ enabled = tvProviderSubmitEnabled(inProgress, providerAvailability),
+ focusableWhenDisabled = inProgress,
+ onClick = onSubmit,
+ )
}
}
@Composable
-private fun EmptyLibraryScreen() {
- Row(
- horizontalArrangement = Arrangement.spacedBy(48.dp),
- verticalAlignment = Alignment.CenterVertically,
+private fun ProviderReauthenticationCard(
+ account: ProviderAccountSummary,
+ focusRequester: FocusRequester?,
+ onReauthenticate: () -> Unit,
+) {
+ val bidiFormatter = rememberTvBidiFormatter()
+ Column(
+ verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
- .height(360.dp)
+ .border(1.dp, TvColors.Danger, RoundedCornerShape(12.dp))
+ .padding(16.dp),
) {
- Column(
- verticalArrangement = Arrangement.spacedBy(16.dp),
- modifier = Modifier.weight(1f)
- ) {
+ Text(
+ text = stringResource(
+ string.feat_setting_provider_reauthentication_required,
+ bidiFormatter.natural(account.playlistTitle),
+ ),
+ color = TvColors.TextPrimary,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = stringResource(
+ string.feat_setting_provider_account_summary,
+ bidiFormatter.natural(account.serverName),
+ bidiFormatter.natural(account.username),
+ bidiFormatter.ltr(account.baseUrl),
+ ),
+ color = TvColors.TextSecondary,
+ fontSize = 14.sp,
+ )
+ if (account.requiresExtensionOwnerConfirmation) {
Text(
- text = stringResource(string.tv_home_title),
- color = TvColors.TextPrimary,
- fontSize = 48.sp,
- fontWeight = FontWeight.Bold,
- fontFamily = TvFonts.Body,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
+ text = stringResource(string.feat_setting_provider_owner_claim_notice),
+ color = TvColors.TextSecondary,
+ fontSize = 14.sp,
)
- Text(
- text = stringResource(string.tv_empty_library_title),
- color = TvColors.TextPrimary,
- fontSize = 28.sp,
- fontWeight = FontWeight.SemiBold,
- fontFamily = TvFonts.Body,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
+ }
+ TvActionButton(
+ text = stringResource(string.feat_setting_provider_reauthenticate),
+ icon = Icons.Rounded.Refresh,
+ focusRequester = focusRequester,
+ onClick = onReauthenticate,
+ )
+ }
+}
+
+@Composable
+private fun ProviderSubscriptionFeedback(feedback: TvProviderSubscriptionFeedback) {
+ val (text, color) = when (feedback) {
+ TvProviderSubscriptionFeedback.InvalidSettings ->
+ stringResource(string.feat_setting_provider_credentials_required) to TvColors.Danger
+
+ TvProviderSubscriptionFeedback.Failed ->
+ stringResource(string.feat_setting_provider_subscription_failed) to TvColors.Danger
+
+ is TvProviderSubscriptionFeedback.Added ->
+ stringResource(string.feat_setting_provider_added) to TvColors.Focus
+ }
+ Text(
+ text = text,
+ color = color,
+ fontSize = 16.sp,
+ modifier = Modifier.semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+}
+
+@Composable
+private fun providerFieldErrorMessage(error: ProviderSettingFieldError): String =
+ stringResource(
+ when (error) {
+ ProviderSettingFieldError.REQUIRED -> string.feat_setting_provider_error_required
+ ProviderSettingFieldError.TOO_LONG -> string.feat_setting_provider_error_too_long
+ ProviderSettingFieldError.INVALID_NUMBER -> string.feat_setting_provider_error_number
+ ProviderSettingFieldError.INVALID_BOOLEAN -> string.feat_setting_provider_error_boolean
+ ProviderSettingFieldError.INVALID_CHOICE -> string.feat_setting_provider_error_choice
+ ProviderSettingFieldError.UNSAFE_VALUE ->
+ string.feat_setting_provider_error_unsafe_value
+ }
+ )
+
+@Composable
+private fun extensionSettingFieldErrorMessage(error: ExtensionSettingInputError): String =
+ stringResource(
+ when (error) {
+ ExtensionSettingInputError.REQUIRED -> string.feat_setting_provider_error_required
+ ExtensionSettingInputError.TOO_LONG -> string.feat_setting_provider_error_too_long
+ ExtensionSettingInputError.INVALID_NUMBER -> string.feat_setting_provider_error_number
+ ExtensionSettingInputError.INVALID_BOOLEAN ->
+ string.feat_setting_provider_error_boolean
+ ExtensionSettingInputError.INVALID_CHOICE -> string.feat_setting_provider_error_choice
+ ExtensionSettingInputError.INVALID_NETWORK_ORIGIN ->
+ string.feat_setting_extension_error_network_origin
+ }
+ )
+
+@Composable
+private fun ProviderFieldError(message: String) {
+ Text(
+ text = message,
+ color = TvColors.Danger,
+ fontSize = 14.sp,
+ modifier = Modifier.semantics {
+ error(message)
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+}
+
+@Composable
+private fun ExtensionAuthorizationConfirmation(
+ plugin: InstalledPlugin,
+ reauthorization: Boolean,
+ onConfirm: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ val bidiFormatter = rememberTvBidiFormatter()
+ val cancelFocusRequester = remember { FocusRequester() }
+ val listState = rememberLazyListState()
+ val identityText = stringResource(
+ string.feat_setting_extension_confirm_identity,
+ bidiFormatter.ltr(plugin.packageName),
+ bidiFormatter.ltr(
+ plugin.certificateSha256.chunked(16).joinToString(" ")
+ ),
+ bidiFormatter.natural(plugin.displayName.orEmpty()),
+ bidiFormatter.natural(plugin.developer.orEmpty()),
+ bidiFormatter.ltr(plugin.version.orEmpty()),
+ )
+ val identitySegments = remember(identityText) {
+ identityText.lines()
+ }
+ val certificateRepinText = plugin.previousCertificateSha256?.let { previousCertificate ->
+ stringResource(
+ string.feat_setting_extension_certificate_repin,
+ bidiFormatter.ltr(previousCertificate.chunked(16).joinToString(" ")),
+ bidiFormatter.ltr(
+ plugin.certificateSha256.chunked(16).joinToString(" ")
+ ),
+ )
+ }
+ val certificateRepinSegments = remember(certificateRepinText) {
+ certificateRepinText
+ ?.lines()
+ .orEmpty()
+ }
+ val requiredCapabilityLabel =
+ stringResource(string.feat_setting_extension_capability_required)
+ val optionalCapabilityLabel =
+ stringResource(string.feat_setting_extension_capability_optional)
+ val capabilityTitles = plugin.capabilityPermissions.associate { permission ->
+ val capabilityName = tvExtensionCapabilityNameResource(permission.id)
+ ?.let { resource -> stringResource(resource) }
+ ?.let(bidiFormatter::natural)
+ ?: bidiFormatter.ltr(permission.id)
+ val requirement = if (permission.required) {
+ requiredCapabilityLabel
+ } else {
+ optionalCapabilityLabel
+ }
+ permission.id to "$capabilityName ($requirement)"
+ }
+ val capabilitySegments = remember(
+ plugin.capabilityPermissions,
+ capabilityTitles,
+ bidiFormatter,
+ ) {
+ plugin.capabilityPermissions.flatMap { permission ->
+ val title = checkNotNull(capabilityTitles[permission.id])
+ permission.reason.tvReadableSegments().map { reason ->
+ title to bidiFormatter.natural(reason)
+ }
+ }
+ }
+ val networkOriginSegments = remember(plugin.networkOrigins, bidiFormatter) {
+ plugin.networkOrigins.sorted().flatMap { origin ->
+ origin.tvReadableSegments().map(bidiFormatter::ltr)
+ }
+ }
+ val networkOriginSettingsText =
+ if (plugin.networkOriginSettingFields.isNotEmpty()) {
+ stringResource(
+ string.feat_setting_extension_network_origin_settings,
+ plugin.networkOriginSettingFields.sorted()
+ .joinToString(transform = bidiFormatter::ltr),
+ )
+ } else {
+ null
+ }
+ val networkOriginSettingsSegments = remember(
+ networkOriginSettingsText,
+ bidiFormatter,
+ ) {
+ networkOriginSettingsText?.let(::listOf).orEmpty()
+ }
+ BackHandler(onBack = onCancel)
+ LaunchedEffect(
+ plugin.packageName,
+ plugin.serviceName,
+ plugin.certificateSha256,
+ reauthorization,
+ ) {
+ listState.scrollToItem(0)
+ repeat(2) { withFrameNanos { } }
+ cancelFocusRequester.requestFocus()
+ }
+ LazyColumn(
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ contentPadding = PaddingValues(start = 48.dp, top = 48.dp, end = 64.dp, bottom = 48.dp),
+ modifier = Modifier.fillMaxSize().focusGroup(),
+ ) {
+ item {
+ TvReadableConfirmationText(
+ text = stringResource(string.feat_setting_extension_confirm_title),
+ color = TvColors.TextPrimary,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ item {
+ TvActionButton(
+ text = stringResource(android.R.string.cancel),
+ icon = Icons.Rounded.Block,
+ focusRequester = cancelFocusRequester,
+ onClick = onCancel,
+ )
+ }
+ items(identitySegments) { identitySegment ->
+ TvReadableConfirmationText(
+ text = identitySegment,
+ color = TvColors.TextSecondary,
+ fontSize = 16.sp,
+ )
+ }
+ items(certificateRepinSegments) { certificateSegment ->
+ TvReadableConfirmationText(
+ text = certificateSegment,
+ color = TvColors.Danger,
+ fontSize = 14.sp,
+ )
+ }
+ item {
+ TvReadableConfirmationText(
+ text = stringResource(string.feat_setting_extension_requested_capabilities),
+ color = TvColors.TextPrimary,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ if (plugin.capabilityPermissions.isEmpty()) {
+ item {
+ TvReadableConfirmationText(
+ text = "—",
+ color = TvColors.TextSecondary,
+ fontSize = 16.sp,
+ )
+ }
+ } else {
+ items(capabilitySegments) { (title, reason) ->
+ TvReadableConfirmationBlock {
+ Text(
+ text = title,
+ color = TvColors.TextPrimary,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ reason,
+ color = TvColors.TextSecondary,
+ fontSize = 14.sp,
+ )
+ }
+ }
+ }
+ item {
+ TvReadableConfirmationText(
+ text = stringResource(string.feat_setting_extension_network_origins),
+ color = TvColors.TextPrimary,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ if (plugin.networkOrigins.isEmpty()) {
+ item {
+ TvReadableConfirmationText(
+ text = "—",
+ color = TvColors.TextSecondary,
+ fontSize = 16.sp,
+ )
+ }
+ } else {
+ items(networkOriginSegments) { originSegment ->
+ TvReadableConfirmationText(
+ text = originSegment,
+ color = TvColors.TextSecondary,
+ fontSize = 16.sp,
+ )
+ }
+ }
+ items(networkOriginSettingsSegments) { settingsSegment ->
+ TvReadableConfirmationText(
+ text = settingsSegment,
+ color = TvColors.TextSecondary,
+ fontSize = 14.sp,
+ )
+ }
+ item {
+ TvActionButton(
+ text = stringResource(
+ if (reauthorization) {
+ string.feat_setting_extension_reauthorize
+ } else {
+ string.feat_setting_extension_enable
+ }
+ ),
+ icon = Icons.Rounded.CheckCircle,
+ onClick = onConfirm,
+ )
+ }
+ }
+}
+
+@Composable
+private fun extensionStateLabel(state: ExtensionState): String = stringResource(
+ when (state) {
+ ExtensionState.ENABLED -> string.feat_setting_extension_state_enabled
+ ExtensionState.DISABLED -> string.feat_setting_extension_state_disabled
+ ExtensionState.INCOMPATIBLE -> string.feat_setting_extension_state_incompatible
+ ExtensionState.UNHEALTHY -> string.feat_setting_extension_state_unhealthy
+ }
+)
+
+private fun InstalledPlugin.tvActionAvailability() =
+ extensionPluginActionAvailability(
+ enabled = enabled,
+ state = state,
+ hasExtensionId = extensionId != null,
+ installed = installed,
+ signatureChanged = signatureChanged,
+ hasInspectionError = inspectionError != null,
+ hasAuthorizationToken = authorizationToken != null,
+ trusted = trusted,
+ canClearData = canClearData,
+ )
+
+@Composable
+private fun ExtensionPluginCard(
+ plugin: InstalledPlugin,
+ restoreFocusAction: TvExtensionPluginAction?,
+ restoreFocusRequester: FocusRequester?,
+ onEnable: () -> Unit,
+ onReauthorize: () -> Unit,
+ onDisable: () -> Unit,
+ onRevoke: () -> Unit,
+ onOpenSettings: () -> Unit,
+ onClearData: () -> Unit,
+ onExportDiagnostics: () -> Unit,
+) {
+ val bidiFormatter = rememberTvBidiFormatter()
+ val actions = plugin.tvActionAvailability()
+ val semanticPluginName = plugin.displayName?.let(bidiFormatter::natural)
+ ?: bidiFormatter.ltr(plugin.packageName)
+ val settingsActionLabel = stringResource(string.feat_setting_extension_settings)
+ val disableActionLabel = stringResource(string.feat_setting_extension_disable)
+ val enableActionLabel = stringResource(string.feat_setting_extension_enable)
+ val revokeActionLabel = stringResource(string.feat_setting_extension_revoke)
+ val reauthorizeActionLabel = stringResource(string.feat_setting_extension_reauthorize)
+ val exportDiagnosticsActionLabel =
+ stringResource(string.feat_setting_extension_export_diagnostics)
+ val clearDataActionLabel = stringResource(string.feat_setting_extension_clear_data)
+ val context = LocalContext.current
+ fun actionDescription(action: String): String = context.getString(
+ string.feat_setting_extension_action_field_description,
+ action,
+ semanticPluginName,
+ )
+ val focusAction = restoreFocusAction?.takeIf(actions::isActionAvailable)
+ fun focusRequesterFor(action: TvExtensionPluginAction): FocusRequester? =
+ restoreFocusRequester.takeIf { focusAction == action }
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = plugin.displayName?.let(bidiFormatter::natural)
+ ?: bidiFormatter.ltr(plugin.packageName),
+ color = TvColors.TextPrimary,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = listOfNotNull(
+ plugin.developer?.let(bidiFormatter::natural),
+ plugin.version?.let { bidiFormatter.ltr("v$it") },
+ ).joinToString(" · "),
+ color = TvColors.TextSecondary,
+ fontSize = 14.sp,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = extensionStateLabel(plugin.state),
+ color = TvColors.TextSecondary,
+ fontSize = 14.sp,
+ maxLines = 1,
+ )
+ Text(
+ bidiFormatter.ltr(plugin.certificateSha256),
+ color = TvColors.TextMuted,
+ fontSize = 12.sp,
+ )
+ if (plugin.signatureChanged) {
+ TvExtensionPluginErrorText(
+ stringResource(string.feat_setting_extension_signature_changed)
+ )
+ }
+ val unapprovedNetworkOrigins = plugin.networkOrigins - plugin.approvedNetworkOrigins
+ if (plugin.trusted && unapprovedNetworkOrigins.isNotEmpty()) {
+ TvExtensionPluginErrorText(
+ stringResource(
+ string.feat_setting_extension_network_reauthorization_required,
+ unapprovedNetworkOrigins.sorted()
+ .joinToString(transform = bidiFormatter::ltr),
+ )
+ )
+ }
+ plugin.inspectionError?.let {
+ TvExtensionPluginErrorText(
+ stringResource(string.feat_setting_extension_inspection_failed)
+ )
+ }
+ if (!plugin.installed) {
+ TvExtensionPluginErrorText(
+ stringResource(string.feat_setting_extension_not_installed)
+ )
+ }
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(20.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (actions.settings) {
+ TvActionButton(
+ text = settingsActionLabel,
+ icon = Icons.Rounded.Extension,
+ semanticsLabel = actionDescription(settingsActionLabel),
+ focusRequester = focusRequesterFor(TvExtensionPluginAction.SETTINGS),
+ onClick = onOpenSettings,
+ )
+ }
+ if (actions.disable) {
+ TvActionButton(
+ text = disableActionLabel,
+ icon = Icons.Rounded.Block,
+ semanticsLabel = actionDescription(disableActionLabel),
+ focusRequester = focusRequesterFor(TvExtensionPluginAction.DISABLE),
+ onClick = onDisable,
+ )
+ }
+ if (actions.enable) {
+ TvActionButton(
+ text = enableActionLabel,
+ icon = Icons.Rounded.CheckCircle,
+ semanticsLabel = actionDescription(enableActionLabel),
+ focusRequester = focusRequesterFor(TvExtensionPluginAction.ENABLE),
+ onClick = onEnable,
+ )
+ }
+ if (actions.revoke) {
+ TvActionButton(
+ text = revokeActionLabel,
+ icon = Icons.Rounded.Block,
+ semanticsLabel = actionDescription(revokeActionLabel),
+ focusRequester = focusRequesterFor(TvExtensionPluginAction.REVOKE),
+ onClick = onRevoke,
+ )
+ }
+ if (actions.reauthorize) {
+ TvActionButton(
+ text = reauthorizeActionLabel,
+ icon = Icons.Rounded.CheckCircle,
+ semanticsLabel = actionDescription(reauthorizeActionLabel),
+ focusRequester = focusRequesterFor(TvExtensionPluginAction.REAUTHORIZE),
+ onClick = onReauthorize,
+ )
+ }
+ if (actions.exportDiagnostics) {
+ TvActionButton(
+ text = exportDiagnosticsActionLabel,
+ icon = Icons.Rounded.Refresh,
+ semanticsLabel = actionDescription(exportDiagnosticsActionLabel),
+ focusRequester = focusRequesterFor(
+ TvExtensionPluginAction.EXPORT_DIAGNOSTICS
+ ),
+ onClick = onExportDiagnostics,
+ )
+ }
+ if (actions.clearData) {
+ TvActionButton(
+ text = clearDataActionLabel,
+ icon = Icons.Rounded.Block,
+ semanticsLabel = actionDescription(clearDataActionLabel),
+ focusRequester = focusRequesterFor(TvExtensionPluginAction.CLEAR_DATA),
+ onClick = onClearData,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TvExtensionPluginErrorText(message: String) {
+ Text(
+ text = message,
+ color = TvColors.Danger,
+ fontSize = 14.sp,
+ modifier = Modifier.semantics {
+ error(message)
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+}
+
+@Composable
+private fun ExtensionClearConfirmation(
+ plugin: InstalledPlugin,
+ onConfirm: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ ExtensionDataRemovalConfirmation(
+ plugin = plugin,
+ title = stringResource(string.feat_setting_extension_clear_data_title),
+ body = stringResource(string.feat_setting_extension_clear_data_body),
+ confirmLabel = stringResource(string.feat_setting_extension_clear_data),
+ onConfirm = onConfirm,
+ onCancel = onCancel,
+ )
+}
+
+@Composable
+private fun ExtensionForgetConfirmation(
+ plugin: InstalledPlugin,
+ onConfirm: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ ExtensionDataRemovalConfirmation(
+ plugin = plugin,
+ title = stringResource(string.feat_setting_extension_forget_title),
+ body = stringResource(string.feat_setting_extension_forget_body),
+ confirmLabel = stringResource(string.feat_setting_extension_revoke),
+ onConfirm = onConfirm,
+ onCancel = onCancel,
+ )
+}
+
+@Composable
+private fun ExtensionDataRemovalConfirmation(
+ plugin: InstalledPlugin,
+ title: String,
+ body: String,
+ confirmLabel: String,
+ onConfirm: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ val bidiFormatter = rememberTvBidiFormatter()
+ BackHandler(onBack = onCancel)
+ val cancelFocusRequester = remember { FocusRequester() }
+ val listState = rememberLazyListState()
+ val focusScrollClearancePx = with(LocalDensity.current) { 28.dp.roundToPx() }
+ val pluginNameSegments = remember(
+ plugin.displayName,
+ plugin.packageName,
+ bidiFormatter,
+ ) {
+ buildList {
+ plugin.displayName?.let { displayName ->
+ addAll(displayName.tvReadableSegments(bidiFormatter::natural))
+ }
+ addAll(plugin.packageName.tvReadableSegments(bidiFormatter::ltr))
+ }
+ }
+ val bodySegments = remember(body) {
+ body.tvReadableSegments()
+ }
+ val actionItemIndex = 1 + pluginNameSegments.size + bodySegments.size
+ LaunchedEffect(
+ plugin.packageName,
+ plugin.serviceName,
+ title,
+ actionItemIndex,
+ focusScrollClearancePx,
+ ) {
+ listState.scrollToItem(
+ actionItemIndex,
+ scrollOffset = -focusScrollClearancePx,
+ )
+ repeat(2) { withFrameNanos { } }
+ cancelFocusRequester.requestFocus()
+ }
+ LazyColumn(
+ state = listState,
+ modifier = Modifier
+ .fillMaxSize()
+ .focusGroup(),
+ contentPadding = PaddingValues(
+ start = 48.dp,
+ top = 48.dp,
+ end = 64.dp,
+ bottom = 72.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ item {
+ TvReadableConfirmationText(
+ text = title,
+ color = TvColors.TextPrimary,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ items(pluginNameSegments) { pluginNameSegment ->
+ TvReadableConfirmationText(
+ text = pluginNameSegment,
+ color = TvColors.TextPrimary,
+ fontSize = 20.sp,
+ )
+ }
+ items(bodySegments) { bodySegment ->
+ TvReadableConfirmationText(
+ text = bodySegment,
+ color = TvColors.TextSecondary,
+ fontSize = 16.sp,
+ )
+ }
+ item {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ TvActionButton(
+ text = stringResource(android.R.string.cancel),
+ icon = Icons.Rounded.CheckCircle,
+ focusRequester = cancelFocusRequester,
+ onClick = onCancel,
+ )
+ TvActionButton(
+ text = confirmLabel,
+ icon = Icons.Rounded.Block,
+ onClick = onConfirm,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TvReadableConfirmationText(
+ text: String,
+ color: Color,
+ fontSize: TextUnit,
+ fontWeight: FontWeight? = null,
+) {
+ var focused by remember { mutableStateOf(false) }
+ val shape = RoundedCornerShape(12.dp)
+ Text(
+ text = text,
+ color = color,
+ fontSize = fontSize,
+ fontWeight = fontWeight,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = if (focused) Color.White.copy(alpha = 0.10f) else Color.Transparent,
+ shape = shape,
+ )
+ .border(
+ width = if (focused) 3.dp else 1.dp,
+ color = if (focused) Color.White else Color.White.copy(alpha = 0.08f),
+ shape = shape,
+ )
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .semantics(mergeDescendants = true) {}
+ .onFocusChanged { focused = it.isFocused }
+ .focusable(),
+ )
+}
+
+@Composable
+private fun TvReadableConfirmationBlock(
+ content: @Composable () -> Unit,
+) {
+ var focused by remember { mutableStateOf(false) }
+ val shape = RoundedCornerShape(12.dp)
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = if (focused) Color.White.copy(alpha = 0.10f) else Color.Transparent,
+ shape = shape,
)
+ .border(
+ width = if (focused) 3.dp else 1.dp,
+ color = if (focused) Color.White else Color.White.copy(alpha = 0.08f),
+ shape = shape,
+ )
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .semantics(mergeDescendants = true) {}
+ .onFocusChanged { focused = it.isFocused }
+ .focusable(),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ content()
+ }
+}
+
+@Composable
+private fun ExtensionSettingsPanel(
+ configuration: ExtensionSettingsConfiguration,
+ operationError: String?,
+ onClose: () -> Unit,
+ onUpdate: (
+ sectionId: String,
+ fieldKey: String,
+ editToken: ExtensionSettingEditToken,
+ rawValue: String?,
+ ) -> Unit,
+) {
+ val bidiFormatter = rememberTvBidiFormatter()
+ val initialFocusRequester = remember { FocusRequester() }
+ val firstFieldKey = configuration.sections.firstNotNullOfOrNull { section ->
+ section.schema.fields.firstOrNull()?.let { field ->
+ ExtensionSettingKeys.qualified(section.id, field.key)
+ }
+ }
+ val draftValues = remember(configuration.extensionId) {
+ mutableStateMapOf().apply {
+ configuration.sections.forEach { section ->
+ section.schema.fields.forEach { field ->
+ val key = ExtensionSettingKeys.qualified(section.id, field.key)
+ if (field.type != ExtensionSettingType.SECRET) {
+ put(key, configuration.snapshot.values[key].tvPrimitiveContent())
+ }
+ }
+ }
+ }
+ }
+ val validationRequested = remember(configuration.extensionId) {
+ mutableStateMapOf()
+ }
+ val dirtyKeys = remember(configuration.extensionId) {
+ mutableStateMapOf()
+ }
+ LaunchedEffect(configuration) {
+ val activeKeys = mutableSetOf()
+ configuration.sections.forEach { section ->
+ section.schema.fields.forEach { field ->
+ val key = ExtensionSettingKeys.qualified(section.id, field.key)
+ activeKeys += key
+ if (dirtyKeys[key] != true) {
+ draftValues[key] = if (field.type == ExtensionSettingType.SECRET) {
+ ""
+ } else {
+ configuration.snapshot.values[key].tvPrimitiveContent()
+ }
+ }
+ }
+ }
+ draftValues.keys.retainAll(activeKeys)
+ validationRequested.keys.retainAll(activeKeys)
+ dirtyKeys.keys.retainAll(activeKeys)
+ }
+ LaunchedEffect(configuration.extensionId) {
+ repeat(2) { withFrameNanos { } }
+ initialFocusRequester.requestFocus()
+ }
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .focusGroup(),
+ contentPadding = PaddingValues(
+ start = 48.dp,
+ top = 48.dp,
+ end = 64.dp,
+ bottom = 48.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ item(key = "extension-settings:header") {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Top,
+ ) {
+ Text(
+ text = stringResource(string.feat_setting_extension_settings),
+ color = TvColors.TextPrimary,
+ fontSize = 24.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 16.dp),
+ )
+ TvActionButton(
+ text = stringResource(android.R.string.cancel),
+ icon = Icons.Rounded.Block,
+ focusRequester = initialFocusRequester.takeIf { firstFieldKey == null },
+ onClick = onClose,
+ )
+ }
+ }
+ if (configuration.sections.isEmpty()) {
+ item(key = "extension-settings:empty") {
+ Text(
+ stringResource(string.feat_setting_extension_settings_empty),
+ color = TvColors.TextSecondary,
+ )
+ }
+ }
+ operationError?.let { message ->
+ item(key = "extension-settings:error") {
+ Text(
+ text = message,
+ color = TvColors.Danger,
+ fontSize = 16.sp,
+ modifier = Modifier.semantics {
+ error(message)
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+ }
+ }
+ configuration.sections.forEach { section ->
+ item(key = "extension-settings:section:${section.id}") {
+ Text(
+ text = bidiFormatter.natural(section.title),
+ color = TvColors.TextPrimary,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ items(
+ items = section.schema.fields,
+ key = { field ->
+ "extension-settings:field:" +
+ ExtensionSettingKeys.qualified(section.id, field.key)
+ },
+ ) { field ->
+ val key = ExtensionSettingKeys.qualified(section.id, field.key)
+ val editToken = checkNotNull(
+ configuration.editToken(section.id, field.key)
+ )
+ val inputError = field.extensionSettingInputError(
+ rawValue = draftValues[key].orEmpty(),
+ secretConfigured = key in configuration.snapshot.credentialHandles,
+ )
+ val accessibilityError = inputError
+ .takeIf {
+ validationRequested[key] == true ||
+ field.type == ExtensionSettingType.SINGLE_CHOICE
+ }
+ ?.let { error -> extensionSettingFieldErrorMessage(error) }
+ TvExtensionSettingControl(
+ field = field,
+ rawValue = draftValues[key].orEmpty(),
+ secretConfigured = key in configuration.snapshot.credentialHandles,
+ focusRequester = initialFocusRequester.takeIf { key == firstFieldKey },
+ accessibilityError = accessibilityError,
+ onDraftChange = { value ->
+ draftValues[key] = value
+ dirtyKeys[key] = true
+ },
+ onUpdate = update@{ value ->
+ if (
+ value != null &&
+ field.extensionSettingInputError(
+ rawValue = value,
+ secretConfigured =
+ key in configuration.snapshot.credentialHandles,
+ ) != null
+ ) {
+ validationRequested[key] = true
+ return@update
+ }
+ validationRequested[key] = false
+ dirtyKeys.remove(key)
+ draftValues[key] = if (field.type == ExtensionSettingType.SECRET) {
+ ""
+ } else {
+ value.orEmpty()
+ }
+ onUpdate(
+ section.id,
+ field.key,
+ editToken,
+ value
+ ?.takeUnless { it.isEmpty() && !field.required }
+ ?.let(field::normalizedExtensionSettingValue),
+ )
+ },
+ )
+ accessibilityError?.let { message ->
+ ProviderFieldError(message)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TvExtensionSettingControl(
+ field: ExtensionSettingField,
+ rawValue: String,
+ secretConfigured: Boolean,
+ focusRequester: FocusRequester?,
+ accessibilityError: String?,
+ onDraftChange: (String) -> Unit,
+ onUpdate: (String?) -> Unit,
+) {
+ val requiredDescription =
+ stringResource(string.feat_setting_provider_error_required)
+ val saveActionLabel = stringResource(string.feat_setting_extension_setting_save)
+ val clearActionLabel = stringResource(string.feat_setting_extension_setting_clear)
+ val bidiFormatter = rememberTvBidiFormatter()
+ val focusManager = LocalFocusManager.current
+ val semanticFieldLabel = field.label.withoutBidiControls()
+ val context = LocalContext.current
+ val semanticFieldDescription = if (field.required) {
+ context.getString(
+ string.feat_setting_extension_field_required_description,
+ semanticFieldLabel,
+ requiredDescription,
+ )
+ } else {
+ semanticFieldLabel
+ }
+ fun semanticChoiceDescription(choiceLabel: String): String = if (field.required) {
+ context.getString(
+ string.feat_setting_extension_choice_field_required_description,
+ choiceLabel.withoutBidiControls(),
+ semanticFieldLabel,
+ requiredDescription,
+ )
+ } else {
+ context.getString(
+ string.feat_setting_extension_choice_field_description,
+ choiceLabel.withoutBidiControls(),
+ semanticFieldLabel,
+ )
+ }
+ fun semanticActionDescription(actionLabel: String): String = context.getString(
+ string.feat_setting_extension_action_field_description,
+ actionLabel,
+ semanticFieldLabel,
+ )
+ val displayLabel = bidiFormatter.natural(
+ if (field.required) "${field.label} *" else field.label
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = displayLabel,
+ color = TvColors.TextPrimary,
+ fontSize = 16.sp,
+ )
+ field.description?.let { description ->
Text(
- text = stringResource(string.tv_empty_library_subtitle),
+ bidiFormatter.natural(description),
color = TvColors.TextSecondary,
- fontSize = 17.sp,
- lineHeight = 25.sp,
- fontFamily = TvFonts.Body,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.fillMaxWidth(0.82f)
+ fontSize = 14.sp,
)
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
+ }
+ if (field.networkOrigin && accessibilityError == null) {
+ Text(
+ text = stringResource(string.feat_setting_extension_network_origin_save_notice),
+ color = TvColors.Danger,
+ fontSize = 14.sp,
+ )
+ }
+ when (field.type) {
+ ExtensionSettingType.BOOLEAN -> {
+ TvActionButton(
+ text = stringResource(
+ if (rawValue.toBooleanStrictOrNull() == true) {
+ string.feat_setting_extension_state_enabled
+ } else {
+ string.feat_setting_extension_state_disabled
+ }
+ ),
+ icon = Icons.Rounded.CheckCircle,
+ focusRequester = focusRequester,
+ checked = rawValue.toBooleanStrictOrNull() == true,
+ semanticRole = Role.Switch,
+ semanticsLabel = semanticFieldDescription,
+ semanticsError = accessibilityError,
+ onClick = {
+ onUpdate((rawValue.toBooleanStrictOrNull() != true).toString())
+ },
+ )
+ }
+
+ ExtensionSettingType.SINGLE_CHOICE -> {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(20.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.selectableGroup(),
+ ) {
+ field.choices.forEach { choice ->
+ TvActionButton(
+ text = bidiFormatter.natural(choice.label),
+ icon = if (rawValue == choice.value) {
+ Icons.Rounded.CheckCircle
+ } else {
+ Icons.Rounded.Extension
+ },
+ focusRequester = focusRequester.takeIf {
+ choice == field.choices.firstOrNull()
+ },
+ selected = rawValue == choice.value,
+ semanticRole = Role.RadioButton,
+ semanticsLabel = semanticChoiceDescription(choice.label),
+ semanticsError = accessibilityError,
+ onClick = { onUpdate(choice.value) },
+ )
+ }
+ }
+ }
+
+ ExtensionSettingType.TEXT,
+ ExtensionSettingType.NUMBER,
+ ExtensionSettingType.SECRET -> {
+ var focused by remember { mutableStateOf(false) }
+ val actionFocusRequester = remember(field.key) { FocusRequester() }
+ val singleLineInput =
+ field.type != ExtensionSettingType.TEXT || field.networkOrigin
+ if (field.type == ExtensionSettingType.SECRET && secretConfigured) {
+ Text(
+ stringResource(string.feat_setting_extension_secret_configured),
+ color = TvColors.TextSecondary,
+ fontSize = 14.sp,
+ )
+ }
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .onPreviewKeyEvent { event ->
+ if (event.type != KeyEventType.KeyDown) {
+ false
+ } else {
+ when (event.key) {
+ Key.DirectionUp ->
+ focusManager.moveFocus(FocusDirection.Up)
+
+ Key.DirectionDown ->
+ actionFocusRequester.requestFocus()
+
+ else -> false
+ }
+ }
+ },
+ ) {
+ BasicTextField(
+ value = rawValue,
+ onValueChange = onDraftChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 52.dp)
+ .then(
+ focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier
+ )
+ .onFocusChanged { focused = it.isFocused }
+ .semantics {
+ contentDescription = semanticFieldDescription
+ if (field.type == ExtensionSettingType.SECRET) {
+ password()
+ }
+ accessibilityError?.let { message ->
+ error(message)
+ }
+ }
+ .border(
+ width = if (focused) 3.dp else 1.dp,
+ color = if (focused) TvColors.Focus else TvColors.TextMuted,
+ shape = RoundedCornerShape(10.dp),
+ )
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ textStyle = TextStyle(
+ color = TvColors.TextPrimary,
+ fontSize = 16.sp,
+ ),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = when (field.type) {
+ ExtensionSettingType.NUMBER -> KeyboardType.Decimal
+ ExtensionSettingType.SECRET -> KeyboardType.Password
+ else -> KeyboardType.Text
+ },
+ autoCorrectEnabled = false,
+ ),
+ visualTransformation = if (field.type == ExtensionSettingType.SECRET) {
+ PasswordVisualTransformation()
+ } else {
+ VisualTransformation.None
+ },
+ singleLine = singleLineInput,
+ minLines = 1,
+ maxLines = if (singleLineInput) 1 else 4,
+ )
+ }
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(20.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TvActionButton(
+ text = saveActionLabel,
+ icon = Icons.Rounded.CheckCircle,
+ focusRequester = actionFocusRequester,
+ semanticsLabel = semanticActionDescription(saveActionLabel),
+ onClick = { onUpdate(rawValue) },
+ )
+ if (rawValue.isNotEmpty() || secretConfigured) {
+ TvActionButton(
+ text = clearActionLabel,
+ icon = Icons.Rounded.Block,
+ semanticsLabel = semanticActionDescription(clearActionLabel),
+ onClick = { onUpdate(null) },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun Any?.tvPrimitiveContent(): String = when (this) {
+ is JsonPrimitive -> booleanOrNull?.toString() ?: contentOrNull.orEmpty()
+ else -> ""
+}
+
+private fun tvExtensionCapabilityNameResource(capabilityId: String): Int? = when (capabilityId) {
+ ExtensionCapabilityIds.Network.id ->
+ string.feat_setting_extension_capability_name_network
+ ExtensionCapabilityIds.CredentialRead.id ->
+ string.feat_setting_extension_capability_name_credential_read
+ ExtensionCapabilityIds.CredentialWrite.id ->
+ string.feat_setting_extension_capability_name_credential_write
+ ExtensionCapabilityIds.SubscriptionRead.id ->
+ string.feat_setting_extension_capability_name_subscription_read
+ ExtensionCapabilityIds.SubscriptionWrite.id ->
+ string.feat_setting_extension_capability_name_subscription_write
+ ExtensionCapabilityIds.PlaybackResolve.id ->
+ string.feat_setting_extension_capability_name_playback_resolve
+ ExtensionCapabilityIds.EpgRead.id ->
+ string.feat_setting_extension_capability_name_epg_read
+ ExtensionCapabilityIds.MetadataWrite.id ->
+ string.feat_setting_extension_capability_name_metadata_write
+ ExtensionCapabilityIds.SettingsContribute.id ->
+ string.feat_setting_extension_capability_name_settings_contribute
+ ExtensionCapabilityIds.SearchRead.id ->
+ string.feat_setting_extension_capability_name_search_read
+ ExtensionCapabilityIds.BackgroundTask.id ->
+ string.feat_setting_extension_capability_name_background_task
+ else -> null
+}
+
+@Composable
+private fun ContentRow(
+ channels: List,
+ onPlay: (Channel) -> Unit,
+ onFocused: (Channel) -> Unit = {},
+ firstItemFocusRequester: FocusRequester? = null
+) {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ contentPadding = PaddingValues(start = 48.dp, top = 8.dp, end = 48.dp, bottom = 8.dp),
+ modifier = Modifier.focusGroup()
+ ) {
+ itemsIndexed(channels, key = { _, channel -> channel.id }) { index, channel ->
+ ChannelCard(
+ channel = channel,
+ onPlay = { onPlay(channel) },
+ onFocused = { onFocused(channel) },
+ focusRequester = firstItemFocusRequester.takeIf { index == 0 },
+ compact = true,
modifier = Modifier
- .fillMaxWidth(0.72f)
- .widthIn(max = 420.dp)
- ) {
- InfoPill(text = stringResource(string.tv_empty_library_phone_hint), modifier = Modifier.fillMaxWidth())
- InfoPill(text = stringResource(string.tv_empty_library_restore_hint), modifier = Modifier.fillMaxWidth())
+ .widthIn(min = 104.dp, max = 120.dp)
+ .aspectRatio(2f / 3f)
+ )
+ }
+ }
+}
+
+@Composable
+private fun ChannelGrid(
+ channels: List,
+ onPlay: (Channel) -> Unit,
+ modifier: Modifier = Modifier.fillMaxSize(),
+ firstItemFocusRequester: FocusRequester? = null
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(168.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ contentPadding = PaddingValues(bottom = 32.dp),
+ modifier = modifier.focusGroup()
+ ) {
+ itemsIndexed(channels, key = { _, channel -> channel.id }) { index, channel ->
+ ChannelCard(
+ channel = channel,
+ onPlay = { onPlay(channel) },
+ focusRequester = firstItemFocusRequester.takeIf { index == 0 }
+ )
+ }
+ }
+}
+
+@Composable
+private fun EmptyLibraryScreen() {
+ val largeTextLayout = tvLargeTextLayout(LocalDensity.current.fontScale)
+ LazyColumn(
+ verticalArrangement = Arrangement.Center,
+ contentPadding = PaddingValues(start = 48.dp, top = 48.dp, end = 64.dp, bottom = 48.dp),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ item {
+ if (largeTextLayout.stackEmptyLibrary) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(32.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ EmptyLibraryDescription(expandedWidth = true)
+ SetupPanel(
+ minHeight = largeTextLayout.emptySetupMinHeightDp.dp,
+ modifier = Modifier
+ .fillMaxWidth()
+ .widthIn(max = 640.dp),
+ )
+ }
+ } else {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(48.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ EmptyLibraryDescription(
+ expandedWidth = false,
+ modifier = Modifier.weight(1f),
+ )
+ SetupPanel(
+ modifier = Modifier
+ .weight(0.88f)
+ .widthIn(max = 420.dp),
+ )
+ }
}
}
- SetupPanel(
- modifier = Modifier
- .weight(0.88f)
- .widthIn(max = 420.dp)
+ }
+}
+
+@Composable
+private fun EmptyLibraryDescription(
+ expandedWidth: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = modifier,
+ ) {
+ Text(
+ text = stringResource(string.tv_home_title),
+ color = TvColors.TextPrimary,
+ fontSize = 48.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = TvFonts.Body,
+ )
+ Text(
+ text = stringResource(string.tv_empty_library_title),
+ color = TvColors.TextPrimary,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.SemiBold,
+ fontFamily = TvFonts.Body,
+ )
+ Text(
+ text = stringResource(string.tv_empty_library_subtitle),
+ color = TvColors.TextSecondary,
+ fontSize = 17.sp,
+ lineHeight = 25.sp,
+ fontFamily = TvFonts.Body,
+ modifier = Modifier.fillMaxWidth(if (expandedWidth) 1f else 0.82f),
)
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier
+ .fillMaxWidth(if (expandedWidth) 1f else 0.72f)
+ .widthIn(max = if (expandedWidth) 720.dp else 420.dp),
+ ) {
+ InfoPill(
+ text = stringResource(string.tv_empty_library_phone_hint),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ InfoPill(
+ text = stringResource(string.tv_empty_library_restore_hint),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
}
}
@Composable
-private fun SetupPanel(modifier: Modifier = Modifier) {
+private fun SetupPanel(
+ modifier: Modifier = Modifier,
+ minHeight: Dp? = null,
+) {
+ val sizeModifier = if (minHeight == null) {
+ Modifier.aspectRatio(1.18f)
+ } else {
+ Modifier.heightIn(min = minHeight)
+ }
FocusFrame(
onClick = {},
enabled = false,
+ focusableWhenDisabled = minHeight != null,
+ semanticRole = null,
modifier = Modifier
.then(modifier)
- .aspectRatio(1.18f),
+ .then(sizeModifier),
shape = RoundedCornerShape(16.dp)
) {
Column(
@@ -696,7 +2860,9 @@ private fun SetupPanel(modifier: Modifier = Modifier) {
title = stringResource(string.tv_empty_library_panel_title),
subtitle = stringResource(string.tv_empty_library_panel_subtitle)
)
- Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
SetupStep(text = stringResource(string.tv_empty_library_step_sources))
SetupStep(text = stringResource(string.tv_empty_library_step_sync))
SetupStep(text = stringResource(string.tv_empty_library_step_watch))
@@ -723,8 +2889,8 @@ private fun SetupStep(text: String) {
color = TvColors.TextSecondary,
fontSize = 14.sp,
fontFamily = TvFonts.Body,
- maxLines = 1,
+ maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
-}
\ No newline at end of file
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvStyle.kt b/app/tv/src/main/java/com/m3u/tv/TvStyle.kt
index 84f6e7151..368bf74f7 100644
--- a/app/tv/src/main/java/com/m3u/tv/TvStyle.kt
+++ b/app/tv/src/main/java/com/m3u/tv/TvStyle.kt
@@ -18,6 +18,7 @@ object TvColors {
val SurfaceRaised = Color(0xFF202A36)
val Focus = Color(0xFF8DF2C7)
val Accent = Color(0xFFFFC37A)
+ val Danger = Color(0xFFFF8A80)
val OnFocus = Color(0xFF06100C)
val TextPrimary = Color(0xFFF7FAFC)
val TextSecondary = Color(0xFFB6C1CC)
@@ -48,4 +49,4 @@ enum class TvDestination(
enum class TvSurface {
Browse,
Player
-}
\ No newline at end of file
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/TvUiPolicies.kt b/app/tv/src/main/java/com/m3u/tv/TvUiPolicies.kt
new file mode 100644
index 000000000..f5a7f9093
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/TvUiPolicies.kt
@@ -0,0 +1,299 @@
+package com.m3u.tv
+
+import com.m3u.extension.api.ExtensionState
+import kotlin.math.roundToInt
+
+internal enum class TvAppBackTarget {
+ PLAYER,
+ PROVIDER_SUBSCRIPTION,
+ EXTENSION_SETTINGS,
+ ACTIVITY,
+}
+
+internal enum class TvHeroAction {
+ PRIMARY,
+ SECONDARY,
+}
+
+internal enum class TvHorizontalDirection {
+ LEFT,
+ RIGHT,
+}
+
+internal data class TvLargeTextLayout(
+ val heroMinHeightDp: Int,
+ val heroTextWidthFraction: Float,
+ val playlistCardMinHeightDp: Int,
+ val metricTileMinHeightDp: Int,
+ val stackEmptyLibrary: Boolean,
+ val emptySetupMinHeightDp: Int,
+)
+
+/**
+ * Keeps the first TV viewport compact at the default scale while allowing
+ * text containers to grow instead of clipping accessibility-sized text.
+ */
+internal fun tvLargeTextLayout(fontScale: Float): TvLargeTextLayout {
+ val normalizedScale = fontScale.coerceIn(1f, 3f)
+ val scaleDelta = normalizedScale - 1f
+ return TvLargeTextLayout(
+ heroMinHeightDp = (288f + 240f * scaleDelta).roundToInt(),
+ heroTextWidthFraction = (0.54f + 0.24f * scaleDelta).coerceAtMost(0.82f),
+ playlistCardMinHeightDp = (144f + 72f * scaleDelta).roundToInt(),
+ metricTileMinHeightDp = (136f + 72f * scaleDelta).roundToInt(),
+ stackEmptyLibrary = normalizedScale >= 1.35f,
+ emptySetupMinHeightDp = (356f + 64f * scaleDelta).roundToInt(),
+ )
+}
+
+/**
+ * Resolves a physical DPad move against the action order that is actually
+ * displayed by a layout-direction-aware Row.
+ *
+ * A null result means the focused hero is already at that physical edge and
+ * must let Compose continue focus search outside the hero.
+ */
+internal fun tvHeroActionAfterHorizontalMove(
+ current: TvHeroAction,
+ direction: TvHorizontalDirection,
+ isRtl: Boolean,
+): TvHeroAction? {
+ val actionAtLeft = if (isRtl) TvHeroAction.SECONDARY else TvHeroAction.PRIMARY
+ val actionAtRight = if (isRtl) TvHeroAction.PRIMARY else TvHeroAction.SECONDARY
+ val destination = when (direction) {
+ TvHorizontalDirection.LEFT -> actionAtLeft
+ TvHorizontalDirection.RIGHT -> actionAtRight
+ }
+ return destination.takeUnless { it == current }
+}
+
+/**
+ * Maps a logical leading-to-trailing gradient onto physical color stops.
+ */
+internal fun tvLeadingGradientColorStops(
+ isRtl: Boolean,
+ leading: T,
+ middle: T,
+ trailing: T,
+ middlePosition: Float,
+): List> = if (isRtl) {
+ listOf(
+ 0f to trailing,
+ (1f - middlePosition) to middle,
+ 1f to leading,
+ )
+} else {
+ listOf(
+ 0f to leading,
+ middlePosition to middle,
+ 1f to trailing,
+ )
+}
+
+internal fun tvAppBackTarget(
+ playerVisible: Boolean,
+ providerSubscriptionVisible: Boolean,
+ extensionSettingsVisible: Boolean,
+): TvAppBackTarget = when {
+ playerVisible -> TvAppBackTarget.PLAYER
+ providerSubscriptionVisible -> TvAppBackTarget.PROVIDER_SUBSCRIPTION
+ extensionSettingsVisible -> TvAppBackTarget.EXTENSION_SETTINGS
+ else -> TvAppBackTarget.ACTIVITY
+}
+
+internal enum class TvProviderFormAvailability {
+ AVAILABLE,
+ LOADING,
+ UNAVAILABLE,
+}
+
+internal fun tvProviderFormAvailability(
+ discoveryLoading: Boolean,
+ providerSupported: Boolean,
+ providerMarkedUnavailable: Boolean,
+): TvProviderFormAvailability = when {
+ discoveryLoading -> TvProviderFormAvailability.LOADING
+ providerMarkedUnavailable || !providerSupported -> TvProviderFormAvailability.UNAVAILABLE
+ else -> TvProviderFormAvailability.AVAILABLE
+}
+
+internal fun tvProviderSubmitEnabled(
+ inProgress: Boolean,
+ availability: TvProviderFormAvailability,
+): Boolean = !inProgress && availability == TvProviderFormAvailability.AVAILABLE
+
+internal data class TvProviderChoicePresentation(
+ val variantName: String,
+ val providerName: String?,
+)
+
+/**
+ * Built-in providers are presented as their selectable variant (for example,
+ * Emby or Jellyfin). External providers additionally retain their plugin
+ * identity when it differs from the variant name.
+ */
+internal fun tvProviderChoicePresentation(
+ providerId: String,
+ providerDisplayName: String,
+ variantDisplayName: String,
+ external: Boolean,
+): TvProviderChoicePresentation {
+ val variantName = variantDisplayName.ifBlank { providerId }
+ val providerName = providerDisplayName.ifBlank { providerId }
+ return TvProviderChoicePresentation(
+ variantName = variantName,
+ providerName = providerName.takeIf {
+ external && providerName != variantName
+ },
+ )
+}
+
+internal fun shouldRestoreTvStatusFocus(
+ panelWasVisible: Boolean,
+ panelIsVisible: Boolean,
+ hasReturnTarget: Boolean,
+): Boolean = panelWasVisible && !panelIsVisible && hasReturnTarget
+
+internal fun tvExtensionDeveloperModeItemIndex(
+ providerFeedbackVisible: Boolean,
+ reauthenticationCount: Int,
+ providerDiscoveryItemCount: Int,
+ extensionErrorVisible: Boolean,
+): Int {
+ require(reauthenticationCount >= 0)
+ require(providerDiscoveryItemCount >= 0)
+ return 3 +
+ (if (providerFeedbackVisible) 1 else 0) +
+ reauthenticationCount +
+ providerDiscoveryItemCount +
+ 1 +
+ (if (extensionErrorVisible) 1 else 0)
+}
+
+internal fun tvProviderReauthenticationItemIndex(
+ providerFeedbackVisible: Boolean,
+ reauthenticationIndex: Int,
+): Int {
+ require(reauthenticationIndex >= 0)
+ return 3 +
+ (if (providerFeedbackVisible) 1 else 0) +
+ reauthenticationIndex
+}
+
+internal fun tvProviderVariantItemIndex(
+ providerFeedbackVisible: Boolean,
+ reauthenticationCount: Int,
+ providerVariantIndex: Int,
+): Int {
+ require(reauthenticationCount >= 0)
+ require(providerVariantIndex >= 0)
+ return 3 +
+ (if (providerFeedbackVisible) 1 else 0) +
+ reauthenticationCount +
+ providerVariantIndex
+}
+
+internal enum class TvProviderReauthenticationFocusAnchor {
+ ACCOUNT_ACTION,
+ PROVIDER_VARIANT,
+}
+
+internal fun tvProviderReauthenticationFocusAnchor(
+ subscriptionSucceeded: Boolean,
+ accountActionVisible: Boolean,
+): TvProviderReauthenticationFocusAnchor =
+ if (subscriptionSucceeded || !accountActionVisible) {
+ TvProviderReauthenticationFocusAnchor.PROVIDER_VARIANT
+ } else {
+ TvProviderReauthenticationFocusAnchor.ACCOUNT_ACTION
+ }
+
+internal enum class TvExtensionPluginAction {
+ SETTINGS,
+ DISABLE,
+ ENABLE,
+ REVOKE,
+ REAUTHORIZE,
+ EXPORT_DIAGNOSTICS,
+ CLEAR_DATA,
+}
+
+internal enum class TvExtensionPluginReturnFocusAnchor {
+ SOURCE_ACTION,
+ DEVELOPER_MODE,
+}
+
+/**
+ * Executed mutations can remove their source action, so they return to the
+ * stable developer-mode switch. Cancelling a panel keeps the source action
+ * intact and returns there instead.
+ */
+internal fun tvExtensionPluginReturnFocusAnchor(
+ action: TvExtensionPluginAction,
+ panelCancelled: Boolean = false,
+): TvExtensionPluginReturnFocusAnchor =
+ if (panelCancelled) {
+ TvExtensionPluginReturnFocusAnchor.SOURCE_ACTION
+ } else {
+ when (action) {
+ TvExtensionPluginAction.DISABLE,
+ TvExtensionPluginAction.ENABLE,
+ TvExtensionPluginAction.REVOKE,
+ TvExtensionPluginAction.REAUTHORIZE,
+ TvExtensionPluginAction.CLEAR_DATA,
+ -> TvExtensionPluginReturnFocusAnchor.DEVELOPER_MODE
+
+ else -> TvExtensionPluginReturnFocusAnchor.SOURCE_ACTION
+ }
+ }
+
+internal data class TvExtensionPluginActionAvailability(
+ val settings: Boolean,
+ val disable: Boolean,
+ val enable: Boolean,
+ val revoke: Boolean,
+ val reauthorize: Boolean,
+ val exportDiagnostics: Boolean,
+ val clearData: Boolean,
+)
+
+internal fun TvExtensionPluginActionAvailability.isActionAvailable(
+ action: TvExtensionPluginAction,
+): Boolean = when (action) {
+ TvExtensionPluginAction.SETTINGS -> settings
+ TvExtensionPluginAction.DISABLE -> disable
+ TvExtensionPluginAction.ENABLE -> enable
+ TvExtensionPluginAction.REVOKE -> revoke
+ TvExtensionPluginAction.REAUTHORIZE -> reauthorize
+ TvExtensionPluginAction.EXPORT_DIAGNOSTICS -> exportDiagnostics
+ TvExtensionPluginAction.CLEAR_DATA -> clearData
+}
+
+internal fun extensionPluginActionAvailability(
+ enabled: Boolean,
+ state: ExtensionState,
+ hasExtensionId: Boolean,
+ installed: Boolean,
+ signatureChanged: Boolean,
+ hasInspectionError: Boolean,
+ hasAuthorizationToken: Boolean,
+ trusted: Boolean,
+ canClearData: Boolean,
+) = TvExtensionPluginActionAvailability(
+ settings = enabled &&
+ state == ExtensionState.ENABLED &&
+ hasExtensionId,
+ disable = enabled && hasExtensionId,
+ enable = !enabled &&
+ state == ExtensionState.DISABLED &&
+ installed &&
+ !signatureChanged &&
+ !hasInspectionError &&
+ hasAuthorizationToken,
+ revoke = trusted || signatureChanged,
+ reauthorize = installed &&
+ (trusted || signatureChanged) &&
+ hasAuthorizationToken,
+ exportDiagnostics = installed && hasExtensionId,
+ clearData = canClearData,
+)
diff --git a/app/tv/src/main/res/xml-v28/backup_rules.xml b/app/tv/src/main/res/xml-v28/backup_rules.xml
new file mode 100644
index 000000000..26ea4ef14
--- /dev/null
+++ b/app/tv/src/main/res/xml-v28/backup_rules.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/tv/src/main/res/xml/backup_rules.xml b/app/tv/src/main/res/xml/backup_rules.xml
new file mode 100644
index 000000000..885d1eada
--- /dev/null
+++ b/app/tv/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/tv/src/main/res/xml/data_extraction_rules.xml b/app/tv/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 000000000..813ef0e74
--- /dev/null
+++ b/app/tv/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/tv/src/test/java/com/m3u/tv/TvBidiFormatterTest.kt b/app/tv/src/test/java/com/m3u/tv/TvBidiFormatterTest.kt
new file mode 100644
index 000000000..5a7cc3cd3
--- /dev/null
+++ b/app/tv/src/test/java/com/m3u/tv/TvBidiFormatterTest.kt
@@ -0,0 +1,45 @@
+package com.m3u.tv
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class TvBidiFormatterTest {
+ @Test
+ fun `strip removes every bidi control accepted from extension metadata`() {
+ val controls = "\u061C\u200E\u200F\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069"
+
+ assertEquals("Section title", "Section${controls} title".withoutBidiControls())
+ }
+
+ @Test
+ fun `display text keeps readable content after removing spoofing controls`() {
+ val sanitized = "Provider\u202Eexe".withoutBidiControls()
+
+ assertTrue(sanitized.contains("Providerexe"))
+ assertFalse(sanitized.contains('\u202E'))
+ }
+
+ @Test
+ fun `technical identifier keeps its exact character order`() {
+ val sanitized = "provider.example\u2066.kind".withoutBidiControls()
+
+ assertEquals("provider.example.kind", sanitized)
+ }
+
+ @Test
+ fun `long text is segmented before paired bidi controls are added`() {
+ val segments = "a".repeat(160).tvReadableSegments { segment ->
+ "\u202A$segment\u202C"
+ }
+
+ assertEquals(2, segments.size)
+ segments.forEach { segment ->
+ assertTrue(segment.startsWith('\u202A'))
+ assertTrue(segment.endsWith('\u202C'))
+ assertEquals(1, segment.count { it == '\u202A' })
+ assertEquals(1, segment.count { it == '\u202C' })
+ }
+ }
+}
diff --git a/app/tv/src/test/java/com/m3u/tv/TvLocaleSortTest.kt b/app/tv/src/test/java/com/m3u/tv/TvLocaleSortTest.kt
new file mode 100644
index 000000000..e23dcd3ca
--- /dev/null
+++ b/app/tv/src/test/java/com/m3u/tv/TvLocaleSortTest.kt
@@ -0,0 +1,47 @@
+package com.m3u.tv
+
+import java.util.Locale
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class TvLocaleSortTest {
+ @Test
+ fun `uses locale collation for visible labels`() {
+ val values = listOf("Örebro", "Zulu", "Ängel", "Åland")
+
+ val sorted = values.sortedWith(
+ localeAwareComparator(
+ primarySelector = { it },
+ locale = Locale.forLanguageTag("sv-SE"),
+ )
+ )
+
+ assertEquals(listOf("Zulu", "Åland", "Ängel", "Örebro"), sorted)
+ }
+
+ @Test
+ fun `uses title as stable secondary ordering`() {
+ val values = listOf(
+ TestLabel(category = "News", title = "Zulu", id = 1),
+ TestLabel(category = "Sports", title = "Alpha", id = 2),
+ TestLabel(category = "News", title = "alpha", id = 3),
+ TestLabel(category = "NEWS", title = "ALPHA", id = 4),
+ )
+
+ val sorted = values.sortedWith(
+ localeAwareComparator(
+ primarySelector = TestLabel::category,
+ secondarySelector = TestLabel::title,
+ locale = Locale.ENGLISH,
+ )
+ )
+
+ assertEquals(listOf(3, 4, 1, 2), sorted.map(TestLabel::id))
+ }
+
+ private data class TestLabel(
+ val category: String,
+ val title: String,
+ val id: Int,
+ )
+}
diff --git a/app/tv/src/test/java/com/m3u/tv/TvUiPoliciesTest.kt b/app/tv/src/test/java/com/m3u/tv/TvUiPoliciesTest.kt
new file mode 100644
index 000000000..410559fda
--- /dev/null
+++ b/app/tv/src/test/java/com/m3u/tv/TvUiPoliciesTest.kt
@@ -0,0 +1,520 @@
+package com.m3u.tv
+
+import com.m3u.extension.api.ExtensionState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class TvUiPoliciesTest {
+ @Test
+ fun `default font scale preserves the compact tv layout`() {
+ assertEquals(
+ TvLargeTextLayout(
+ heroMinHeightDp = 288,
+ heroTextWidthFraction = 0.54f,
+ playlistCardMinHeightDp = 144,
+ metricTileMinHeightDp = 136,
+ stackEmptyLibrary = false,
+ emptySetupMinHeightDp = 356,
+ ),
+ tvLargeTextLayout(fontScale = 1f),
+ )
+ }
+
+ @Test
+ fun `two hundred percent text grows clipped surfaces and stacks empty state`() {
+ val layout = tvLargeTextLayout(fontScale = 2f)
+ assertEquals(528, layout.heroMinHeightDp)
+ assertEquals(0.78f, layout.heroTextWidthFraction, absoluteTolerance = 0.0001f)
+ assertEquals(216, layout.playlistCardMinHeightDp)
+ assertEquals(208, layout.metricTileMinHeightDp)
+ assertTrue(layout.stackEmptyLibrary)
+ assertEquals(420, layout.emptySetupMinHeightDp)
+ }
+
+ @Test
+ fun `large text policy clamps invalid and extreme scale inputs`() {
+ assertEquals(tvLargeTextLayout(1f), tvLargeTextLayout(0.5f))
+ assertEquals(tvLargeTextLayout(3f), tvLargeTextLayout(5f))
+ }
+
+ @Test
+ fun `hero physical movement follows the visual order in ltr`() {
+ assertEquals(
+ TvHeroAction.SECONDARY,
+ tvHeroActionAfterHorizontalMove(
+ current = TvHeroAction.PRIMARY,
+ direction = TvHorizontalDirection.RIGHT,
+ isRtl = false,
+ ),
+ )
+ assertEquals(
+ TvHeroAction.PRIMARY,
+ tvHeroActionAfterHorizontalMove(
+ current = TvHeroAction.SECONDARY,
+ direction = TvHorizontalDirection.LEFT,
+ isRtl = false,
+ ),
+ )
+ assertNull(
+ tvHeroActionAfterHorizontalMove(
+ current = TvHeroAction.PRIMARY,
+ direction = TvHorizontalDirection.LEFT,
+ isRtl = false,
+ )
+ )
+ assertNull(
+ tvHeroActionAfterHorizontalMove(
+ current = TvHeroAction.SECONDARY,
+ direction = TvHorizontalDirection.RIGHT,
+ isRtl = false,
+ )
+ )
+ }
+
+ @Test
+ fun `hero physical movement mirrors the visual order in rtl`() {
+ assertEquals(
+ TvHeroAction.SECONDARY,
+ tvHeroActionAfterHorizontalMove(
+ current = TvHeroAction.PRIMARY,
+ direction = TvHorizontalDirection.LEFT,
+ isRtl = true,
+ ),
+ )
+ assertEquals(
+ TvHeroAction.PRIMARY,
+ tvHeroActionAfterHorizontalMove(
+ current = TvHeroAction.SECONDARY,
+ direction = TvHorizontalDirection.RIGHT,
+ isRtl = true,
+ ),
+ )
+ assertNull(
+ tvHeroActionAfterHorizontalMove(
+ current = TvHeroAction.SECONDARY,
+ direction = TvHorizontalDirection.LEFT,
+ isRtl = true,
+ )
+ )
+ assertNull(
+ tvHeroActionAfterHorizontalMove(
+ current = TvHeroAction.PRIMARY,
+ direction = TvHorizontalDirection.RIGHT,
+ isRtl = true,
+ )
+ )
+ }
+
+ @Test
+ fun `leading gradient mirrors both colors and midpoint in rtl`() {
+ assertEquals(
+ listOf(
+ 0f to "leading",
+ 0.58f to "middle",
+ 1f to "trailing",
+ ),
+ tvLeadingGradientColorStops(
+ isRtl = false,
+ leading = "leading",
+ middle = "middle",
+ trailing = "trailing",
+ middlePosition = 0.58f,
+ ),
+ )
+ assertEquals(
+ listOf(
+ 0f to "trailing",
+ (1f - 0.58f) to "middle",
+ 1f to "leading",
+ ),
+ tvLeadingGradientColorStops(
+ isRtl = true,
+ leading = "leading",
+ middle = "middle",
+ trailing = "trailing",
+ middlePosition = 0.58f,
+ ),
+ )
+ }
+
+ @Test
+ fun `ordinary browse delegates back to the activity`() {
+ assertEquals(
+ TvAppBackTarget.ACTIVITY,
+ tvAppBackTarget(
+ playerVisible = false,
+ providerSubscriptionVisible = false,
+ extensionSettingsVisible = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `provider form distinguishes loading from unavailable`() {
+ assertEquals(
+ TvProviderFormAvailability.LOADING,
+ tvProviderFormAvailability(
+ discoveryLoading = true,
+ providerSupported = false,
+ providerMarkedUnavailable = true,
+ ),
+ )
+ assertEquals(
+ TvProviderFormAvailability.UNAVAILABLE,
+ tvProviderFormAvailability(
+ discoveryLoading = false,
+ providerSupported = false,
+ providerMarkedUnavailable = false,
+ ),
+ )
+ assertEquals(
+ TvProviderFormAvailability.UNAVAILABLE,
+ tvProviderFormAvailability(
+ discoveryLoading = false,
+ providerSupported = true,
+ providerMarkedUnavailable = true,
+ ),
+ )
+ assertEquals(
+ TvProviderFormAvailability.AVAILABLE,
+ tvProviderFormAvailability(
+ discoveryLoading = false,
+ providerSupported = true,
+ providerMarkedUnavailable = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `provider submit requires an available idle provider`() {
+ assertTrue(
+ tvProviderSubmitEnabled(
+ inProgress = false,
+ availability = TvProviderFormAvailability.AVAILABLE,
+ )
+ )
+ assertFalse(
+ tvProviderSubmitEnabled(
+ inProgress = true,
+ availability = TvProviderFormAvailability.AVAILABLE,
+ )
+ )
+ assertFalse(
+ tvProviderSubmitEnabled(
+ inProgress = false,
+ availability = TvProviderFormAvailability.LOADING,
+ )
+ )
+ assertFalse(
+ tvProviderSubmitEnabled(
+ inProgress = false,
+ availability = TvProviderFormAvailability.UNAVAILABLE,
+ )
+ )
+ }
+
+ @Test
+ fun `built in provider choice uses the selectable variant name`() {
+ assertEquals(
+ TvProviderChoicePresentation(
+ variantName = "Jellyfin",
+ providerName = null,
+ ),
+ tvProviderChoicePresentation(
+ providerId = "builtin.media-server",
+ providerDisplayName = "Emby / Jellyfin",
+ variantDisplayName = "Jellyfin",
+ external = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `external provider choice preserves a distinct plugin name`() {
+ assertEquals(
+ TvProviderChoicePresentation(
+ variantName = "Jellyfin",
+ providerName = "Living room provider",
+ ),
+ tvProviderChoicePresentation(
+ providerId = "dev.example.provider",
+ providerDisplayName = "Living room provider",
+ variantDisplayName = "Jellyfin",
+ external = true,
+ ),
+ )
+ assertEquals(
+ TvProviderChoicePresentation(
+ variantName = "Jellyfin",
+ providerName = null,
+ ),
+ tvProviderChoicePresentation(
+ providerId = "dev.example.provider",
+ providerDisplayName = "Jellyfin",
+ variantDisplayName = "Jellyfin",
+ external = true,
+ ),
+ )
+ }
+
+ @Test
+ fun `status focus returns only after a transient panel closes`() {
+ assertTrue(
+ shouldRestoreTvStatusFocus(
+ panelWasVisible = true,
+ panelIsVisible = false,
+ hasReturnTarget = true,
+ )
+ )
+ assertFalse(
+ shouldRestoreTvStatusFocus(
+ panelWasVisible = false,
+ panelIsVisible = false,
+ hasReturnTarget = true,
+ )
+ )
+ assertFalse(
+ shouldRestoreTvStatusFocus(
+ panelWasVisible = true,
+ panelIsVisible = false,
+ hasReturnTarget = false,
+ )
+ )
+ }
+
+ @Test
+ fun `plugin trust mutations return to the stable developer mode control`() {
+ listOf(
+ TvExtensionPluginAction.DISABLE,
+ TvExtensionPluginAction.ENABLE,
+ TvExtensionPluginAction.REVOKE,
+ TvExtensionPluginAction.REAUTHORIZE,
+ TvExtensionPluginAction.CLEAR_DATA,
+ ).forEach { action ->
+ assertEquals(
+ TvExtensionPluginReturnFocusAnchor.DEVELOPER_MODE,
+ tvExtensionPluginReturnFocusAnchor(action),
+ )
+ }
+ listOf(
+ TvExtensionPluginAction.SETTINGS,
+ TvExtensionPluginAction.EXPORT_DIAGNOSTICS,
+ ).forEach { action ->
+ assertEquals(
+ TvExtensionPluginReturnFocusAnchor.SOURCE_ACTION,
+ tvExtensionPluginReturnFocusAnchor(action),
+ )
+ }
+ }
+
+ @Test
+ fun `developer mode item index accounts for dynamic provider rows`() {
+ assertEquals(
+ 8,
+ tvExtensionDeveloperModeItemIndex(
+ providerFeedbackVisible = false,
+ reauthenticationCount = 0,
+ providerDiscoveryItemCount = 4,
+ extensionErrorVisible = false,
+ ),
+ )
+ assertEquals(
+ 12,
+ tvExtensionDeveloperModeItemIndex(
+ providerFeedbackVisible = true,
+ reauthenticationCount = 2,
+ providerDiscoveryItemCount = 4,
+ extensionErrorVisible = true,
+ ),
+ )
+ }
+
+ @Test
+ fun `provider return indexes account for feedback and reauthentication rows`() {
+ assertEquals(
+ 5,
+ tvProviderReauthenticationItemIndex(
+ providerFeedbackVisible = true,
+ reauthenticationIndex = 1,
+ ),
+ )
+ assertEquals(
+ 8,
+ tvProviderVariantItemIndex(
+ providerFeedbackVisible = true,
+ reauthenticationCount = 2,
+ providerVariantIndex = 2,
+ ),
+ )
+ }
+
+ @Test
+ fun `successful reauthentication returns to the stable provider variant`() {
+ assertEquals(
+ TvProviderReauthenticationFocusAnchor.ACCOUNT_ACTION,
+ tvProviderReauthenticationFocusAnchor(
+ subscriptionSucceeded = false,
+ accountActionVisible = true,
+ ),
+ )
+ listOf(
+ true to true,
+ false to false,
+ true to false,
+ ).forEach { (subscriptionSucceeded, accountActionVisible) ->
+ assertEquals(
+ TvProviderReauthenticationFocusAnchor.PROVIDER_VARIANT,
+ tvProviderReauthenticationFocusAnchor(
+ subscriptionSucceeded = subscriptionSucceeded,
+ accountActionVisible = accountActionVisible,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `cancelling a plugin panel returns to its source action`() {
+ listOf(
+ TvExtensionPluginAction.ENABLE,
+ TvExtensionPluginAction.REVOKE,
+ TvExtensionPluginAction.REAUTHORIZE,
+ TvExtensionPluginAction.CLEAR_DATA,
+ ).forEach { action ->
+ assertEquals(
+ TvExtensionPluginReturnFocusAnchor.SOURCE_ACTION,
+ tvExtensionPluginReturnFocusAnchor(
+ action = action,
+ panelCancelled = true,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `plugin source action availability is exposed for focus retry`() {
+ val actions = TvExtensionPluginActionAvailability(
+ settings = false,
+ disable = true,
+ enable = false,
+ revoke = true,
+ reauthorize = false,
+ exportDiagnostics = true,
+ clearData = false,
+ )
+
+ assertTrue(actions.isActionAvailable(TvExtensionPluginAction.DISABLE))
+ assertTrue(actions.isActionAvailable(TvExtensionPluginAction.REVOKE))
+ assertTrue(
+ actions.isActionAvailable(TvExtensionPluginAction.EXPORT_DIAGNOSTICS)
+ )
+ assertFalse(actions.isActionAvailable(TvExtensionPluginAction.SETTINGS))
+ assertFalse(actions.isActionAvailable(TvExtensionPluginAction.ENABLE))
+ assertFalse(actions.isActionAvailable(TvExtensionPluginAction.REAUTHORIZE))
+ assertFalse(actions.isActionAvailable(TvExtensionPluginAction.CLEAR_DATA))
+ }
+
+ @Test
+ fun `app back handler preserves overlay priority`() {
+ assertEquals(
+ TvAppBackTarget.PLAYER,
+ tvAppBackTarget(
+ playerVisible = true,
+ providerSubscriptionVisible = true,
+ extensionSettingsVisible = true,
+ ),
+ )
+ assertEquals(
+ TvAppBackTarget.PROVIDER_SUBSCRIPTION,
+ tvAppBackTarget(
+ playerVisible = false,
+ providerSubscriptionVisible = true,
+ extensionSettingsVisible = true,
+ ),
+ )
+ assertEquals(
+ TvAppBackTarget.EXTENSION_SETTINGS,
+ tvAppBackTarget(
+ playerVisible = false,
+ providerSubscriptionVisible = false,
+ extensionSettingsVisible = true,
+ ),
+ )
+ }
+
+ @Test
+ fun `enabled unhealthy plugin keeps disable without exposing settings or enable`() {
+ val actions = actions(
+ enabled = true,
+ state = ExtensionState.UNHEALTHY,
+ hasInspectionError = true,
+ )
+
+ assertTrue(actions.disable)
+ assertFalse(actions.settings)
+ assertFalse(actions.enable)
+ }
+
+ @Test
+ fun `enabled incompatible plugin keeps disable after inspection failure`() {
+ val actions = actions(
+ enabled = true,
+ state = ExtensionState.INCOMPATIBLE,
+ hasInspectionError = true,
+ )
+
+ assertTrue(actions.disable)
+ assertFalse(actions.settings)
+ assertFalse(actions.enable)
+ }
+
+ @Test
+ fun `only an eligible disabled plugin exposes enable`() {
+ assertTrue(
+ actions(
+ enabled = false,
+ state = ExtensionState.DISABLED,
+ hasAuthorizationToken = true,
+ ).enable
+ )
+ assertFalse(
+ actions(
+ enabled = false,
+ state = ExtensionState.INCOMPATIBLE,
+ hasAuthorizationToken = true,
+ ).enable
+ )
+ assertFalse(
+ actions(
+ enabled = false,
+ state = ExtensionState.DISABLED,
+ hasInspectionError = true,
+ hasAuthorizationToken = true,
+ ).enable
+ )
+ }
+
+ private fun actions(
+ enabled: Boolean,
+ state: ExtensionState,
+ hasExtensionId: Boolean = true,
+ installed: Boolean = true,
+ signatureChanged: Boolean = false,
+ hasInspectionError: Boolean = false,
+ hasAuthorizationToken: Boolean = false,
+ trusted: Boolean = false,
+ canClearData: Boolean = false,
+ ) = extensionPluginActionAvailability(
+ enabled = enabled,
+ state = state,
+ hasExtensionId = hasExtensionId,
+ installed = installed,
+ signatureChanged = signatureChanged,
+ hasInspectionError = hasInspectionError,
+ hasAuthorizationToken = hasAuthorizationToken,
+ trusted = trusted,
+ canClearData = canClearData,
+ )
+}
diff --git a/baselineprofile/smartphone/build.gradle.kts b/baselineprofile/smartphone/build.gradle.kts
index daf2abec3..fd9db2e42 100644
--- a/baselineprofile/smartphone/build.gradle.kts
+++ b/baselineprofile/smartphone/build.gradle.kts
@@ -3,14 +3,13 @@
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
- alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.androidx.baselineprofile)
alias(libs.plugins.com.android.test)
}
android {
namespace = "com.m3u.baselineprofile.smartphone"
- compileSdk = 36
+ compileSdk = 37
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
diff --git a/baselineprofile/tv/build.gradle.kts b/baselineprofile/tv/build.gradle.kts
index 01b5fa91f..6cb2b0f81 100644
--- a/baselineprofile/tv/build.gradle.kts
+++ b/baselineprofile/tv/build.gradle.kts
@@ -3,14 +3,13 @@
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
- alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.androidx.baselineprofile)
alias(libs.plugins.com.android.test)
}
android {
namespace = "com.m3u.baselineprofile.tv"
- compileSdk = 36
+ compileSdk = 37
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
@@ -57,4 +56,4 @@ dependencies {
implementation(libs.androidx.test.espresso.espresso.core)
implementation(libs.androidx.test.uiautomator.uiautomator)
implementation(libs.androidx.benchmark.benchmark.macro.junit4)
-}
\ No newline at end of file
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index f51a64569..0cae50ef1 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,48 +1,186 @@
-import com.android.build.gradle.LibraryExtension
+import com.android.build.api.artifact.SingleArtifact
+import com.android.build.api.dsl.LibraryExtension
+import com.android.build.api.variant.ApplicationAndroidComponentsExtension
+import com.android.build.api.variant.BuiltArtifactsLoader
+import com.android.build.api.variant.FilterConfiguration
+import org.gradle.api.DefaultTask
+import org.gradle.api.artifacts.VersionCatalogsExtension
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+@CacheableTask
+abstract class CopyPublishedApks : DefaultTask() {
+ @get:InputDirectory
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val inputDirectory: DirectoryProperty
+
+ @get:OutputDirectory
+ abstract val outputDirectory: DirectoryProperty
+
+ @get:Internal
+ abstract val builtArtifactsLoader: Property
+
+ @get:Input
+ abstract val fileNamePrefix: Property
+
+ @get:Input
+ abstract val includeAbiSuffix: Property
+
+ @TaskAction
+ fun copyApks() {
+ val input = inputDirectory.get()
+ val builtArtifacts = builtArtifactsLoader.get().load(input)
+ ?: error("Cannot load APK metadata from ${input.asFile}")
+ check(builtArtifacts.elements.isNotEmpty()) {
+ "No APK outputs were produced in ${input.asFile}"
+ }
+ val output = outputDirectory.get().asFile
+ check(!output.exists() || output.deleteRecursively()) {
+ "Cannot clear previously published APKs from $output"
+ }
+ Files.createDirectories(output.toPath())
+
+ val publishedNames = mutableSetOf()
+ builtArtifacts.elements.forEach { artifact ->
+ val versionName = artifact.versionName
+ ?.takeIf(String::isNotBlank)
+ ?: error("APK ${artifact.outputFile} does not declare a version name")
+ val abi = artifact.filters
+ .firstOrNull { it.filterType == FilterConfiguration.FilterType.ABI }
+ ?.identifier
+ val abiSuffix = if (includeAbiSuffix.get() && abi != null) "_$abi" else ""
+ val publishedName = "${fileNamePrefix.get()}$versionName$abiSuffix.apk"
+ check(publishedNames.add(publishedName)) {
+ "Multiple APK outputs map to $publishedName"
+ }
+ Files.copy(
+ File(artifact.outputFile).toPath(),
+ output.resolve(publishedName).toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ }
+ }
+}
plugins {
alias(libs.plugins.com.android.application) apply false
alias(libs.plugins.com.android.library) apply false
- alias(libs.plugins.org.jetbrains.kotlin.android) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.com.google.dagger.hilt.android) apply false
alias(libs.plugins.com.google.devtools.ksp) apply false
alias(libs.plugins.com.android.test) apply false
alias(libs.plugins.org.jetbrains.kotlin.serialization) apply false
alias(libs.plugins.org.jetbrains.kotlin.jvm) apply false
+ alias(libs.plugins.org.jetbrains.kotlin.multiplatform) apply false
alias(libs.plugins.androidx.baselineprofile) apply false
alias(libs.plugins.com.squareup.wire) apply false
}
+val kotlinMetadataVersion = extensions
+ .getByType()
+ .named("libs")
+ .findVersion("kotlin")
+ .orElseThrow()
+ .requiredVersion
+
subprojects {
+ val coroutineOptInProjects = setOf(
+ ":app:smartphone",
+ ":business:channel",
+ ":business:favorite",
+ ":business:foryou",
+ ":business:playlist",
+ ":business:playlist-configuration",
+ ":business:setting",
+ ":core:foundation",
+ ":data",
+ )
+ val projectWideKotlinOptIns = buildList {
+ if (path in coroutineOptInProjects) {
+ add("kotlinx.coroutines.ExperimentalCoroutinesApi")
+ }
+ }
tasks.withType().configureEach {
compilerOptions {
- jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
- freeCompilerArgs.addAll(
- "-Xcontext-parameters"
- )
+ jvmTarget.set(JvmTarget.JVM_17)
}
}
+ configurations
+ .matching { it.name.startsWith("hiltAnnotationProcessor") }
+ .configureEach {
+ resolutionStrategy.force(
+ "org.jetbrains.kotlin:kotlin-metadata-jvm:$kotlinMetadataVersion"
+ )
+ }
fun configureKotlinOptIns() {
kotlinExtension.sourceSets.configureEach {
languageSettings {
- optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
- optIn("androidx.compose.ui.ExperimentalComposeUiApi")
- optIn("androidx.compose.foundation.ExperimentalFoundationApi")
- optIn("androidx.compose.foundation.layout.ExperimentalLayoutApi")
- optIn("androidx.compose.animation.ExperimentalSharedTransitionApi")
- optIn("androidx.compose.material3.ExperimentalMaterial3Api")
- optIn("androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi")
- optIn("androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi")
- optIn("androidx.tv.material3.ExperimentalTvMaterial3Api")
- optIn("com.google.accompanist.permissions.ExperimentalPermissionsApi")
+ projectWideKotlinOptIns.forEach(::optIn)
}
}
}
- plugins.withId("org.jetbrains.kotlin.android") {
- configureKotlinOptIns()
+ fun configureBuiltInAndroidKotlin() {
+ extensions.configure {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ optIn.addAll(projectWideKotlinOptIns)
+ }
+ }
+ }
+ plugins.withId("com.android.application") {
+ configureBuiltInAndroidKotlin()
+ val publishedApkPrefix = when (path) {
+ ":app:smartphone" -> ""
+ ":app:tv" -> "tv-"
+ else -> null
+ }
+ if (publishedApkPrefix != null) {
+ val publishedApkIncludesAbi = path == ":app:smartphone"
+ val androidComponents =
+ extensions.getByType()
+ androidComponents.onVariants(
+ androidComponents.selector().withBuildType("release")
+ ) { variant ->
+ val variantName = variant.name.replaceFirstChar { character ->
+ if (character.isLowerCase()) character.titlecase() else character.toString()
+ }
+ val copyTask = tasks.register(
+ "copy${variantName}PublishedApks"
+ ) {
+ outputDirectory.set(
+ layout.buildDirectory.dir("outputs/published-apk/${variant.name}")
+ )
+ builtArtifactsLoader.set(variant.artifacts.getBuiltArtifactsLoader())
+ fileNamePrefix.set(publishedApkPrefix)
+ includeAbiSuffix.set(publishedApkIncludesAbi)
+ }
+ variant.artifacts
+ .use(copyTask)
+ .wiredWith(CopyPublishedApks::inputDirectory)
+ .toListenTo(SingleArtifact.APK)
+ }
+ }
+ }
+ plugins.withId("com.android.library") {
+ configureBuiltInAndroidKotlin()
+ }
+ plugins.withId("com.android.test") {
+ configureBuiltInAndroidKotlin()
}
plugins.withId("org.jetbrains.kotlin.jvm") {
configureKotlinOptIns()
@@ -60,7 +198,7 @@ subprojects {
}
plugins.withId("com.android.library") {
configure {
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
diff --git a/business/channel/build.gradle.kts b/business/channel/build.gradle.kts
index 8ee3139ca..34ffa9a3c 100644
--- a/business/channel/build.gradle.kts
+++ b/business/channel/build.gradle.kts
@@ -1,6 +1,5 @@
plugins {
alias(libs.plugins.com.android.library)
- alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.com.google.dagger.hilt.android)
alias(libs.plugins.compose.compiler)
diff --git a/business/channel/src/main/java/com/m3u/business/channel/ChannelViewModel.kt b/business/channel/src/main/java/com/m3u/business/channel/ChannelViewModel.kt
index 0b295f609..c4113040f 100644
--- a/business/channel/src/main/java/com/m3u/business/channel/ChannelViewModel.kt
+++ b/business/channel/src/main/java/com/m3u/business/channel/ChannelViewModel.kt
@@ -191,6 +191,7 @@ class ChannelViewModel @Inject constructor(
private var dlnaSearchJob: Job? = null
fun openDlnaDevices() {
+ if (channel.value?.url == Channel.URL_DYNAMIC) return
dlnaSearchJob?.cancel()
dlnaSearchJob = viewModelScope.launch {
delay(800.milliseconds)
@@ -211,6 +212,7 @@ class ChannelViewModel @Inject constructor(
fun connectDlnaDevice(device: Device) {
val channel = channel.value ?: return
+ if (channel.url == Channel.URL_DYNAMIC) return
dlnaController.play(device, channel)
}
@@ -369,4 +371,4 @@ class ChannelViewModel @Inject constructor(
playerManager.recordVideo(uri)
}
}
-}
\ No newline at end of file
+}
diff --git a/business/channel/src/main/java/com/m3u/business/channel/DlnaController.kt b/business/channel/src/main/java/com/m3u/business/channel/DlnaController.kt
index 897129a3c..7287eacf0 100644
--- a/business/channel/src/main/java/com/m3u/business/channel/DlnaController.kt
+++ b/business/channel/src/main/java/com/m3u/business/channel/DlnaController.kt
@@ -59,6 +59,7 @@ internal class DlnaController : ControlPoint.DiscoveryListener {
}
fun play(device: Device, channel: Channel) {
+ if (channel.url == Channel.URL_DYNAMIC) return
val setUri = device.findAction(ACTION_SET_AV_TRANSPORT_URI) ?: return
setUri.invoke(
argumentValues = mapOf(
diff --git a/business/extension/.gitignore b/business/extension/.gitignore
deleted file mode 100644
index 42afabfd2..000000000
--- a/business/extension/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build
\ No newline at end of file
diff --git a/business/extension/build.gradle.kts b/business/extension/build.gradle.kts
deleted file mode 100644
index a0876f3b3..000000000
--- a/business/extension/build.gradle.kts
+++ /dev/null
@@ -1,34 +0,0 @@
-plugins {
- alias(libs.plugins.com.android.library)
- alias(libs.plugins.org.jetbrains.kotlin.android)
- alias(libs.plugins.com.google.devtools.ksp)
- alias(libs.plugins.com.google.dagger.hilt.android)
- alias(libs.plugins.compose.compiler)
- id("kotlin-parcelize")
-}
-android {
- namespace = "com.m3u.business.extension"
- buildFeatures {
- compose = true
- }
- packaging {
- resources.excludes += "META-INF/**"
- }
-}
-
-dependencies {
- implementation(project(":core:extension"))
- implementation(project(":data"))
- implementation(libs.m3u.extension.api)
- implementation(libs.m3u.extension.annotation)
- ksp(libs.m3u.extension.processor)
-
- implementation(libs.androidx.core.ktx)
-
- implementation(libs.androidx.lifecycle.runtime.ktx)
- implementation(libs.androidx.lifecycle.runtime.compose)
-
- implementation(libs.google.dagger.hilt)
- implementation(libs.androidx.hilt.navigation.compose)
- ksp(libs.google.dagger.hilt.compiler)
-}
\ No newline at end of file
diff --git a/business/extension/proguard-rules.pro b/business/extension/proguard-rules.pro
deleted file mode 100644
index 481bb4348..000000000
--- a/business/extension/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/business/extension/src/main/AndroidManifest.xml b/business/extension/src/main/AndroidManifest.xml
deleted file mode 100644
index a5918e68a..000000000
--- a/business/extension/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/business/extension/src/main/java/com/m3u/business/extension/ExtensionViewModel.kt b/business/extension/src/main/java/com/m3u/business/extension/ExtensionViewModel.kt
deleted file mode 100644
index 90aec4fc9..000000000
--- a/business/extension/src/main/java/com/m3u/business/extension/ExtensionViewModel.kt
+++ /dev/null
@@ -1,102 +0,0 @@
-package com.m3u.business.extension
-
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageInfo
-import android.content.pm.PackageManager
-import android.graphics.drawable.Drawable
-import android.os.Build
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.m3u.core.extension.RemoteService
-import com.m3u.extension.api.CallTokenConst
-import dagger.hilt.android.lifecycle.HiltViewModel
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.stateIn
-import java.util.UUID
-import javax.inject.Inject
-
-data class App(
- val name: String,
- val icon: Drawable,
- val packageName: String,
- val mainClassName: String,
- val version: String,
- val description: String,
-)
-
-@HiltViewModel
-class ExtensionViewModel @Inject constructor(
- @ApplicationContext private val context: Context
-) : ViewModel() {
- val applications: StateFlow> = flow {
- val pkgManager = context.packageManager
- val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- pkgManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(PACKAGE_FLAGS.toLong()))
- } else {
- pkgManager.getInstalledPackages(PACKAGE_FLAGS)
- }
- val extensions = installedPkgs
- .filter(::isPackageAnExtension)
- .mapNotNull { info ->
- val applicationInfo = info.applicationInfo ?: return@mapNotNull null
- val mainClass = applicationInfo.metaData?.getString(EXTENSION_MAIN_CLASS)
- ?: return@mapNotNull null
- val name = pkgManager.getApplicationLabel(applicationInfo).toString()
- val icon = pkgManager.getApplicationIcon(applicationInfo)
- val version = applicationInfo.metaData?.getString(EXTENSION_VERSION).orEmpty()
- val description = applicationInfo.metaData?.getString(EXTENSION_DESCRIPTION).orEmpty()
- App(
- name = name,
- icon = icon,
- packageName = info.packageName,
- mainClassName = mainClass.let {
- if (it.startsWith(".")) info.packageName + it else it
- },
- version = version,
- description = description,
- )
- }
- .toList()
- emit(extensions)
- }
- .flowOn(Dispatchers.Default)
- .stateIn(
- scope = viewModelScope,
- initialValue = emptyList(),
- started = SharingStarted.WhileSubscribed(5_000L)
- )
-
- fun runExtension(app: App) {
- val intent = Intent().apply {
- this.component = ComponentName(app.packageName, app.mainClassName)
- putExtra(CallTokenConst.PACKAGE_NAME, context.packageName)
- putExtra(CallTokenConst.CLASS_NAME, RemoteService::class.qualifiedName)
- putExtra(CallTokenConst.ACCESS_KEY, UUID.randomUUID().toString())
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- context.startActivity(intent)
- }
-
-}
-
-@Suppress("DEPRECATION")
-private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or
- PackageManager.GET_META_DATA or
- PackageManager.GET_SIGNATURES or
- (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0)
-
-private const val EXTENSION_FEATURE = "m3uandroid.extension"
-private const val EXTENSION_MAIN_CLASS = "m3uandroid.extension.class"
-private const val EXTENSION_VERSION = "m3uandroid.extension.version"
-private const val EXTENSION_DESCRIPTION = "m3uandroid.extension.description"
-
-private fun isPackageAnExtension(pkgInfo: PackageInfo): Boolean {
- return pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }
-}
\ No newline at end of file
diff --git a/business/favorite/build.gradle.kts b/business/favorite/build.gradle.kts
index 302e12c18..336a48522 100644
--- a/business/favorite/build.gradle.kts
+++ b/business/favorite/build.gradle.kts
@@ -1,6 +1,5 @@
plugins {
alias(libs.plugins.com.android.library)
- alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.com.google.dagger.hilt.android)
alias(libs.plugins.compose.compiler)
diff --git a/business/foryou/build.gradle.kts b/business/foryou/build.gradle.kts
index aff28d9d9..b5ca01703 100644
--- a/business/foryou/build.gradle.kts
+++ b/business/foryou/build.gradle.kts
@@ -1,6 +1,5 @@
plugins {
alias(libs.plugins.com.android.library)
- alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.com.google.dagger.hilt.android)
alias(libs.plugins.compose.compiler)
diff --git a/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt b/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt
index 31f3e6df1..1d5aeeaee 100644
--- a/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt
+++ b/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt
@@ -19,6 +19,7 @@ import com.m3u.data.repository.playlist.PlaylistRepository
import com.m3u.data.repository.programme.ProgrammeRepository
import com.m3u.data.service.PlayerManager
import com.m3u.data.worker.SubscriptionWorker
+import com.m3u.data.worker.playlistWorkTag
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -61,10 +62,19 @@ class ForyouViewModel @Inject constructor(
WorkInfo.State.ENQUEUED,
)
)
- .combine(playlistRepository.observePlaylistUrls()) { infos, playlistUrls ->
+ .combine(playlistRepository.observeAll()) { infos, playlists ->
+ val urlByWorkTag = playlists.associate { playlist ->
+ playlistWorkTag(playlist.url) to playlist.url
+ }
+ val playlistUrls = playlists.mapTo(mutableSetOf(), Playlist::url)
infos
.filter { info -> SubscriptionWorker.TAG in info.tags }
- .mapNotNull { info -> info.tags.find { it in playlistUrls } }
+ .mapNotNull { info ->
+ info.tags.firstNotNullOfOrNull(urlByWorkTag::get)
+ // Compatibility with work enqueued before hashed tags shipped.
+ ?: info.tags.firstOrNull { tag -> tag in playlistUrls }
+ }
+ .distinct()
}
.stateIn(
scope = viewModelScope,
diff --git a/business/playlist-configuration/build.gradle.kts b/business/playlist-configuration/build.gradle.kts
index 97e172a82..309ad10da 100644
--- a/business/playlist-configuration/build.gradle.kts
+++ b/business/playlist-configuration/build.gradle.kts
@@ -1,6 +1,5 @@
plugins {
alias(libs.plugins.com.android.library)
- alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.com.google.dagger.hilt.android)
alias(libs.plugins.compose.compiler)
@@ -30,4 +29,6 @@ dependencies {
ksp(libs.google.dagger.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx)
-}
\ No newline at end of file
+
+ testImplementation(kotlin("test-junit"))
+}
diff --git a/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationNavigation.kt b/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationNavigation.kt
index db0390146..b90ff63d9 100644
--- a/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationNavigation.kt
+++ b/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationNavigation.kt
@@ -1,17 +1,8 @@
package com.m3u.business.playlist.configuration
-import android.net.Uri
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
-import androidx.compose.foundation.layout.PaddingValues
import androidx.navigation.NavController
-import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
-import androidx.navigation.NavType
-import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
+import com.m3u.data.worker.playlistWorkTag
private const val PLAYLIST_CONFIGURATION_ROUTE_PATH = "playlist_configuration_route"
@@ -22,15 +13,36 @@ object PlaylistConfigurationNavigation {
"$PLAYLIST_CONFIGURATION_ROUTE_PATH?$TYPE_PLAYLIST_URL={$TYPE_PLAYLIST_URL}"
internal fun createPlaylistConfigurationRoute(playlistUrl: String): String {
- return "$PLAYLIST_CONFIGURATION_ROUTE_PATH?$TYPE_PLAYLIST_URL=$playlistUrl"
+ val playlistReference = playlistConfigurationReference(playlistUrl)
+ return "$PLAYLIST_CONFIGURATION_ROUTE_PATH?$TYPE_PLAYLIST_URL=$playlistReference"
}
}
+fun playlistConfigurationReference(playlistUrl: String): String =
+ playlistWorkTag(playlistUrl)
+
+internal fun resolvePlaylistConfigurationUrl(
+ playlistUrls: Iterable,
+ routeArgument: String,
+): String? {
+ if (routeArgument.isBlank()) return null
+
+ var legacyRawUrlMatch: String? = null
+ playlistUrls.forEach { playlistUrl ->
+ if (playlistConfigurationReference(playlistUrl) == routeArgument) {
+ return playlistUrl
+ }
+ if (playlistUrl == routeArgument) {
+ legacyRawUrlMatch = playlistUrl
+ }
+ }
+ return legacyRawUrlMatch
+}
+
fun NavController.navigateToPlaylistConfiguration(
playlistUrl: String,
navOptions: NavOptions? = null,
) {
- val encodedUrl = Uri.encode(playlistUrl)
- val route = PlaylistConfigurationNavigation.createPlaylistConfigurationRoute(encodedUrl)
+ val route = PlaylistConfigurationNavigation.createPlaylistConfigurationRoute(playlistUrl)
this.navigate(route, navOptions)
}
diff --git a/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationState.kt b/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationState.kt
new file mode 100644
index 000000000..ccedd2e20
--- /dev/null
+++ b/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationState.kt
@@ -0,0 +1,65 @@
+package com.m3u.business.playlist.configuration
+
+import com.m3u.core.foundation.util.basic.PlaylistInputKind
+import com.m3u.core.foundation.util.basic.normalizePlaylistInputForSubmission
+import com.m3u.data.database.model.Playlist
+
+sealed interface PlaylistConfigurationState {
+ val playlistReference: String?
+
+ data object Loading : PlaylistConfigurationState {
+ override val playlistReference: String? = null
+ }
+
+ data class Content(
+ val playlist: Playlist,
+ override val playlistReference: String =
+ playlistConfigurationReference(playlist.url),
+ ) : PlaylistConfigurationState
+
+ data class NotFound(
+ override val playlistReference: String,
+ ) : PlaylistConfigurationState
+}
+
+internal fun resolvePlaylistConfigurationState(
+ playlists: List,
+ playlistReference: String,
+): PlaylistConfigurationState {
+ val playlistUrl = resolvePlaylistConfigurationUrl(
+ playlistUrls = playlists.map(Playlist::url),
+ routeArgument = playlistReference,
+ )
+ val playlist = playlists.firstOrNull { candidate ->
+ candidate.url == playlistUrl
+ }
+ return playlist
+ ?.let(PlaylistConfigurationState::Content)
+ ?: PlaylistConfigurationState.NotFound(
+ playlistReference = playlistReference.safePlaylistConfigurationReference(),
+ )
+}
+
+internal fun normalizePlaylistTitle(title: String): String? =
+ title
+ .normalizePlaylistInputForSubmission(PlaylistInputKind.TITLE)
+ .takeIf(String::isNotEmpty)
+
+internal fun normalizePlaylistUserAgent(userAgent: String?): String? =
+ userAgent
+ ?.normalizePlaylistInputForSubmission(PlaylistInputKind.USER_AGENT)
+ ?.takeIf(String::isNotEmpty)
+
+enum class PlaylistRemovalState {
+ IDLE,
+ REMOVING,
+ FAILED,
+ REMOVED,
+}
+
+private val PLAYLIST_CONFIGURATION_REFERENCE =
+ Regex("playlist-work:[a-f0-9]{64}")
+
+private fun String.safePlaylistConfigurationReference(): String =
+ takeIf(PLAYLIST_CONFIGURATION_REFERENCE::matches)
+ ?: playlistConfigurationReference(this)
diff --git a/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationViewModel.kt b/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationViewModel.kt
index 2d04e6d98..87e79445f 100644
--- a/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationViewModel.kt
+++ b/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistConfigurationViewModel.kt
@@ -14,19 +14,30 @@ import com.m3u.data.parser.xtream.XtreamUserInfo
import com.m3u.data.parser.xtream.XtreamInput
import com.m3u.data.parser.xtream.XtreamParser
import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.data.repository.playlist.PlaylistRefreshReason
import com.m3u.data.repository.programme.ProgrammeRepository
+import com.m3u.data.repository.provider.DiscoveredSubscriptionProvider
+import com.m3u.data.repository.provider.ProviderAccountSummary
+import com.m3u.data.repository.provider.SubscriptionProviderRepository
import com.m3u.data.worker.SubscriptionWorker
+import com.m3u.data.worker.playlistWorkTag
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
+import java.util.UUID
import kotlin.time.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
@@ -42,39 +53,104 @@ class PlaylistConfigurationViewModel @Inject constructor(
private val programmeRepository: ProgrammeRepository,
private val xtreamParser: XtreamParser,
private val workManager: WorkManager,
- savedStateHandle: SavedStateHandle,
+ private val subscriptionProviderRepository: SubscriptionProviderRepository,
+ private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val timber = Timber.tag("PlaylistConfigurationViewModel")
- private val playlistUrl: StateFlow = savedStateHandle
+ private val playlistReference: StateFlow = savedStateHandle
.getStateFlow(PlaylistConfigurationNavigation.TYPE_PLAYLIST_URL, "")
- val playlist: StateFlow = playlistUrl.flatMapLatest {
- playlistRepository.observe(it)
- }
+ private val activeSubscriptionWorkInfos = workManager.getWorkInfosFlow(
+ WorkQuery.fromStates(
+ WorkInfo.State.BLOCKED,
+ WorkInfo.State.RUNNING,
+ WorkInfo.State.ENQUEUED,
+ )
+ )
+ private val playlistRefreshLaunch = MutableStateFlow(RefreshLaunch.Idle)
+ private val programmeRefreshLaunch = MutableStateFlow(RefreshLaunch.Idle)
+ val playlistRemovalState: StateFlow =
+ savedStateHandle.getStateFlow(
+ PLAYLIST_REMOVAL_STATE_KEY,
+ PlaylistRemovalState.IDLE,
+ )
+ private val _discoveredProviders =
+ MutableStateFlow>(emptyList())
+ val discoveredProviders: StateFlow> =
+ _discoveredProviders
+ private var providerCatalogLocaleTag: String? = null
+
+ val state: StateFlow = playlistRepository
+ .observeAll()
+ .combine(playlistReference) { playlists, routeArgument ->
+ resolvePlaylistConfigurationState(
+ playlists = playlists,
+ playlistReference = routeArgument,
+ )
+ }
+ .onEach { configurationState ->
+ val currentPlaylist =
+ (configurationState as? PlaylistConfigurationState.Content)?.playlist
+ ?: return@onEach
+ val canonicalReference =
+ playlistConfigurationReference(currentPlaylist.url)
+ if (playlistReference.value != canonicalReference) {
+ savedStateHandle[PlaylistConfigurationNavigation.TYPE_PLAYLIST_URL] =
+ canonicalReference
+ }
+ }
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = PlaylistConfigurationState.Loading,
+ started = SharingStarted.Eagerly
+ )
+
+ val playlist: StateFlow = state
+ .map { configurationState ->
+ (configurationState as? PlaylistConfigurationState.Content)?.playlist
+ }
.stateIn(
scope = viewModelScope,
initialValue = null,
- started = SharingStarted.WhileSubscribed(5_000L)
+ started = SharingStarted.Eagerly,
)
- val xtreamUserInfo: StateFlow> =
- playlist.map { playlist ->
- playlist ?: return@map null
- if (playlist.source != DataSource.Xtream) return@map null
- val xtreamInput = XtreamInput
- .decodeFromPlaylistUrlOrNull(playlist.url)
- ?: return@map null
- xtreamParser
- .getInfo(xtreamInput)
- .userInfo
- }
- .filterNotNull()
- .asResource()
+ val providerAccountSummary: StateFlow =
+ subscriptionProviderRepository
+ .observeAccountSummaries()
+ .combine(state) { summaries, configurationState ->
+ val playlistUrl =
+ (configurationState as? PlaylistConfigurationState.Content)
+ ?.playlist
+ ?.url
+ ?: return@combine null
+ summaries.firstOrNull { summary ->
+ summary.playlistUrl == playlistUrl
+ }
+ }
.stateIn(
scope = viewModelScope,
- initialValue = Resource.Loading,
- started = SharingStarted.Lazily
+ initialValue = null,
+ started = SharingStarted.WhileSubscribed(5_000L),
)
+ val xtreamUserInfo: StateFlow> = playlist
+ .flatMapLatest { currentPlaylist ->
+ if (currentPlaylist?.source != DataSource.Xtream) {
+ return@flatMapLatest flowOf(Resource.Loading)
+ }
+ val xtreamInput = XtreamInput
+ .decodeFromPlaylistUrlOrNull(currentPlaylist.url)
+ ?: return@flatMapLatest flowOf(Resource.Failure(null))
+ flow {
+ emit(xtreamParser.getInfo(xtreamInput).userInfo)
+ }.asResource()
+ }
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = Resource.Loading,
+ started = SharingStarted.Lazily
+ )
+
val manifest: StateFlow = combine(
playlistRepository.observeAllEpgs(),
playlist
@@ -87,19 +163,12 @@ class PlaylistConfigurationViewModel @Inject constructor(
started = SharingStarted.Lazily,
initialValue = emptyMap()
)
- val subscribingOrRefreshingWorkInfo: StateFlow = workManager
- .getWorkInfosFlow(
- WorkQuery.fromStates(
- WorkInfo.State.RUNNING,
- WorkInfo.State.ENQUEUED,
+ val playlistRefreshWorkInfo: StateFlow = activeSubscriptionWorkInfos
+ .combine(playlist) { infos, currentPlaylist ->
+ infos.findPlaylistWork(
+ playlist = currentPlaylist,
+ includeProgrammeRefresh = false,
)
- )
- .combine(playlistUrl) { infos, playlistUrl ->
- infos.find { info ->
- info.tags.containsAll(
- listOf(SubscriptionWorker.TAG, DataSource.EPG.value, playlistUrl)
- )
- }
}
.flowOn(Dispatchers.Default)
.stateIn(
@@ -108,57 +177,323 @@ class PlaylistConfigurationViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L)
)
- val expired: StateFlow = playlistUrl
- .flatMapLatest { playlistUrl ->
- programmeRepository.observeProgrammeRange(playlistUrl)
- }
- .map { range ->
- if (range.start == 0L || range.end == 0L) null
- else Instant
- .fromEpochMilliseconds(range.end)
- .toLocalDateTime(TimeZone.currentSystemDefault())
+ val programmeRefreshWorkInfo: StateFlow = activeSubscriptionWorkInfos
+ .combine(playlist) { infos, currentPlaylist ->
+ infos.findPlaylistWork(
+ playlist = currentPlaylist,
+ includeProgrammeRefresh = true,
+ )
}
+ .flowOn(Dispatchers.Default)
.stateIn(
scope = viewModelScope,
initialValue = null,
started = SharingStarted.WhileSubscribed(5_000L)
)
+ val playlistRefreshStatus: StateFlow =
+ refreshStatus(
+ launch = playlistRefreshLaunch,
+ activeWorkInfo = playlistRefreshWorkInfo,
+ )
+
+ val programmeRefreshStatus: StateFlow =
+ refreshStatus(
+ launch = programmeRefreshLaunch,
+ activeWorkInfo = programmeRefreshWorkInfo,
+ )
+
+ val expired: StateFlow = playlist
+ .flatMapLatest { currentPlaylist ->
+ if (currentPlaylist == null) {
+ flowOf(null)
+ } else {
+ programmeRepository
+ .observeProgrammeRange(currentPlaylist.url)
+ .map { range ->
+ if (range.start == 0L || range.end == 0L) {
+ null
+ } else {
+ Instant
+ .fromEpochMilliseconds(range.end)
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ }
+ }
+ }
+ }
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = null,
+ started = SharingStarted.WhileSubscribed(5_000L)
+ )
fun onUpdatePlaylistTitle(title: String) {
- val playlistUrl = playlistUrl.value
+ val normalizedTitle = normalizePlaylistTitle(title) ?: return
+ val playlistUrl = currentPlaylist()?.url ?: return
viewModelScope.launch {
- playlistRepository.onUpdatePlaylistTitle(playlistUrl, title)
+ playlistRepository.onUpdatePlaylistTitle(playlistUrl, normalizedTitle)
}
}
fun onUpdatePlaylistUserAgent(userAgent: String?) {
- val playlistUrl = playlistUrl.value
+ val playlistUrl = currentPlaylist()?.url ?: return
+ val normalizedUserAgent = normalizePlaylistUserAgent(userAgent)
viewModelScope.launch {
- playlistRepository.onUpdatePlaylistUserAgent(playlistUrl, userAgent)
+ playlistRepository.onUpdatePlaylistUserAgent(playlistUrl, normalizedUserAgent)
}
}
fun onUpdateEpgPlaylist(usecase: PlaylistRepository.EpgPlaylistUseCase) {
+ val playlistUrl = currentPlaylist()?.url ?: return
+ val resolvedUseCase = when (usecase) {
+ is PlaylistRepository.EpgPlaylistUseCase.Check ->
+ usecase.copy(playlistUrl = playlistUrl)
+
+ is PlaylistRepository.EpgPlaylistUseCase.Upgrade ->
+ usecase.copy(playlistUrl = playlistUrl)
+ }
viewModelScope.launch {
- playlistRepository.onUpdateEpgPlaylist(usecase)
+ playlistRepository.onUpdateEpgPlaylist(resolvedUseCase)
}
}
fun onUpdatePlaylistAutoRefreshProgrammes() {
- val playlistUrl = playlistUrl.value
+ val playlistUrl = currentPlaylist()?.url ?: return
viewModelScope.launch {
playlistRepository.onUpdatePlaylistAutoRefreshProgrammes(playlistUrl)
}
}
+ fun onRefreshPlaylist() {
+ val playlistUrl = currentPlaylist()?.url ?: return
+ if (playlistRefreshStatus.value.isInProgress) return
+ playlistRefreshLaunch.value = RefreshLaunch.Enqueuing(playlistUrl)
+ viewModelScope.launch {
+ try {
+ val workId = playlistRepository.refreshWithWorkId(
+ url = playlistUrl,
+ reason = PlaylistRefreshReason.USER,
+ )
+ playlistRefreshLaunch.updateFor(playlistUrl) {
+ workId?.let { RefreshLaunch.Work(playlistUrl, it) }
+ ?: RefreshLaunch.Failed(playlistUrl)
+ }
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (error: Exception) {
+ timber.w(
+ error,
+ "Unable to enqueue playlist refresh (%s)",
+ error.javaClass.simpleName,
+ )
+ playlistRefreshLaunch.updateFor(playlistUrl) {
+ RefreshLaunch.Failed(playlistUrl)
+ }
+ }
+ }
+ }
+
+ fun onCancelRefreshPlaylist() {
+ val workId = (playlistRefreshLaunch.value as? RefreshLaunch.Work)
+ ?.takeIf { launch -> launch.playlistUrl == currentPlaylist()?.url }
+ ?.workId
+ ?: playlistRefreshWorkInfo.value?.id
+ workId?.let(workManager::cancelWorkById)
+ }
+
fun onSyncProgrammes() {
- val playlistUrl = playlistUrl.value
- SubscriptionWorker.epg(workManager, playlistUrl, true)
+ val playlistUrl = currentPlaylist()?.url ?: return
+ if (programmeRefreshStatus.value.isInProgress) return
+ programmeRefreshLaunch.value = RefreshLaunch.Enqueuing(playlistUrl)
+ viewModelScope.launch {
+ try {
+ val workId = SubscriptionWorker.epg(workManager, playlistUrl, true)
+ programmeRefreshLaunch.updateFor(playlistUrl) {
+ RefreshLaunch.Work(playlistUrl, workId)
+ }
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (error: Exception) {
+ timber.w(
+ error,
+ "Unable to enqueue programme refresh (%s)",
+ error.javaClass.simpleName,
+ )
+ programmeRefreshLaunch.updateFor(playlistUrl) {
+ RefreshLaunch.Failed(playlistUrl)
+ }
+ }
+ }
}
fun onCancelSyncProgrammes() {
- val workInfo = subscribingOrRefreshingWorkInfo.value
- workInfo?.id?.let { workManager.cancelWorkById(it) }
+ val workId = (programmeRefreshLaunch.value as? RefreshLaunch.Work)
+ ?.takeIf { launch -> launch.playlistUrl == currentPlaylist()?.url }
+ ?.workId
+ ?: programmeRefreshWorkInfo.value?.id
+ workId?.let(workManager::cancelWorkById)
+ }
+
+ fun onRemovePlaylist() {
+ if (
+ playlistRemovalState.value == PlaylistRemovalState.REMOVING ||
+ playlistRemovalState.value == PlaylistRemovalState.REMOVED
+ ) {
+ return
+ }
+ val playlistUrl = currentPlaylist()?.url ?: return
+ savedStateHandle[PLAYLIST_REMOVAL_STATE_KEY] =
+ PlaylistRemovalState.REMOVING
+ viewModelScope.launch {
+ try {
+ playlistRepository.unsubscribe(playlistUrl)
+ savedStateHandle[PLAYLIST_REMOVAL_STATE_KEY] =
+ PlaylistRemovalState.REMOVED
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (_: Exception) {
+ savedStateHandle[PLAYLIST_REMOVAL_STATE_KEY] =
+ PlaylistRemovalState.FAILED
+ }
+ }
+ }
+
+ fun clearPlaylistRemovalFailure() {
+ if (playlistRemovalState.value == PlaylistRemovalState.FAILED) {
+ savedStateHandle[PLAYLIST_REMOVAL_STATE_KEY] =
+ PlaylistRemovalState.IDLE
+ }
+ }
+
+ fun refreshProviderCatalog(localeTag: String) {
+ if (
+ providerCatalogLocaleTag == localeTag &&
+ _discoveredProviders.value.isNotEmpty()
+ ) {
+ return
+ }
+ providerCatalogLocaleTag = localeTag
+ viewModelScope.launch {
+ try {
+ val providers =
+ subscriptionProviderRepository.discoverProviders(localeTag)
+ if (providerCatalogLocaleTag == localeTag) {
+ _discoveredProviders.value = providers
+ }
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (_: Exception) {
+ // The account/server name remains a readable offline fallback.
+ }
+ }
+ }
+
+ fun acknowledgePlaylistRemoval() {
+ if (playlistRemovalState.value == PlaylistRemovalState.REMOVED) {
+ savedStateHandle[PLAYLIST_REMOVAL_STATE_KEY] =
+ PlaylistRemovalState.IDLE
+ }
+ }
+
+ fun openPlaylistReference(reference: String) {
+ savedStateHandle[PlaylistConfigurationNavigation.TYPE_PLAYLIST_URL] = reference
+ }
+
+ private fun currentPlaylist(): Playlist? =
+ (state.value as? PlaylistConfigurationState.Content)?.playlist
+
+ private fun refreshStatus(
+ launch: StateFlow,
+ activeWorkInfo: StateFlow,
+ ): StateFlow = combine(
+ playlist,
+ launch,
+ ) { currentPlaylist, currentLaunch ->
+ currentPlaylist?.url to currentLaunch
+ }
+ .flatMapLatest { (playlistUrl, currentLaunch) ->
+ val applicableLaunch = currentLaunch.takeIf { launchState ->
+ launchState.playlistUrl == playlistUrl
+ } ?: RefreshLaunch.Idle
+ val trackedWork = (applicableLaunch as? RefreshLaunch.Work)
+ val trackedWorkInfo = trackedWork?.let { work ->
+ workManager.getWorkInfoByIdFlow(work.workId)
+ } ?: flowOf(null)
+ combine(
+ trackedWorkInfo,
+ activeWorkInfo,
+ ) { tracked, active ->
+ resolvePlaylistRefreshStatus(
+ launchPhase = applicableLaunch.phase,
+ trackedWorkState = tracked?.state,
+ activeWorkState = active?.state,
+ )
+ }
+ }
+ .distinctUntilChanged()
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = PlaylistRefreshStatus.IDLE,
+ started = SharingStarted.Eagerly,
+ )
+
+ private companion object {
+ const val PLAYLIST_REMOVAL_STATE_KEY = "playlist_removal_state"
+ }
+}
+
+private sealed interface RefreshLaunch {
+ val playlistUrl: String?
+ val phase: RefreshLaunchPhase
+
+ data object Idle : RefreshLaunch {
+ override val playlistUrl: String? = null
+ override val phase = RefreshLaunchPhase.IDLE
+ }
+
+ data class Enqueuing(
+ override val playlistUrl: String,
+ ) : RefreshLaunch {
+ override val phase = RefreshLaunchPhase.ENQUEUING
+ }
+
+ data class Work(
+ override val playlistUrl: String,
+ val workId: UUID,
+ ) : RefreshLaunch {
+ override val phase = RefreshLaunchPhase.WORK
+ }
+
+ data class Failed(
+ override val playlistUrl: String,
+ ) : RefreshLaunch {
+ override val phase = RefreshLaunchPhase.FAILED
+ }
+}
+
+private inline fun MutableStateFlow.updateFor(
+ playlistUrl: String,
+ update: () -> RefreshLaunch,
+) {
+ if (value.playlistUrl == playlistUrl) {
+ value = update()
+ }
+}
+
+private fun List.findPlaylistWork(
+ playlist: Playlist?,
+ includeProgrammeRefresh: Boolean,
+): WorkInfo? {
+ val playlistUrl = playlist?.url ?: return null
+ val workTag = playlistWorkTag(playlistUrl)
+ return find { info ->
+ val isProgrammeRefresh = DataSource.EPG.value in info.tags
+ SubscriptionWorker.TAG in info.tags &&
+ isProgrammeRefresh == includeProgrammeRefresh &&
+ (
+ workTag in info.tags ||
+ // Compatibility with work enqueued before hashed tags shipped.
+ playlistUrl in info.tags
+ )
}
-}
\ No newline at end of file
+}
diff --git a/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistRefreshStatus.kt b/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistRefreshStatus.kt
new file mode 100644
index 000000000..7667609cc
--- /dev/null
+++ b/business/playlist-configuration/src/main/java/com/m3u/business/playlist/configuration/PlaylistRefreshStatus.kt
@@ -0,0 +1,50 @@
+package com.m3u.business.playlist.configuration
+
+import androidx.compose.runtime.Immutable
+import androidx.work.WorkInfo
+
+@Immutable
+enum class PlaylistRefreshStatus {
+ IDLE,
+ ENQUEUED,
+ RUNNING,
+ SUCCEEDED,
+ FAILED,
+ CANCELLED;
+
+ val isInProgress: Boolean
+ get() = this == ENQUEUED || this == RUNNING
+}
+
+internal enum class RefreshLaunchPhase {
+ IDLE,
+ ENQUEUING,
+ WORK,
+ FAILED,
+}
+
+internal fun resolvePlaylistRefreshStatus(
+ launchPhase: RefreshLaunchPhase,
+ trackedWorkState: WorkInfo.State?,
+ activeWorkState: WorkInfo.State?,
+): PlaylistRefreshStatus = when (launchPhase) {
+ RefreshLaunchPhase.ENQUEUING -> PlaylistRefreshStatus.ENQUEUED
+ RefreshLaunchPhase.FAILED -> PlaylistRefreshStatus.FAILED
+ RefreshLaunchPhase.WORK ->
+ trackedWorkState?.toPlaylistRefreshStatus()
+ ?: PlaylistRefreshStatus.ENQUEUED
+ RefreshLaunchPhase.IDLE ->
+ activeWorkState
+ ?.takeUnless { state -> state.isFinished }
+ ?.toPlaylistRefreshStatus()
+ ?: PlaylistRefreshStatus.IDLE
+}
+
+private fun WorkInfo.State.toPlaylistRefreshStatus(): PlaylistRefreshStatus = when (this) {
+ WorkInfo.State.BLOCKED,
+ WorkInfo.State.ENQUEUED -> PlaylistRefreshStatus.ENQUEUED
+ WorkInfo.State.RUNNING -> PlaylistRefreshStatus.RUNNING
+ WorkInfo.State.SUCCEEDED -> PlaylistRefreshStatus.SUCCEEDED
+ WorkInfo.State.FAILED -> PlaylistRefreshStatus.FAILED
+ WorkInfo.State.CANCELLED -> PlaylistRefreshStatus.CANCELLED
+}
diff --git a/business/playlist-configuration/src/test/java/com/m3u/business/playlist/configuration/PlaylistConfigurationNavigationTest.kt b/business/playlist-configuration/src/test/java/com/m3u/business/playlist/configuration/PlaylistConfigurationNavigationTest.kt
new file mode 100644
index 000000000..7494fea62
--- /dev/null
+++ b/business/playlist-configuration/src/test/java/com/m3u/business/playlist/configuration/PlaylistConfigurationNavigationTest.kt
@@ -0,0 +1,65 @@
+package com.m3u.business.playlist.configuration
+
+import com.m3u.data.worker.playlistWorkTag
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+
+class PlaylistConfigurationNavigationTest {
+ @Test
+ fun `route contains only a stable playlist reference`() {
+ val username = "private-user"
+ val password = "private-password"
+ val token = "private-token"
+ val playlistUrl =
+ "https://example.com/get.php?username=$username&password=$password&token=$token"
+
+ val route = PlaylistConfigurationNavigation
+ .createPlaylistConfigurationRoute(playlistUrl)
+
+ assertEquals(
+ "playlist_configuration_route?playlist_url=${playlistWorkTag(playlistUrl)}",
+ route,
+ )
+ assertFalse(playlistUrl in route)
+ assertFalse(username in route)
+ assertFalse(password in route)
+ assertFalse(token in route)
+ }
+
+ @Test
+ fun `hashed argument resolves to the current playlist url`() {
+ val expectedUrl = "https://example.com/playlist.m3u"
+
+ val resolved = resolvePlaylistConfigurationUrl(
+ playlistUrls = listOf("https://example.com/other.m3u", expectedUrl),
+ routeArgument = playlistConfigurationReference(expectedUrl),
+ )
+
+ assertEquals(expectedUrl, resolved)
+ }
+
+ @Test
+ fun `legacy raw url argument still resolves once`() {
+ val legacyRawUrl =
+ "https://example.com/get.php?username=legacy&password=secret"
+
+ val resolved = resolvePlaylistConfigurationUrl(
+ playlistUrls = listOf(legacyRawUrl),
+ routeArgument = legacyRawUrl,
+ )
+
+ assertEquals(legacyRawUrl, resolved)
+ }
+
+ @Test
+ fun `unknown argument does not resolve`() {
+ val resolved = resolvePlaylistConfigurationUrl(
+ playlistUrls = listOf("https://example.com/playlist.m3u"),
+ routeArgument = "playlist-work:unknown",
+ )
+
+ assertNull(resolved)
+ }
+}
diff --git a/business/playlist-configuration/src/test/java/com/m3u/business/playlist/configuration/PlaylistConfigurationStateTest.kt b/business/playlist-configuration/src/test/java/com/m3u/business/playlist/configuration/PlaylistConfigurationStateTest.kt
new file mode 100644
index 000000000..ba84c7933
--- /dev/null
+++ b/business/playlist-configuration/src/test/java/com/m3u/business/playlist/configuration/PlaylistConfigurationStateTest.kt
@@ -0,0 +1,84 @@
+package com.m3u.business.playlist.configuration
+
+import com.m3u.data.database.model.Playlist
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class PlaylistConfigurationStateTest {
+ @Test
+ fun `blank reference resolves to not found after playlists load`() {
+ assertEquals(
+ PlaylistConfigurationState.NotFound(
+ playlistReference = playlistConfigurationReference(""),
+ ),
+ resolvePlaylistConfigurationState(
+ playlists = listOf(PLAYLIST),
+ playlistReference = "",
+ ),
+ )
+ }
+
+ @Test
+ fun `stable reference resolves to content`() {
+ assertEquals(
+ PlaylistConfigurationState.Content(PLAYLIST),
+ resolvePlaylistConfigurationState(
+ playlists = listOf(PLAYLIST),
+ playlistReference = playlistConfigurationReference(PLAYLIST.url),
+ ),
+ )
+ }
+
+ @Test
+ fun `unknown reference resolves to not found`() {
+ assertEquals(
+ PlaylistConfigurationState.NotFound(
+ playlistReference = playlistConfigurationReference(
+ "playlist-work:unknown"
+ ),
+ ),
+ resolvePlaylistConfigurationState(
+ playlists = listOf(PLAYLIST),
+ playlistReference = "playlist-work:unknown",
+ ),
+ )
+ }
+
+ @Test
+ fun `playlist title is trimmed before persistence`() {
+ assertEquals(
+ "Living room",
+ normalizePlaylistTitle(" \u202ELiving\u2066 room\n"),
+ )
+ }
+
+ @Test
+ fun `blank playlist title is rejected`() {
+ assertNull(normalizePlaylistTitle(" \t\n"))
+ }
+
+ @Test
+ fun `playlist title is bounded before persistence`() {
+ assertEquals(
+ 256,
+ normalizePlaylistTitle("x".repeat(300))?.length,
+ )
+ }
+
+ @Test
+ fun `user agent removes controls and blank value becomes null`() {
+ assertEquals(
+ "M3U Android",
+ normalizePlaylistUserAgent(" \u202EM3U\n Android "),
+ )
+ assertNull(normalizePlaylistUserAgent("\u2066\t\n"))
+ }
+
+ private companion object {
+ val PLAYLIST = Playlist(
+ title = "Living room",
+ url = "https://example.com/playlist.m3u",
+ )
+ }
+}
diff --git a/business/playlist-configuration/src/test/java/com/m3u/business/playlist/configuration/PlaylistRefreshStatusTest.kt b/business/playlist-configuration/src/test/java/com/m3u/business/playlist/configuration/PlaylistRefreshStatusTest.kt
new file mode 100644
index 000000000..1a6812b62
--- /dev/null
+++ b/business/playlist-configuration/src/test/java/com/m3u/business/playlist/configuration/PlaylistRefreshStatusTest.kt
@@ -0,0 +1,84 @@
+package com.m3u.business.playlist.configuration
+
+import androidx.work.WorkInfo
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class PlaylistRefreshStatusTest {
+ @Test
+ fun `tracked work exposes every work manager phase`() {
+ val expected = mapOf(
+ WorkInfo.State.BLOCKED to PlaylistRefreshStatus.ENQUEUED,
+ WorkInfo.State.ENQUEUED to PlaylistRefreshStatus.ENQUEUED,
+ WorkInfo.State.RUNNING to PlaylistRefreshStatus.RUNNING,
+ WorkInfo.State.SUCCEEDED to PlaylistRefreshStatus.SUCCEEDED,
+ WorkInfo.State.FAILED to PlaylistRefreshStatus.FAILED,
+ WorkInfo.State.CANCELLED to PlaylistRefreshStatus.CANCELLED,
+ )
+
+ expected.forEach { (workState, refreshStatus) ->
+ assertEquals(
+ refreshStatus,
+ resolvePlaylistRefreshStatus(
+ launchPhase = RefreshLaunchPhase.WORK,
+ trackedWorkState = workState,
+ activeWorkState = null,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `cancelled work is not reported as failure`() {
+ assertEquals(
+ PlaylistRefreshStatus.CANCELLED,
+ resolvePlaylistRefreshStatus(
+ launchPhase = RefreshLaunchPhase.WORK,
+ trackedWorkState = WorkInfo.State.CANCELLED,
+ activeWorkState = null,
+ ),
+ )
+ }
+
+ @Test
+ fun `terminal history is ignored without a user tracked work`() {
+ listOf(
+ WorkInfo.State.SUCCEEDED,
+ WorkInfo.State.FAILED,
+ WorkInfo.State.CANCELLED,
+ ).forEach { historicalState ->
+ assertEquals(
+ PlaylistRefreshStatus.IDLE,
+ resolvePlaylistRefreshStatus(
+ launchPhase = RefreshLaunchPhase.IDLE,
+ trackedWorkState = null,
+ activeWorkState = historicalState,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `currently active background work remains visible`() {
+ assertEquals(
+ PlaylistRefreshStatus.RUNNING,
+ resolvePlaylistRefreshStatus(
+ launchPhase = RefreshLaunchPhase.IDLE,
+ trackedWorkState = null,
+ activeWorkState = WorkInfo.State.RUNNING,
+ ),
+ )
+ }
+
+ @Test
+ fun `enqueue failure is visible without a work id`() {
+ assertEquals(
+ PlaylistRefreshStatus.FAILED,
+ resolvePlaylistRefreshStatus(
+ launchPhase = RefreshLaunchPhase.FAILED,
+ trackedWorkState = null,
+ activeWorkState = null,
+ ),
+ )
+ }
+}
diff --git a/business/playlist/build.gradle.kts b/business/playlist/build.gradle.kts
index c761a8655..c831c38f6 100644
--- a/business/playlist/build.gradle.kts
+++ b/business/playlist/build.gradle.kts
@@ -1,8 +1,5 @@
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
-
plugins {
alias(libs.plugins.com.android.library)
- alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.com.google.dagger.hilt.android)
alias(libs.plugins.compose.compiler)
@@ -36,11 +33,3 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
}
-
-tasks.withType().configureEach {
- compilerOptions {
- freeCompilerArgs.addAll(
- "-opt-in=androidx.compose.material.ExperimentalMaterialApi"
- )
- }
-}
diff --git a/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt b/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt
index 86425f472..381dc7a99 100644
--- a/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt
+++ b/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistViewModel.kt
@@ -40,11 +40,13 @@ import com.m3u.data.parser.xtream.XtreamEpisodeInfo
import com.m3u.data.repository.channel.ChannelRepository
import com.m3u.data.repository.media.MediaRepository
import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.data.repository.playlist.PlaylistRefreshReason
import com.m3u.data.repository.programme.ProgrammeRepository
import com.m3u.data.service.MediaCommand
import com.m3u.data.service.Messager
import com.m3u.data.service.PlayerManager
import com.m3u.data.worker.SubscriptionWorker
+import com.m3u.data.worker.playlistWorkTag
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -125,11 +127,16 @@ class PlaylistViewModel @Inject constructor(
WorkInfo.State.ENQUEUED,
)
)
- .combine(playlistUrl) { infos, playlistUrl ->
+ .combine(playlist) { infos, playlist ->
+ val playlistUrl = playlist?.url ?: return@combine false
+ val workTag = playlistWorkTag(playlistUrl)
infos.any { info ->
- info.tags.containsAll(
- listOf(SubscriptionWorker.TAG, playlistUrl)
- )
+ SubscriptionWorker.TAG in info.tags &&
+ (
+ workTag in info.tags ||
+ // Compatibility with work enqueued before hashed tags shipped.
+ playlistUrl in info.tags
+ )
}
}
.flowOn(Dispatchers.Default)
@@ -139,10 +146,17 @@ class PlaylistViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5000)
)
- fun refresh() {
+ fun refresh(background: Boolean = false) {
val url = playlistUrl.value
viewModelScope.launch {
- playlistRepository.refresh(url)
+ playlistRepository.refresh(
+ url = url,
+ reason = if (background) {
+ PlaylistRefreshReason.BACKGROUND
+ } else {
+ PlaylistRefreshReason.USER
+ },
+ )
}
}
diff --git a/business/setting/build.gradle.kts b/business/setting/build.gradle.kts
index 8d5d37dc8..02d62d38f 100644
--- a/business/setting/build.gradle.kts
+++ b/business/setting/build.gradle.kts
@@ -1,6 +1,5 @@
plugins {
alias(libs.plugins.com.android.library)
- alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.com.google.dagger.hilt.android)
alias(libs.plugins.compose.compiler)
@@ -19,6 +18,7 @@ android {
dependencies {
implementation(project(":core:foundation"))
implementation(project(":data"))
+ implementation(project(":extension:api"))
implementation(libs.androidx.core.ktx)
@@ -32,4 +32,6 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.hilt.work)
+
+ testImplementation(kotlin("test-junit"))
}
diff --git a/business/setting/src/main/java/com/m3u/business/setting/ExtensionPluginDiscoveryState.kt b/business/setting/src/main/java/com/m3u/business/setting/ExtensionPluginDiscoveryState.kt
new file mode 100644
index 000000000..bc13d3781
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/ExtensionPluginDiscoveryState.kt
@@ -0,0 +1,37 @@
+package com.m3u.business.setting
+
+import com.m3u.data.repository.plugin.InstalledPlugin
+
+sealed interface ExtensionPluginDiscoveryState {
+ val plugins: List
+
+ data class Loading(
+ override val plugins: List = emptyList(),
+ ) : ExtensionPluginDiscoveryState
+
+ data object Empty : ExtensionPluginDiscoveryState {
+ override val plugins: List = emptyList()
+ }
+
+ data class Content(
+ override val plugins: List,
+ ) : ExtensionPluginDiscoveryState {
+ init {
+ require(plugins.isNotEmpty()) {
+ "Content requires at least one installed plugin"
+ }
+ }
+ }
+
+ data class Error(
+ override val plugins: List = emptyList(),
+ ) : ExtensionPluginDiscoveryState
+}
+
+internal fun List.toExtensionPluginDiscoveryState():
+ ExtensionPluginDiscoveryState =
+ if (isEmpty()) {
+ ExtensionPluginDiscoveryState.Empty
+ } else {
+ ExtensionPluginDiscoveryState.Content(toList())
+ }
diff --git a/business/setting/src/main/java/com/m3u/business/setting/ExtensionPluginOperationState.kt b/business/setting/src/main/java/com/m3u/business/setting/ExtensionPluginOperationState.kt
new file mode 100644
index 000000000..9450cffdf
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/ExtensionPluginOperationState.kt
@@ -0,0 +1,84 @@
+package com.m3u.business.setting
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+sealed interface ExtensionPluginOperation {
+ data object Refresh : ExtensionPluginOperation
+
+ data class Enable(
+ val packageName: String,
+ val serviceName: String,
+ ) : ExtensionPluginOperation
+
+ data class Reauthorize(
+ val packageName: String,
+ val serviceName: String,
+ ) : ExtensionPluginOperation
+
+ data class Disable(
+ val extensionId: String,
+ ) : ExtensionPluginOperation
+
+ data class ClearData(
+ val packageName: String,
+ val serviceName: String,
+ val extensionId: String,
+ ) : ExtensionPluginOperation
+
+ data class Revoke(
+ val packageName: String,
+ val serviceName: String,
+ val extensionId: String,
+ ) : ExtensionPluginOperation
+}
+
+sealed interface ExtensionPluginOperationState {
+ data object Idle : ExtensionPluginOperationState
+
+ data class Running(
+ val operation: ExtensionPluginOperation,
+ ) : ExtensionPluginOperationState
+}
+
+internal class ExtensionPluginOperationController {
+ private val lock = Any()
+ private var refreshPending = false
+ private val _state = MutableStateFlow(
+ ExtensionPluginOperationState.Idle
+ )
+ val state: StateFlow = _state
+
+ fun tryStart(
+ operation: ExtensionPluginOperation,
+ queueRefreshIfBusy: Boolean = false,
+ ): ExtensionPluginOperationState.Running? {
+ val running = ExtensionPluginOperationState.Running(operation)
+ return synchronized(lock) {
+ if (_state.value != ExtensionPluginOperationState.Idle) {
+ if (
+ queueRefreshIfBusy &&
+ operation == ExtensionPluginOperation.Refresh
+ ) {
+ refreshPending = true
+ }
+ null
+ } else {
+ _state.value = running
+ running
+ }
+ }
+ }
+
+ fun finishAndConsumePendingRefresh(
+ running: ExtensionPluginOperationState.Running,
+ ): Boolean =
+ synchronized(lock) {
+ if (_state.value === running) {
+ _state.value = ExtensionPluginOperationState.Idle
+ refreshPending.also { refreshPending = false }
+ } else {
+ false
+ }
+ }
+}
diff --git a/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingInputValidation.kt b/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingInputValidation.kt
new file mode 100644
index 000000000..267f2ffe5
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingInputValidation.kt
@@ -0,0 +1,82 @@
+package com.m3u.business.setting
+
+import com.m3u.extension.api.ExtensionNetworkOrigin
+import com.m3u.extension.api.ExtensionSettingField
+import com.m3u.extension.api.ExtensionSettingType
+
+enum class ExtensionSettingInputError {
+ REQUIRED,
+ TOO_LONG,
+ INVALID_NUMBER,
+ INVALID_BOOLEAN,
+ INVALID_CHOICE,
+ INVALID_NETWORK_ORIGIN,
+}
+
+fun ExtensionSettingField.extensionSettingInputError(
+ rawValue: String,
+ secretConfigured: Boolean,
+): ExtensionSettingInputError? {
+ if (rawValue.length > MAX_EXTENSION_SETTING_VALUE_LENGTH) {
+ return ExtensionSettingInputError.TOO_LONG
+ }
+ if (
+ type == ExtensionSettingType.SECRET &&
+ secretConfigured &&
+ rawValue.isEmpty()
+ ) {
+ return null
+ }
+ if (required && rawValue.isBlank()) {
+ return ExtensionSettingInputError.REQUIRED
+ }
+ if (!required && rawValue.isEmpty()) {
+ return null
+ }
+ if (networkOrigin) {
+ return ExtensionSettingInputError.INVALID_NETWORK_ORIGIN.takeUnless {
+ runCatching { ExtensionNetworkOrigin(rawValue) }.isSuccess
+ }
+ }
+ return when (type) {
+ ExtensionSettingType.TEXT,
+ ExtensionSettingType.SECRET -> null
+
+ ExtensionSettingType.NUMBER -> ExtensionSettingInputError.INVALID_NUMBER.takeUnless {
+ rawValue.canonicalExtensionNumberOrNull() != null
+ }
+
+ ExtensionSettingType.BOOLEAN -> ExtensionSettingInputError.INVALID_BOOLEAN.takeUnless {
+ rawValue.toBooleanStrictOrNull() != null
+ }
+
+ ExtensionSettingType.SINGLE_CHOICE ->
+ ExtensionSettingInputError.INVALID_CHOICE.takeUnless {
+ choices.any { choice -> choice.value == rawValue }
+ }
+ }
+}
+
+fun ExtensionSettingField.normalizedExtensionSettingValue(rawValue: String): String =
+ if (type == ExtensionSettingType.NUMBER) {
+ rawValue.canonicalExtensionNumberOrNull() ?: rawValue
+ } else {
+ rawValue
+ }
+
+fun String.canonicalExtensionNumberOrNull(): String? {
+ val trimmed = trim()
+ val normalized = if (
+ '.' !in trimmed &&
+ trimmed.count { character -> character == ',' } == 1
+ ) {
+ trimmed.replace(',', '.')
+ } else {
+ trimmed
+ }
+ return normalized.takeIf { value ->
+ value.toDoubleOrNull()?.isFinite() == true
+ }
+}
+
+private const val MAX_EXTENSION_SETTING_VALUE_LENGTH = 16_384
diff --git a/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingUpdateGate.kt b/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingUpdateGate.kt
new file mode 100644
index 000000000..713cb9f48
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingUpdateGate.kt
@@ -0,0 +1,32 @@
+package com.m3u.business.setting
+
+internal class ExtensionSettingUpdateGate {
+ private val activeKeys = mutableMapOf>()
+
+ fun tryStart(
+ extensionId: String,
+ qualifiedKey: String,
+ ): Boolean = synchronized(activeKeys) {
+ activeKeys
+ .getOrPut(extensionId, ::mutableSetOf)
+ .add(qualifiedKey)
+ }
+
+ fun finish(
+ extensionId: String,
+ qualifiedKey: String,
+ ) {
+ synchronized(activeKeys) {
+ activeKeys[extensionId]?.let { keys ->
+ keys.remove(qualifiedKey)
+ if (keys.isEmpty()) activeKeys.remove(extensionId)
+ }
+ }
+ }
+
+ fun clear(extensionId: String) {
+ synchronized(activeKeys) {
+ activeKeys.remove(extensionId)
+ }
+ }
+}
diff --git a/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingsOperationQueue.kt b/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingsOperationQueue.kt
new file mode 100644
index 000000000..bf9d024f0
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingsOperationQueue.kt
@@ -0,0 +1,106 @@
+package com.m3u.business.setting
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.joinAll
+import kotlinx.coroutines.launch
+
+class ExtensionSettingsOperationQueue(
+ private val scope: CoroutineScope,
+ private val onFailure: suspend (Exception) -> Unit,
+) {
+ private val stateLock = Any()
+ private val operationJobs = mutableMapOf>()
+ private val updateJobs = mutableMapOf>()
+
+ fun launchUpdate(
+ extensionId: String,
+ operation: suspend () -> Unit,
+ ): Job = enqueue(
+ extensionId = extensionId,
+ cancelPendingUpdates = false,
+ trackAsUpdate = true,
+ operation = operation,
+ )
+
+ fun launchOperation(
+ extensionId: String,
+ operation: suspend () -> Unit,
+ ): Job = enqueue(
+ extensionId = extensionId,
+ cancelPendingUpdates = false,
+ trackAsUpdate = false,
+ operation = operation,
+ )
+
+ fun launchDestructive(
+ extensionId: String,
+ operation: suspend () -> Unit,
+ ): Job = enqueue(
+ extensionId = extensionId,
+ cancelPendingUpdates = true,
+ trackAsUpdate = false,
+ operation = operation,
+ )
+
+ private fun enqueue(
+ extensionId: String,
+ cancelPendingUpdates: Boolean,
+ trackAsUpdate: Boolean,
+ operation: suspend () -> Unit,
+ ): Job {
+ lateinit var job: Job
+ val pendingUpdates: List
+ synchronized(stateLock) {
+ val operationsToAwait = operationJobs[extensionId].orEmpty().toList()
+ pendingUpdates = if (cancelPendingUpdates) {
+ updateJobs[extensionId].orEmpty().toList()
+ } else {
+ emptyList()
+ }
+ job = scope.launch(start = CoroutineStart.LAZY) {
+ try {
+ operationsToAwait.joinAll()
+ coroutineContext.ensureActive()
+ operation()
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (failure: Exception) {
+ onFailure(failure)
+ }
+ }
+ operationJobs.getOrPut(extensionId, ::mutableSetOf).add(job)
+ if (trackAsUpdate) {
+ updateJobs.getOrPut(extensionId, ::mutableSetOf).add(job)
+ }
+ job.invokeOnCompletion {
+ removeCompletedOperation(extensionId, job, trackAsUpdate)
+ }
+ }
+ pendingUpdates.forEach(Job::cancel)
+ job.start()
+ return job
+ }
+
+ private fun removeCompletedOperation(
+ extensionId: String,
+ job: Job,
+ trackedAsUpdate: Boolean,
+ ) {
+ synchronized(stateLock) {
+ operationJobs[extensionId]?.let { jobs ->
+ jobs.remove(job)
+ if (jobs.isEmpty()) operationJobs.remove(extensionId)
+ }
+ if (trackedAsUpdate) {
+ updateJobs[extensionId]?.let { jobs ->
+ jobs.remove(job)
+ if (jobs.isEmpty()) updateJobs.remove(extensionId)
+ }
+ }
+ }
+ }
+}
diff --git a/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingsState.kt b/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingsState.kt
new file mode 100644
index 000000000..7418a4938
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/ExtensionSettingsState.kt
@@ -0,0 +1,43 @@
+package com.m3u.business.setting
+
+import com.m3u.data.repository.extension.ExtensionSettingsConfiguration
+import com.m3u.extension.api.ExtensionId
+
+sealed interface ExtensionSettingsState {
+ val extensionId: ExtensionId?
+
+ data object Closed : ExtensionSettingsState {
+ override val extensionId: ExtensionId? = null
+ }
+
+ data class Loading(
+ override val extensionId: ExtensionId,
+ ) : ExtensionSettingsState
+
+ data class Content(
+ val configuration: ExtensionSettingsConfiguration,
+ val updatingKeys: Set = emptySet(),
+ ) : ExtensionSettingsState {
+ override val extensionId: ExtensionId = configuration.extensionId
+ }
+
+ data class Unavailable(
+ override val extensionId: ExtensionId,
+ ) : ExtensionSettingsState
+
+ data class Error(
+ override val extensionId: ExtensionId,
+ ) : ExtensionSettingsState
+}
+
+internal fun ExtensionSettingsConfiguration?.toExtensionSettingsState(
+ extensionId: ExtensionId,
+ updatingKeys: Set = emptySet(),
+): ExtensionSettingsState =
+ this?.let { configuration ->
+ ExtensionSettingsState.Content(
+ configuration = configuration,
+ updatingKeys = updatingKeys,
+ )
+ }
+ ?: ExtensionSettingsState.Unavailable(extensionId)
diff --git a/business/setting/src/main/java/com/m3u/business/setting/PlaylistSubscriptionInput.kt b/business/setting/src/main/java/com/m3u/business/setting/PlaylistSubscriptionInput.kt
new file mode 100644
index 000000000..9f56a05b9
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/PlaylistSubscriptionInput.kt
@@ -0,0 +1,178 @@
+package com.m3u.business.setting
+
+import com.m3u.core.foundation.util.basic.PlaylistInputKind
+import com.m3u.core.foundation.util.basic.normalizePlaylistInputForSubmission
+import com.m3u.data.database.model.DataSource
+import com.m3u.data.parser.xtream.XtreamInput
+
+internal class SubscriptionDraftSession {
+ private var activeKey: String? = null
+
+ fun begin(draftKey: String): Boolean {
+ require(draftKey.isNotBlank()) { "Subscription draft key must not be blank" }
+ if (activeKey == draftKey) return false
+ activeKey = draftKey
+ return true
+ }
+}
+
+internal class ClipboardPlaylistInput private constructor(
+ val title: String,
+ val m3uUrl: String?,
+ val xtreamInput: XtreamInput?,
+) {
+ companion object {
+ fun parse(
+ rawUrl: String,
+ source: DataSource,
+ ): ClipboardPlaylistInput {
+ val normalizedUrl = rawUrl.normalizePlaylistInputForSubmission(
+ PlaylistInputKind.URL
+ )
+ val safePathTitle = normalizedUrl
+ .safePlaylistFilenameTitleOrNull()
+ .orEmpty()
+
+ if (source != DataSource.Xtream) {
+ return ClipboardPlaylistInput(
+ title = safePathTitle,
+ m3uUrl = normalizedUrl.takeIf { source == DataSource.M3U },
+ xtreamInput = null,
+ )
+ }
+
+ val input = XtreamInput.decodeFromPlaylistUrlOrNull(normalizedUrl)
+ return ClipboardPlaylistInput(
+ // An account URL does not contain a trustworthy user-facing playlist name.
+ // Keep the surrounding localized title field explicit instead of inventing an
+ // English timestamp label.
+ title = "",
+ m3uUrl = null,
+ xtreamInput = input?.copy(
+ basicUrl = input.basicUrl.normalizePlaylistInputForSubmission(
+ PlaylistInputKind.BASE_URL
+ ),
+ username = input.username.normalizePlaylistInputForSubmission(
+ PlaylistInputKind.USERNAME
+ ),
+ password = input.password.normalizePlaylistInputForSubmission(
+ PlaylistInputKind.PASSWORD
+ ),
+ ),
+ )
+ }
+ }
+}
+
+private fun String.safePlaylistFilenameTitleOrNull(): String? {
+ val withoutFragment = substringBefore('#')
+ val withoutQuery = withoutFragment.substringBefore('?')
+ val schemeSeparator = withoutQuery.indexOf("://")
+ val encodedFilename = if (schemeSeparator >= 0) {
+ val authorityStart = schemeSeparator + 3
+ val pathStart = withoutQuery.indexOf('/', authorityStart)
+ if (pathStart < 0) return null
+ withoutQuery.substring(pathStart + 1).substringAfterLast('/')
+ } else {
+ val filename = withoutQuery.substringAfterLast('/')
+ if (
+ '/' !in withoutQuery &&
+ filename.any { character ->
+ character == '@' || character == ':' || character == '=' || character == '&'
+ }
+ ) {
+ return null
+ }
+ filename
+ }
+ if (encodedFilename.isBlank()) return null
+
+ val decodedFilename = encodedFilename
+ .decodePercentEncodedPathSegment()
+ .substringAfterLast('/')
+ .substringAfterLast('\\')
+ val extensionSeparator = decodedFilename.lastIndexOf('.')
+ val filenameWithoutExtension = if (extensionSeparator > 0) {
+ decodedFilename.substring(0, extensionSeparator)
+ } else {
+ decodedFilename
+ }
+ val normalized = filenameWithoutExtension.normalizePlaylistInputForSubmission(
+ PlaylistInputKind.TITLE
+ )
+ if (
+ normalized.isBlank() ||
+ normalized == "." ||
+ normalized == ".." ||
+ normalized.containsSensitiveTitleMaterial()
+ ) {
+ return null
+ }
+ return normalized
+}
+
+private fun String.containsSensitiveTitleMaterial(): Boolean {
+ if (any { character ->
+ character == '@' ||
+ character == '=' ||
+ character == '&' ||
+ character == '?' ||
+ character == '#' ||
+ character == ':'
+ }
+ ) {
+ return true
+ }
+ return SENSITIVE_TITLE_WORDS.any { word -> contains(word, ignoreCase = true) }
+}
+
+private fun String.decodePercentEncodedPathSegment(): String {
+ if ('%' !in this) return this
+
+ val output = StringBuilder(length)
+ var index = 0
+ while (index < length) {
+ if (this[index] != '%' || index + 2 >= length) {
+ output.append(this[index])
+ index++
+ continue
+ }
+
+ val bytes = mutableListOf()
+ var encodedIndex = index
+ while (
+ encodedIndex + 2 < length &&
+ this[encodedIndex] == '%'
+ ) {
+ val high = this[encodedIndex + 1].hexDigitOrNull() ?: break
+ val low = this[encodedIndex + 2].hexDigitOrNull() ?: break
+ bytes += ((high shl 4) or low).toByte()
+ encodedIndex += 3
+ }
+ if (bytes.isEmpty()) {
+ output.append(this[index])
+ index++
+ } else {
+ output.append(bytes.toByteArray().decodeToString())
+ index = encodedIndex
+ }
+ }
+ return output.toString()
+}
+
+private fun Char.hexDigitOrNull(): Int? = when (this) {
+ in '0'..'9' -> code - '0'.code
+ in 'a'..'f' -> code - 'a'.code + 10
+ in 'A'..'F' -> code - 'A'.code + 10
+ else -> null
+}
+
+private val SENSITIVE_TITLE_WORDS = setOf(
+ "authorization",
+ "credential",
+ "passwd",
+ "password",
+ "secret",
+ "token",
+ "username",
+)
diff --git a/business/setting/src/main/java/com/m3u/business/setting/PlaylistSubscriptionState.kt b/business/setting/src/main/java/com/m3u/business/setting/PlaylistSubscriptionState.kt
new file mode 100644
index 000000000..bde98d115
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/PlaylistSubscriptionState.kt
@@ -0,0 +1,95 @@
+package com.m3u.business.setting
+
+import androidx.work.WorkInfo
+import com.m3u.core.foundation.util.basic.PlaylistInputKind
+import com.m3u.core.foundation.util.basic.normalizePlaylistInputForSubmission
+import com.m3u.data.database.model.DataSource
+import java.util.UUID
+
+enum class PlaylistSubscriptionPhase {
+ IDLE,
+ ENQUEUED,
+ RUNNING,
+ SUCCEEDED,
+ FAILED,
+ CANCELLED,
+}
+
+data class PlaylistSubscriptionState(
+ val phase: PlaylistSubscriptionPhase,
+ val title: String? = null,
+ val source: DataSource? = null,
+ val workId: UUID? = null,
+) {
+ val isInProgress: Boolean
+ get() = phase == PlaylistSubscriptionPhase.ENQUEUED ||
+ phase == PlaylistSubscriptionPhase.RUNNING
+
+ val isTerminal: Boolean
+ get() = phase == PlaylistSubscriptionPhase.SUCCEEDED ||
+ phase == PlaylistSubscriptionPhase.FAILED ||
+ phase == PlaylistSubscriptionPhase.CANCELLED
+
+ companion object {
+ val Idle = PlaylistSubscriptionState(
+ phase = PlaylistSubscriptionPhase.IDLE,
+ )
+ }
+}
+
+internal data class PlaylistSubscriptionTracking(
+ val title: String,
+ val source: DataSource,
+ val workId: UUID,
+)
+
+internal fun createPlaylistSubscriptionTracking(
+ title: String,
+ source: DataSource,
+ workId: UUID,
+): PlaylistSubscriptionTracking? {
+ if (source != DataSource.M3U && source != DataSource.Xtream) return null
+ val sanitizedTitle = title.normalizePlaylistInputForSubmission(
+ PlaylistInputKind.TITLE
+ )
+ if (sanitizedTitle.isBlank()) return null
+ return PlaylistSubscriptionTracking(
+ title = sanitizedTitle,
+ source = source,
+ workId = workId,
+ )
+}
+
+internal fun restorePlaylistSubscriptionTracking(
+ title: String,
+ sourceValue: String,
+ workIdValue: String,
+): PlaylistSubscriptionTracking? {
+ val source = DataSource.ofOrNull(sourceValue) ?: return null
+ val workId = runCatching { UUID.fromString(workIdValue) }
+ .getOrNull()
+ ?: return null
+ return createPlaylistSubscriptionTracking(
+ title = title,
+ source = source,
+ workId = workId,
+ )
+}
+
+internal fun resolvePlaylistSubscriptionState(
+ tracking: PlaylistSubscriptionTracking,
+ workState: WorkInfo.State?,
+): PlaylistSubscriptionState = PlaylistSubscriptionState(
+ phase = when (workState) {
+ null,
+ WorkInfo.State.BLOCKED,
+ WorkInfo.State.ENQUEUED -> PlaylistSubscriptionPhase.ENQUEUED
+ WorkInfo.State.RUNNING -> PlaylistSubscriptionPhase.RUNNING
+ WorkInfo.State.SUCCEEDED -> PlaylistSubscriptionPhase.SUCCEEDED
+ WorkInfo.State.FAILED -> PlaylistSubscriptionPhase.FAILED
+ WorkInfo.State.CANCELLED -> PlaylistSubscriptionPhase.CANCELLED
+ },
+ title = tracking.title,
+ source = tracking.source,
+ workId = tracking.workId,
+)
diff --git a/business/setting/src/main/java/com/m3u/business/setting/ProviderDiscoveryState.kt b/business/setting/src/main/java/com/m3u/business/setting/ProviderDiscoveryState.kt
new file mode 100644
index 000000000..d0895697b
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/ProviderDiscoveryState.kt
@@ -0,0 +1,102 @@
+package com.m3u.business.setting
+
+import com.m3u.data.repository.provider.DiscoveredSubscriptionProvider
+import com.m3u.data.repository.provider.SubscriptionProviderExecutionKind
+import com.m3u.extension.api.ExtensionId
+import com.m3u.extension.api.subscription.ProviderKind
+
+sealed interface ProviderDiscoveryState {
+ data object Loading : ProviderDiscoveryState
+
+ data class Ready(
+ val providers: List,
+ ) : ProviderDiscoveryState {
+ init {
+ require(providers.isNotEmpty())
+ }
+ }
+
+ data object Empty : ProviderDiscoveryState
+
+ data class Failed(
+ val failureCount: Int?,
+ ) : ProviderDiscoveryState
+}
+
+internal fun List.toProviderDiscoveryState(): ProviderDiscoveryState =
+ if (isEmpty()) ProviderDiscoveryState.Empty else ProviderDiscoveryState.Ready(this)
+
+data class ProviderSubscriptionSource(
+ val providerId: ExtensionId,
+ val providerKind: ProviderKind,
+ val providerDisplayName: String,
+ val displayName: String,
+ val executionKind: SubscriptionProviderExecutionKind,
+)
+
+fun ProviderDiscoveryState.subscriptionSources(): List =
+ (this as? ProviderDiscoveryState.Ready)
+ ?.providers
+ .orEmpty()
+ .flatMap { provider ->
+ provider.descriptor.variants
+ .filter { variant -> variant.userSelectable }
+ .map { variant ->
+ ProviderSubscriptionSource(
+ providerId = provider.descriptor.providerId,
+ providerKind = variant.kind,
+ providerDisplayName = provider.descriptor.displayName,
+ displayName = variant.displayName,
+ executionKind = provider.executionKind,
+ )
+ }
+ }
+
+fun ProviderDiscoveryState.supports(
+ form: ProviderSubscriptionForm?,
+): Boolean {
+ if (form == null) return false
+ return (this as? ProviderDiscoveryState.Ready)
+ ?.providers
+ .orEmpty()
+ .any { provider ->
+ provider.descriptor.providerId == form.providerId &&
+ provider.descriptor.variants.any { variant ->
+ variant.kind == form.providerKind &&
+ (variant.userSelectable || form.reauthenticationPlaylistUrl != null)
+ }
+ }
+}
+
+internal fun List.reconcileSubscriptionForm(
+ current: ProviderSubscriptionForm?,
+): ProviderSubscriptionForm? {
+ if (current == null) {
+ val (descriptor, kind) = firstNotNullOfOrNull { provider ->
+ provider.descriptor.variants
+ .firstOrNull { variant -> variant.userSelectable }
+ ?.let { variant -> provider.descriptor to variant.kind }
+ } ?: return null
+ return ProviderSubscriptionForm.create(
+ descriptor = descriptor,
+ providerKind = kind,
+ )
+ }
+
+ val descriptor = firstOrNull { provider ->
+ provider.descriptor.providerId == current.providerId
+ }?.descriptor ?: return current
+ if (descriptor.variants.none { variant -> variant.kind == current.providerKind }) {
+ return current
+ }
+
+ val currentDefinitions = current.fields.map(ProviderSubscriptionFormField::definition)
+ return if (
+ current.schemaVersion != descriptor.settingsSchema?.version ||
+ currentDefinitions != descriptor.settingsSchema?.fields.orEmpty()
+ ) {
+ current.updateDescriptor(descriptor)
+ } else {
+ current
+ }
+}
diff --git a/business/setting/src/main/java/com/m3u/business/setting/ProviderOperationState.kt b/business/setting/src/main/java/com/m3u/business/setting/ProviderOperationState.kt
new file mode 100644
index 000000000..81dc8ace1
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/ProviderOperationState.kt
@@ -0,0 +1,25 @@
+package com.m3u.business.setting
+
+import com.m3u.extension.api.ExtensionId
+import com.m3u.extension.api.subscription.ProviderKind
+
+data class ProviderSubmissionOperation(
+ val providerId: ExtensionId,
+ val providerKind: ProviderKind,
+ val reauthenticationPlaylistUrl: String? = null,
+)
+
+data class ProviderOperationState(
+ val submission: ProviderSubmissionOperation? = null,
+ val preparingReauthenticationPlaylistUrl: String? = null,
+) {
+ val isSubmitting: Boolean
+ get() = submission != null
+
+ val isBusy: Boolean
+ get() = submission != null || preparingReauthenticationPlaylistUrl != null
+
+ fun isReauthenticating(playlistUrl: String): Boolean =
+ preparingReauthenticationPlaylistUrl == playlistUrl ||
+ submission?.reauthenticationPlaylistUrl == playlistUrl
+}
diff --git a/business/setting/src/main/java/com/m3u/business/setting/ProviderSubscriptionForm.kt b/business/setting/src/main/java/com/m3u/business/setting/ProviderSubscriptionForm.kt
new file mode 100644
index 000000000..7b0c2e532
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/ProviderSubscriptionForm.kt
@@ -0,0 +1,292 @@
+package com.m3u.business.setting
+
+import com.m3u.data.repository.provider.ProviderAccountSummary
+import com.m3u.data.repository.provider.ProviderSubscriptionRequest
+import com.m3u.core.foundation.util.basic.sanitizeDisplayText
+import com.m3u.extension.api.ExtensionId
+import com.m3u.extension.api.ExtensionSettingField
+import com.m3u.extension.api.ExtensionSettingType
+import com.m3u.extension.api.security.CredentialHandle
+import com.m3u.extension.api.subscription.ProviderKind
+import com.m3u.extension.api.subscription.SubscriptionProviderDescriptor
+import com.m3u.extension.api.subscription.SubscriptionProviderSettingKeys
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.contentOrNull
+
+enum class ProviderSettingFieldError {
+ REQUIRED,
+ TOO_LONG,
+ UNSAFE_VALUE,
+ INVALID_NUMBER,
+ INVALID_BOOLEAN,
+ INVALID_CHOICE,
+}
+
+data class ProviderSubscriptionFormField(
+ val definition: ExtensionSettingField,
+ val input: String? = null,
+ val inputBoundaryError: ProviderSettingFieldError? = null,
+ val error: ProviderSettingFieldError? = null,
+) {
+ val value: String?
+ get() = input
+ ?.takeUnless(String::isBlank)
+ ?: definition.defaultValue.asSettingValueOrNull(definition.type)
+
+ val isUsingDefault: Boolean
+ get() = input.isNullOrBlank() && value != null
+}
+
+data class ProviderSubscriptionForm(
+ val providerId: ExtensionId,
+ val providerKind: ProviderKind,
+ val schemaVersion: Int?,
+ val fields: List,
+ val reauthenticationPlaylistUrl: String? = null,
+ val validationRequested: Boolean = false,
+) {
+ fun update(fieldKey: String, value: String?): ProviderSubscriptionForm {
+ if (fields.none { field -> field.definition.key == fieldKey }) return this
+ return copy(
+ fields = fields.map { field ->
+ if (field.definition.key == fieldKey) {
+ val normalized = field.normalizeInput(value)
+ field.copy(
+ input = normalized.value,
+ inputBoundaryError = normalized.error,
+ error = null,
+ )
+ } else {
+ field
+ }
+ },
+ ).validateFields()
+ }
+
+ fun updateDescriptor(
+ descriptor: SubscriptionProviderDescriptor,
+ ): ProviderSubscriptionForm {
+ require(descriptor.providerId == providerId) {
+ "Provider descriptor does not match the form"
+ }
+ require(descriptor.variants.any { variant -> variant.kind == providerKind }) {
+ "Provider descriptor does not support ${providerKind.value}"
+ }
+ if (schemaVersion != descriptor.settingsSchema?.version) {
+ return create(descriptor, providerKind).copy(
+ reauthenticationPlaylistUrl = reauthenticationPlaylistUrl,
+ )
+ }
+ val previousFields = fields.associateBy { field -> field.definition.key }
+ return copy(
+ schemaVersion = descriptor.settingsSchema?.version,
+ fields = descriptor.settingsSchema?.fields.orEmpty().map { definition ->
+ val previous = previousFields[definition.key]
+ ?.takeIf { field -> field.definition.type == definition.type }
+ ProviderSubscriptionFormField(
+ definition = definition,
+ input = previous?.input,
+ inputBoundaryError = previous?.inputBoundaryError,
+ )
+ },
+ ).validateFields()
+ }
+
+ fun buildRequest(
+ title: String,
+ stageCredential: (String) -> CredentialHandle,
+ ): ProviderSubscriptionFormBuildResult {
+ val validated = copy(validationRequested = true).validateFields()
+ if (validated.fields.any { field -> field.error != null }) {
+ return ProviderSubscriptionFormBuildResult.Invalid(validated)
+ }
+
+ val settingValues = linkedMapOf()
+ val secrets = linkedMapOf()
+ validated.fields.forEach { field ->
+ val value = field.value ?: return@forEach
+ when (field.definition.type) {
+ ExtensionSettingType.SECRET -> secrets[field.definition.key] = value
+ else -> settingValues[field.definition.key] = value.normalizedFor(field.definition.type)
+ }
+ }
+
+ val credentialHandles = secrets.mapValues { (_, secret) -> stageCredential(secret) }
+ return ProviderSubscriptionFormBuildResult.Ready(
+ ProviderSubscriptionRequest(
+ title = title,
+ providerId = providerId,
+ providerKind = providerKind,
+ settingValues = settingValues,
+ credentialHandles = credentialHandles,
+ reauthenticationPlaylistUrl = reauthenticationPlaylistUrl,
+ )
+ )
+ }
+
+ private fun validateFields(): ProviderSubscriptionForm = copy(
+ fields = fields.map { field ->
+ field.copy(
+ error = field.inputBoundaryError
+ ?: field.definition.validationError(
+ value = field.value,
+ showRequired = validationRequested,
+ )
+ )
+ },
+ )
+
+ companion object {
+ fun create(
+ descriptor: SubscriptionProviderDescriptor,
+ providerKind: ProviderKind,
+ ): ProviderSubscriptionForm {
+ require(descriptor.variants.any { variant -> variant.kind == providerKind }) {
+ "Provider ${descriptor.providerId.value} does not support ${providerKind.value}"
+ }
+ return ProviderSubscriptionForm(
+ providerId = descriptor.providerId,
+ providerKind = providerKind,
+ schemaVersion = descriptor.settingsSchema?.version,
+ fields = descriptor.settingsSchema?.fields.orEmpty().map { definition ->
+ ProviderSubscriptionFormField(definition = definition)
+ },
+ ).validateFields()
+ }
+
+ fun createForReauthentication(
+ descriptor: SubscriptionProviderDescriptor,
+ account: ProviderAccountSummary,
+ ): ProviderSubscriptionForm {
+ require(descriptor.providerId == account.providerId) {
+ "Provider descriptor does not match the account"
+ }
+ require(descriptor.variants.any { variant -> variant.kind == account.providerKind }) {
+ "Provider descriptor does not support the account kind"
+ }
+ return create(descriptor, account.providerKind)
+ .update(SubscriptionProviderSettingKeys.BaseUrl, account.baseUrl)
+ .update(SubscriptionProviderSettingKeys.Username, account.username)
+ .copy(reauthenticationPlaylistUrl = account.playlistUrl)
+ }
+ }
+}
+
+sealed interface ProviderSubscriptionFormBuildResult {
+ data class Ready(
+ val request: ProviderSubscriptionRequest,
+ ) : ProviderSubscriptionFormBuildResult
+
+ data class Invalid(
+ val form: ProviderSubscriptionForm,
+ ) : ProviderSubscriptionFormBuildResult
+}
+
+internal fun ProviderSubscriptionForm?.matchesNewSubscription(
+ descriptor: SubscriptionProviderDescriptor,
+ providerKind: ProviderKind,
+): Boolean =
+ this != null &&
+ providerId == descriptor.providerId &&
+ this.providerKind == providerKind &&
+ schemaVersion == descriptor.settingsSchema?.version &&
+ reauthenticationPlaylistUrl == null
+
+private fun ExtensionSettingField.validationError(
+ value: String?,
+ showRequired: Boolean,
+): ProviderSettingFieldError? {
+ if (value == null) {
+ return ProviderSettingFieldError.REQUIRED.takeIf { required && showRequired }
+ }
+ if (
+ type in setOf(ExtensionSettingType.TEXT, ExtensionSettingType.SECRET) &&
+ value.length > MAX_PROVIDER_SETTING_VALUE_LENGTH
+ ) {
+ return ProviderSettingFieldError.TOO_LONG
+ }
+ return when (type) {
+ ExtensionSettingType.TEXT,
+ ExtensionSettingType.SECRET -> null
+
+ ExtensionSettingType.NUMBER -> ProviderSettingFieldError.INVALID_NUMBER.takeUnless {
+ value.canonicalExtensionNumberOrNull() != null
+ }
+
+ ExtensionSettingType.BOOLEAN -> ProviderSettingFieldError.INVALID_BOOLEAN.takeUnless {
+ value.trim().toBooleanStrictOrNull() != null
+ }
+
+ ExtensionSettingType.SINGLE_CHOICE -> ProviderSettingFieldError.INVALID_CHOICE.takeUnless {
+ choices.any { choice -> choice.value == value }
+ }
+ }
+}
+
+private const val MAX_PROVIDER_SETTING_VALUE_LENGTH = 4_096
+private const val MAX_PROVIDER_SETTING_VALUE_UTF8_BYTES = 4_096
+
+private data class NormalizedProviderInput(
+ val value: String?,
+ val error: ProviderSettingFieldError? = null,
+)
+
+private fun ProviderSubscriptionFormField.normalizeInput(
+ rawValue: String?,
+): NormalizedProviderInput {
+ rawValue ?: return NormalizedProviderInput(value = null)
+ val safeValue = rawValue.sanitizeDisplayText(
+ maximumCharacters = rawValue.length,
+ maximumUtf8Bytes = Int.MAX_VALUE,
+ )
+ if (definition.type == ExtensionSettingType.SECRET && safeValue != rawValue) {
+ return NormalizedProviderInput(
+ value = input,
+ error = ProviderSettingFieldError.UNSAFE_VALUE,
+ )
+ }
+
+ val boundedValue = safeValue.sanitizeDisplayText(
+ maximumCharacters = MAX_PROVIDER_SETTING_VALUE_LENGTH,
+ maximumUtf8Bytes = MAX_PROVIDER_SETTING_VALUE_UTF8_BYTES,
+ )
+ if (boundedValue != safeValue) {
+ return if (definition.type == ExtensionSettingType.SECRET) {
+ NormalizedProviderInput(
+ value = input,
+ error = ProviderSettingFieldError.TOO_LONG,
+ )
+ } else {
+ NormalizedProviderInput(
+ value = boundedValue,
+ error = ProviderSettingFieldError.TOO_LONG,
+ )
+ }
+ }
+ return NormalizedProviderInput(value = boundedValue)
+}
+
+private fun String.normalizedFor(type: ExtensionSettingType): String = when (type) {
+ ExtensionSettingType.NUMBER -> canonicalExtensionNumberOrNull() ?: trim()
+ ExtensionSettingType.BOOLEAN -> trim()
+
+ ExtensionSettingType.TEXT,
+ ExtensionSettingType.SECRET,
+ ExtensionSettingType.SINGLE_CHOICE -> this
+}
+
+private fun JsonElement?.asSettingValueOrNull(
+ type: ExtensionSettingType,
+): String? {
+ val primitive = this as? JsonPrimitive ?: return null
+ return when (type) {
+ ExtensionSettingType.BOOLEAN -> primitive.booleanOrNull?.toString()
+ ExtensionSettingType.TEXT,
+ ExtensionSettingType.NUMBER,
+ ExtensionSettingType.SINGLE_CHOICE -> primitive.contentOrNull
+
+ ExtensionSettingType.SECRET -> null
+ }
+}
diff --git a/business/setting/src/main/java/com/m3u/business/setting/SettingMessage.kt b/business/setting/src/main/java/com/m3u/business/setting/SettingMessage.kt
index 46040c913..c48ab6fe6 100644
--- a/business/setting/src/main/java/com/m3u/business/setting/SettingMessage.kt
+++ b/business/setting/src/main/java/com/m3u/business/setting/SettingMessage.kt
@@ -40,18 +40,48 @@ sealed class SettingMessage(
resId = string.feat_setting_error_unselected_file
)
+ data object FileAccessFailed : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_playlist_file_access_failed,
+ )
+
data object Enqueued : SettingMessage(
level = LEVEL_INFO,
type = TYPE_SNACK,
resId = string.feat_setting_enqueue_subscribe
)
+ data object ProviderCredentialsRequired : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_provider_credentials_required
+ )
+
+ data object ProviderAdded : SettingMessage(
+ level = LEVEL_INFO,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_provider_added
+ )
+
+ data object ProviderSubscriptionFailed : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_provider_subscription_failed
+ )
+
data object EpgAdded : SettingMessage(
level = LEVEL_INFO,
type = TYPE_SNACK,
resId = string.feat_setting_epg_added
)
+ data object PlaylistOperationFailed : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ resId = string.ui_error_unknown,
+ )
+
data object RemoteTvNotConnected : SettingMessage(
level = LEVEL_ERROR,
type = TYPE_SNACK,
@@ -81,4 +111,16 @@ sealed class SettingMessage(
type = TYPE_SNACK,
resId = string.feat_setting_restoring
)
-}
\ No newline at end of file
+
+ data object ExtensionDataCleared : SettingMessage(
+ level = LEVEL_INFO,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_extension_data_cleared,
+ )
+
+ data object ExtensionOperationFailed : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_extension_operation_failed,
+ )
+}
diff --git a/business/setting/src/main/java/com/m3u/business/setting/SettingProperties.kt b/business/setting/src/main/java/com/m3u/business/setting/SettingProperties.kt
index 5cc3bed77..9094d09d1 100644
--- a/business/setting/src/main/java/com/m3u/business/setting/SettingProperties.kt
+++ b/business/setting/src/main/java/com/m3u/business/setting/SettingProperties.kt
@@ -1,19 +1,42 @@
package com.m3u.business.setting
import android.net.Uri
+import androidx.compose.runtime.Stable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
+import com.m3u.core.foundation.util.basic.PlaylistInputKind
+import com.m3u.core.foundation.util.basic.sanitizePlaylistInput
import com.m3u.data.database.model.DataSource
class SettingProperties(
- val titleState: MutableState = mutableStateOf(""),
- val urlState: MutableState = mutableStateOf(""),
+ val titleState: MutableState = playlistInputState(PlaylistInputKind.TITLE),
+ val urlState: MutableState = playlistInputState(PlaylistInputKind.URL),
val uriState: MutableState = mutableStateOf(Uri.EMPTY),
val localStorageState: MutableState = mutableStateOf(false),
val forTvState: MutableState = mutableStateOf(false),
- val basicUrlState: MutableState = mutableStateOf(""),
- val usernameState: MutableState = mutableStateOf(""),
- val passwordState: MutableState = mutableStateOf(""),
- val epgState: MutableState = mutableStateOf(""),
- val selectedState: MutableState = mutableStateOf(DataSource.M3U)
+ val basicUrlState: MutableState = playlistInputState(PlaylistInputKind.BASE_URL),
+ val usernameState: MutableState = playlistInputState(PlaylistInputKind.USERNAME),
+ val passwordState: MutableState = playlistInputState(PlaylistInputKind.PASSWORD),
+ val epgState: MutableState = playlistInputState(PlaylistInputKind.EPG_URL),
+ val xtreamPlaylistTypeState: MutableState = mutableStateOf(null),
+ val selectedState: MutableState = mutableStateOf(DataSource.M3U),
)
+
+private fun playlistInputState(kind: PlaylistInputKind): MutableState =
+ SanitizedPlaylistInputState(kind)
+
+@Stable
+private class SanitizedPlaylistInputState(
+ private val kind: PlaylistInputKind,
+ private val delegate: MutableState = mutableStateOf(""),
+) : MutableState {
+ override var value: String
+ get() = delegate.value
+ set(value) {
+ delegate.value = value.sanitizePlaylistInput(kind)
+ }
+
+ override fun component1(): String = value
+
+ override fun component2(): (String) -> Unit = { value = it }
+}
diff --git a/business/setting/src/main/java/com/m3u/business/setting/SettingViewModel.kt b/business/setting/src/main/java/com/m3u/business/setting/SettingViewModel.kt
index e8a5077cb..c36b3edd1 100644
--- a/business/setting/src/main/java/com/m3u/business/setting/SettingViewModel.kt
+++ b/business/setting/src/main/java/com/m3u/business/setting/SettingViewModel.kt
@@ -1,8 +1,10 @@
package com.m3u.business.setting
import android.net.Uri
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkInfo
@@ -12,8 +14,14 @@ import androidx.work.workDataOf
import com.m3u.core.foundation.architecture.Publisher
import com.m3u.core.foundation.architecture.preferences.PreferencesKeys
import com.m3u.core.foundation.architecture.preferences.Settings
+import com.m3u.core.foundation.architecture.preferences.ThemePreference
+import com.m3u.core.foundation.architecture.preferences.ThemePreset
+import com.m3u.core.foundation.architecture.preferences.ThemeStyle
+import com.m3u.core.foundation.architecture.preferences.applyThemePreference
import com.m3u.core.foundation.architecture.preferences.flowOf
import com.m3u.core.foundation.architecture.preferences.set
+import com.m3u.core.foundation.util.basic.PlaylistInputKind
+import com.m3u.core.foundation.util.basic.normalizePlaylistInputForSubmission
import com.m3u.core.foundation.util.basic.startWithHttpScheme
import com.m3u.data.api.TvApiDelegate
import com.m3u.data.codec.CodecPackInstallResult
@@ -26,46 +34,742 @@ import com.m3u.data.database.model.DataSource
import com.m3u.data.database.model.Playlist
import com.m3u.data.parser.xtream.XtreamInput
import com.m3u.data.repository.channel.ChannelRepository
+import com.m3u.data.repository.extension.ExtensionSettingEditToken
+import com.m3u.data.repository.extension.ExtensionSettingUpdateResult
+import com.m3u.data.repository.extension.ExtensionSettingsConfiguration
+import com.m3u.data.repository.extension.ExtensionSettingsRepository
import com.m3u.data.repository.playlist.PlaylistRepository
+import com.m3u.data.repository.provider.DiscoveredSubscriptionProvider
+import com.m3u.data.repository.provider.ProviderAccountSummary
+import com.m3u.data.repository.provider.ProviderDiscoveryException
+import com.m3u.data.repository.provider.ProviderSubscriptionRequest
+import com.m3u.data.repository.provider.SubscriptionProviderRepository
+import com.m3u.data.repository.plugin.ExtensionPluginRepository
+import com.m3u.data.repository.plugin.PluginAuthorizationToken
+import com.m3u.data.repository.plugin.PluginDataClearResult
+import com.m3u.data.repository.plugin.PluginEnableResult
import com.m3u.data.repository.tv.TvRepository
import com.m3u.data.service.Messager
import com.m3u.data.worker.BackupWorker
import com.m3u.data.worker.RestoreWorker
import com.m3u.data.worker.SubscriptionWorker
+import com.m3u.data.worker.enqueuePersistedUriWork
+import com.m3u.data.worker.persistedUriPermissionTag
+import com.m3u.extension.api.ExtensionId
+import com.m3u.extension.api.ExtensionSettingKeys
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.UUID
import javax.inject.Inject
-import kotlin.time.Clock
@HiltViewModel
class SettingViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository,
private val channelRepository: ChannelRepository,
+ private val subscriptionProviderRepository: SubscriptionProviderRepository,
+ private val extensionPluginRepository: ExtensionPluginRepository,
+ private val extensionSettingsRepository: ExtensionSettingsRepository,
private val workManager: WorkManager,
private val settings: Settings,
private val messager: Messager,
private val tvRepository: TvRepository,
private val tvApi: TvApiDelegate,
private val codecPackRepository: CodecPackRepository,
+ private val savedStateHandle: SavedStateHandle,
publisher: Publisher,
// FIXME: do not use dao in viewmodel
private val colorSchemeDao: ColorSchemeDao,
) : ViewModel() {
private val _codecPackState = MutableStateFlow(codecPackRepository.toPendingState())
val codecPackState: StateFlow = _codecPackState
+ private var providerSubscriptionJob: Job? = null
+ private var providerDiscoveryJob: Job? = null
+ private var providerDiscoveryGeneration = 0L
+ private var providerLocaleTag: String? = null
+ private var providerReauthenticationJob: Job? = null
+ private val subscriptionDraftSession = SubscriptionDraftSession()
+ private var extensionSettingsLoadJob: Job? = null
+ private var extensionSettingsRequestedId: ExtensionId? = null
+ private var extensionSettingsGeneration = 0L
+ private var extensionSettingsUpdateGeneration = 0L
+ private val extensionSettingsOperationQueue = ExtensionSettingsOperationQueue(
+ scope = viewModelScope,
+ onFailure = { messager.emit(SettingMessage.ExtensionOperationFailed) },
+ )
+ private val extensionSettingUpdateGate = ExtensionSettingUpdateGate()
+ private val extensionPluginOperationController = ExtensionPluginOperationController()
+
+ private val _extensionPluginDiscoveryState =
+ MutableStateFlow(
+ ExtensionPluginDiscoveryState.Loading()
+ )
+ val extensionPluginDiscoveryState: StateFlow =
+ _extensionPluginDiscoveryState
+
+ private val _extensionSettingsState = MutableStateFlow(
+ ExtensionSettingsState.Closed
+ )
+ val extensionSettingsState: StateFlow = _extensionSettingsState
+
+ val extensionPluginOperationState: StateFlow =
+ extensionPluginOperationController.state
+
+ private val _extensionDiagnostics = MutableSharedFlow(extraBufferCapacity = 1)
+ val extensionDiagnostics = _extensionDiagnostics.asSharedFlow()
+
+ private val _subscriptionAccepted = MutableSharedFlow(extraBufferCapacity = 1)
+ val subscriptionAccepted = _subscriptionAccepted.asSharedFlow()
+
+ private val trackedPlaylistSubscription = combine(
+ savedStateHandle.getStateFlow(PLAYLIST_SUBSCRIPTION_TITLE_KEY, ""),
+ savedStateHandle.getStateFlow(PLAYLIST_SUBSCRIPTION_SOURCE_KEY, ""),
+ savedStateHandle.getStateFlow(PLAYLIST_SUBSCRIPTION_WORK_ID_KEY, ""),
+ ) { title, sourceValue, workIdValue ->
+ restorePlaylistSubscriptionTracking(
+ title = title,
+ sourceValue = sourceValue,
+ workIdValue = workIdValue,
+ )
+ }
+
+ val playlistSubscriptionState: StateFlow =
+ trackedPlaylistSubscription
+ .flatMapLatest { tracking ->
+ if (tracking == null) {
+ flowOf(PlaylistSubscriptionState.Idle)
+ } else {
+ workManager.getWorkInfoByIdFlow(tracking.workId)
+ .map { workInfo ->
+ resolvePlaylistSubscriptionState(
+ tracking = tracking,
+ workState = workInfo?.state,
+ )
+ }
+ .catch { error ->
+ if (error is CancellationException) throw error
+ emit(
+ resolvePlaylistSubscriptionState(
+ tracking = tracking,
+ workState = WorkInfo.State.FAILED,
+ )
+ )
+ }
+ }
+ }
+ .distinctUntilChanged()
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = currentPlaylistSubscriptionTracking()
+ ?.let { tracking ->
+ resolvePlaylistSubscriptionState(
+ tracking = tracking,
+ workState = null,
+ )
+ }
+ ?: PlaylistSubscriptionState.Idle,
+ started = SharingStarted.Eagerly,
+ )
+
+ private val _providerDiscoveryState = MutableStateFlow(
+ ProviderDiscoveryState.Loading
+ )
+ val providerDiscoveryState: StateFlow = _providerDiscoveryState
+
+ val providerAccountSummaries: StateFlow> =
+ subscriptionProviderRepository.observeAccountSummaries()
+ .catch { emit(emptyList()) }
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = emptyList(),
+ started = SharingStarted.WhileSubscribed(5_000L),
+ )
+
+ private val _providerSubscriptionForm = MutableStateFlow(null)
+ val providerSubscriptionForm: StateFlow = _providerSubscriptionForm
+
+ private val _providerOperationState = MutableStateFlow(ProviderOperationState())
+ val providerOperationState: StateFlow = _providerOperationState
init {
refreshCodecPack()
+ viewModelScope.launch {
+ settings.flowOf(PreferencesKeys.EXTERNAL_EXTENSIONS).collect { enabled ->
+ if (!enabled) closeExtensionSettings()
+ requestExtensionPluginRefresh(queueIfBusy = true)
+ }
+ }
+ }
+
+ fun refreshSubscriptionProviders() {
+ if (_providerOperationState.value.isBusy) return
+ startSubscriptionProviderDiscovery(providerLocaleTag)
+ }
+
+ fun refreshSubscriptionProvidersForLocale(localeTag: String) {
+ val requestedLocaleTag = localeTag.trim().takeIf(String::isNotEmpty)
+ if (providerLocaleTag == requestedLocaleTag) return
+ providerLocaleTag = requestedLocaleTag
+ startSubscriptionProviderDiscovery(requestedLocaleTag)
+ extensionSettingsRequestedId?.value?.let { extensionId ->
+ openExtensionSettings(extensionId, requestedLocaleTag)
+ }
+ }
+
+ private fun startSubscriptionProviderDiscovery(localeTag: String?): Job {
+ val previousJob = providerDiscoveryJob
+ val generation = ++providerDiscoveryGeneration
+ return viewModelScope.launch {
+ previousJob?.cancelAndJoin()
+ try {
+ loadSubscriptionProviders(localeTag)
+ } finally {
+ if (providerDiscoveryGeneration == generation) {
+ providerDiscoveryJob = null
+ }
+ }
+ }.also { job -> providerDiscoveryJob = job }
+ }
+
+ private suspend fun awaitLatestSubscriptionProviderDiscovery(
+ forceRefresh: Boolean,
+ ) {
+ var awaitedJob = if (forceRefresh) {
+ startSubscriptionProviderDiscovery(providerLocaleTag)
+ } else {
+ providerDiscoveryJob ?: startSubscriptionProviderDiscovery(providerLocaleTag)
+ }
+ while (true) {
+ awaitedJob.join()
+ val latestJob = providerDiscoveryJob
+ if (latestJob == null || latestJob === awaitedJob) return
+ awaitedJob = latestJob
+ }
+ }
+
+ fun selectSubscriptionProviderVariant(
+ providerId: String,
+ kindValue: String,
+ ) {
+ if (_providerOperationState.value.isBusy) return
+ val descriptor = currentSubscriptionProviders().firstOrNull { provider ->
+ provider.descriptor.providerId.value == providerId
+ }?.descriptor ?: return
+ val kind = descriptor.variants.firstOrNull { variant ->
+ variant.kind.value == kindValue && variant.userSelectable
+ }?.kind ?: return
+ val current = _providerSubscriptionForm.value
+ if (current.matchesNewSubscription(descriptor, kind)) {
+ return
+ }
+ _providerSubscriptionForm.value = ProviderSubscriptionForm.create(descriptor, kind)
+ }
+
+ fun beginSubscriptionDraft(
+ draftKey: String,
+ source: DataSource,
+ ) {
+ if (source !in SUBSCRIPTION_DRAFT_SOURCES) return
+ if (!subscriptionDraftSession.begin(draftKey)) return
+ resetAllInputs()
+ properties.selectedState.value = source
+ }
+
+ fun updateSubscriptionProviderSetting(fieldKey: String, value: String?) {
+ if (_providerOperationState.value.isBusy) return
+ _providerSubscriptionForm.value = _providerSubscriptionForm.value?.update(fieldKey, value)
+ }
+
+ private fun synchronizeProviderSubscriptionForm(
+ providers: List,
+ ) {
+ _providerSubscriptionForm.value = providers.reconcileSubscriptionForm(
+ current = _providerSubscriptionForm.value,
+ )
+ }
+
+ private suspend fun loadSubscriptionProviders(
+ localeTag: String? = providerLocaleTag,
+ ): List? {
+ val previousState = _providerDiscoveryState.value
+ _providerDiscoveryState.value = ProviderDiscoveryState.Loading
+ return try {
+ val providers = withContext(Dispatchers.IO) {
+ subscriptionProviderRepository.discoverProviders(localeTag)
+ }
+ synchronizeProviderSubscriptionForm(providers)
+ _providerDiscoveryState.value = providers.toProviderDiscoveryState()
+ providers
+ } catch (cancelled: CancellationException) {
+ if (_providerDiscoveryState.value is ProviderDiscoveryState.Loading) {
+ _providerDiscoveryState.value = previousState
+ }
+ throw cancelled
+ } catch (error: Exception) {
+ _providerDiscoveryState.value = ProviderDiscoveryState.Failed(
+ failureCount = (error as? ProviderDiscoveryException)?.failureCount,
+ )
+ null
+ }
+ }
+
+ private fun currentSubscriptionProviders(): List =
+ (_providerDiscoveryState.value as? ProviderDiscoveryState.Ready)?.providers.orEmpty()
+
+ fun reauthenticateProviderAccount(playlistUrl: String) {
+ if (_providerOperationState.value.isBusy) return
+ val account = providerAccountSummaries.value.firstOrNull { summary ->
+ summary.playlistUrl == playlistUrl && summary.requiresReauthentication
+ } ?: return
+ providerReauthenticationJob?.cancel()
+ _providerOperationState.value = _providerOperationState.value.copy(
+ preparingReauthenticationPlaylistUrl = playlistUrl,
+ )
+ providerReauthenticationJob = viewModelScope.launch {
+ try {
+ awaitLatestSubscriptionProviderDiscovery(forceRefresh = false)
+ var provider = currentSubscriptionProviders().providerFor(account)
+ if (provider == null) {
+ awaitLatestSubscriptionProviderDiscovery(forceRefresh = true)
+ provider = currentSubscriptionProviders().providerFor(account)
+ }
+ if (provider == null) {
+ messager.emit(SettingMessage.ProviderSubscriptionFailed)
+ return@launch
+ }
+ resetAllInputs()
+ properties.selectedState.value = DataSource.Provider
+ properties.titleState.value = account.playlistTitle
+ _providerSubscriptionForm.value =
+ ProviderSubscriptionForm.createForReauthentication(
+ descriptor = provider,
+ account = account,
+ )
+ } finally {
+ if (
+ _providerOperationState.value.preparingReauthenticationPlaylistUrl ==
+ playlistUrl
+ ) {
+ _providerOperationState.value = _providerOperationState.value.copy(
+ preparingReauthenticationPlaylistUrl = null,
+ )
+ }
+ }
+ }
+ }
+
+ fun refreshExtensionPlugins() {
+ requestExtensionPluginRefresh(queueIfBusy = false)
+ }
+
+ private fun requestExtensionPluginRefresh(queueIfBusy: Boolean) {
+ launchExtensionPluginOperation(
+ operation = ExtensionPluginOperation.Refresh,
+ queueRefreshIfBusy = queueIfBusy,
+ ) {
+ refreshExtensionPluginsInternal()
+ }
+ }
+
+ fun enableExtensionPlugin(
+ packageName: String,
+ serviceName: String,
+ authorizationToken: PluginAuthorizationToken,
+ ) {
+ launchExtensionPluginOperation(
+ ExtensionPluginOperation.Enable(packageName, serviceName)
+ ) {
+ val enabled = when (
+ val result = extensionPluginRepository.enable(
+ packageName,
+ serviceName,
+ authorizationToken,
+ )
+ ) {
+ is PluginEnableResult.Enabled -> true
+ is PluginEnableResult.Rejected -> false
+ }
+ refreshExtensionPluginsInternal()
+ if (!enabled) {
+ messager.emit(SettingMessage.ExtensionOperationFailed)
+ }
+ }
+ }
+
+ fun reauthorizeExtensionPlugin(
+ packageName: String,
+ serviceName: String,
+ authorizationToken: PluginAuthorizationToken,
+ ) {
+ launchExtensionPluginOperation(
+ ExtensionPluginOperation.Reauthorize(packageName, serviceName)
+ ) {
+ val reauthorized = when (
+ val result = extensionPluginRepository.reauthorize(
+ packageName,
+ serviceName,
+ authorizationToken,
+ )
+ ) {
+ is PluginEnableResult.Enabled -> true
+ is PluginEnableResult.Rejected -> false
+ }
+ refreshExtensionPluginsInternal()
+ if (!reauthorized) {
+ messager.emit(SettingMessage.ExtensionOperationFailed)
+ }
+ }
+ }
+
+ fun disableExtensionPlugin(extensionId: String) {
+ launchExtensionPluginOperation(
+ operation = ExtensionPluginOperation.Disable(extensionId),
+ onStarted = { closeExtensionSettingsIfActive(extensionId) },
+ ) {
+ val disabled = extensionPluginRepository.disable(extensionId)
+ refreshExtensionPluginsInternal()
+ if (!disabled) {
+ messager.emit(SettingMessage.ExtensionOperationFailed)
+ }
+ }
+ }
+
+ fun revokeExtensionPlugin(
+ packageName: String,
+ serviceName: String,
+ extensionId: String?,
+ ) {
+ if (extensionId == null) {
+ viewModelScope.launch { messager.emit(SettingMessage.ExtensionOperationFailed) }
+ return
+ }
+ launchDestructiveExtensionPluginOperation(
+ operation = ExtensionPluginOperation.Revoke(
+ packageName = packageName,
+ serviceName = serviceName,
+ extensionId = extensionId,
+ ),
+ extensionId = extensionId,
+ onStarted = { closeExtensionSettingsIfActive(extensionId) },
+ ) {
+ extensionPluginRepository.revoke(packageName, serviceName)
+ refreshExtensionPluginsInternal()
+ }
+ }
+
+ fun openExtensionSettings(extensionId: String, localeTag: String?) {
+ val requestedExtensionId = ExtensionId(extensionId)
+ val generation = ++extensionSettingsGeneration
+ extensionSettingsLoadJob?.cancel()
+ extensionSettingsRequestedId?.value?.let(extensionSettingUpdateGate::clear)
+ extensionSettingsRequestedId = requestedExtensionId
+ _extensionSettingsState.value =
+ ExtensionSettingsState.Loading(requestedExtensionId)
+ extensionSettingsLoadJob = extensionSettingsOperationQueue.launchOperation(extensionId) {
+ try {
+ val configuration = withContext(Dispatchers.IO) {
+ extensionSettingsRepository.configuration(
+ requestedExtensionId,
+ localeTag,
+ PHONE_SETTINGS_SURFACE,
+ )
+ }
+ if (generation == extensionSettingsGeneration) {
+ _extensionSettingsState.value =
+ configuration.toExtensionSettingsState(requestedExtensionId)
+ }
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (failure: Exception) {
+ if (generation == extensionSettingsGeneration) {
+ _extensionSettingsState.value =
+ ExtensionSettingsState.Error(requestedExtensionId)
+ }
+ throw failure
+ }
+ }
+ }
+
+ fun closeExtensionSettings() {
+ extensionSettingsGeneration++
+ extensionSettingsLoadJob?.cancel()
+ extensionSettingsLoadJob = null
+ extensionSettingsRequestedId?.value?.let(extensionSettingUpdateGate::clear)
+ extensionSettingsRequestedId = null
+ _extensionSettingsState.value = ExtensionSettingsState.Closed
+ }
+
+ private fun closeExtensionSettingsIfActive(extensionId: String) {
+ if (
+ _extensionSettingsState.value.extensionId?.value == extensionId ||
+ extensionSettingsRequestedId?.value == extensionId
+ ) {
+ closeExtensionSettings()
+ }
+ }
+
+ fun clearExtensionData(
+ packageName: String,
+ serviceName: String,
+ extensionId: String?,
+ ) {
+ if (extensionId == null) {
+ viewModelScope.launch { messager.emit(SettingMessage.ExtensionOperationFailed) }
+ return
+ }
+ launchDestructiveExtensionPluginOperation(
+ operation = ExtensionPluginOperation.ClearData(
+ packageName = packageName,
+ serviceName = serviceName,
+ extensionId = extensionId,
+ ),
+ extensionId = extensionId,
+ onStarted = { closeExtensionSettingsIfActive(extensionId) },
+ ) {
+ when (
+ val result = withContext(Dispatchers.IO) {
+ extensionPluginRepository.clearData(packageName, serviceName)
+ }
+ ) {
+ is PluginDataClearResult.Cleared -> {
+ messager.emit(SettingMessage.ExtensionDataCleared)
+ }
+ is PluginDataClearResult.Rejected ->
+ messager.emit(SettingMessage.ExtensionOperationFailed)
+ }
+ }
+ }
+
+ fun exportExtensionDiagnostics(extensionId: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ extensionPluginRepository.diagnostics(extensionId)?.let { payload ->
+ _extensionDiagnostics.emit(payload)
+ }
+ }
+ }
+
+ fun updateExtensionSetting(
+ sectionId: String,
+ fieldKey: String,
+ editToken: ExtensionSettingEditToken,
+ rawValue: String?,
+ localeTag: String?,
+ ) {
+ val content =
+ _extensionSettingsState.value as? ExtensionSettingsState.Content
+ ?: return
+ val configuration = content.configuration
+ val extensionId = configuration.extensionId
+ val qualifiedKey = runCatching {
+ ExtensionSettingKeys.qualified(sectionId, fieldKey)
+ }.getOrNull() ?: return
+ if (!extensionSettingUpdateGate.tryStart(extensionId.value, qualifiedKey)) return
+ val refreshPluginProjection =
+ configuration.networkOriginState(sectionId, fieldKey) != null
+ _extensionSettingsState.value = content.copy(
+ updatingKeys = content.updatingKeys + qualifiedKey,
+ )
+ val generation = extensionSettingsGeneration
+ val updateGeneration = ++extensionSettingsUpdateGeneration
+ extensionSettingsOperationQueue.launchUpdate(extensionId.value) update@{
+ try {
+ val result = withContext(Dispatchers.IO) {
+ when (
+ val update = extensionSettingsRepository.update(
+ extensionId,
+ sectionId,
+ fieldKey,
+ editToken,
+ rawValue,
+ )
+ ) {
+ is ExtensionSettingUpdateResult.Updated -> {
+ ExtensionSettingsRefreshResult.Updated(
+ configuration = extensionSettingsRepository.configuration(
+ extensionId,
+ localeTag,
+ PHONE_SETTINGS_SURFACE,
+ ),
+ )
+ }
+ is ExtensionSettingUpdateResult.Rejected -> {
+ ExtensionSettingsRefreshResult.Rejected(
+ configuration = extensionSettingsRepository.configuration(
+ extensionId,
+ localeTag,
+ PHONE_SETTINGS_SURFACE,
+ )
+ )
+ }
+ }
+ }
+ if (
+ result is ExtensionSettingsRefreshResult.Updated &&
+ refreshPluginProjection
+ ) {
+ requestExtensionPluginRefresh(queueIfBusy = true)
+ }
+ if (
+ generation != extensionSettingsGeneration ||
+ updateGeneration != extensionSettingsUpdateGeneration ||
+ _extensionSettingsState.value.extensionId != extensionId
+ ) {
+ return@update
+ }
+ val updatingKeys = currentExtensionSettingUpdateKeys(extensionId) - qualifiedKey
+ when (result) {
+ is ExtensionSettingsRefreshResult.Updated -> {
+ _extensionSettingsState.value =
+ result.configuration.toExtensionSettingsState(
+ extensionId = extensionId,
+ updatingKeys = updatingKeys,
+ )
+ }
+ is ExtensionSettingsRefreshResult.Rejected -> {
+ _extensionSettingsState.value =
+ result.configuration.toExtensionSettingsState(
+ extensionId = extensionId,
+ updatingKeys = updatingKeys,
+ )
+ messager.emit(SettingMessage.ExtensionOperationFailed)
+ }
+ }
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (failure: Exception) {
+ if (
+ generation == extensionSettingsGeneration &&
+ _extensionSettingsState.value.extensionId == extensionId
+ ) {
+ _extensionSettingsState.value =
+ ExtensionSettingsState.Error(extensionId)
+ }
+ throw failure
+ } finally {
+ extensionSettingUpdateGate.finish(extensionId.value, qualifiedKey)
+ val current = _extensionSettingsState.value
+ if (
+ current is ExtensionSettingsState.Content &&
+ current.extensionId == extensionId &&
+ qualifiedKey in current.updatingKeys
+ ) {
+ _extensionSettingsState.value = current.copy(
+ updatingKeys = current.updatingKeys - qualifiedKey,
+ )
+ }
+ }
+ }
+ }
+
+ private fun currentExtensionSettingUpdateKeys(extensionId: ExtensionId): Set =
+ (_extensionSettingsState.value as? ExtensionSettingsState.Content)
+ ?.takeIf { content -> content.extensionId == extensionId }
+ ?.updatingKeys
+ .orEmpty()
+
+ private fun launchExtensionPluginOperation(
+ operation: ExtensionPluginOperation,
+ queueRefreshIfBusy: Boolean = false,
+ onStarted: () -> Unit = {},
+ block: suspend () -> Unit,
+ ): Job? {
+ val running = extensionPluginOperationController.tryStart(
+ operation = operation,
+ queueRefreshIfBusy = queueRefreshIfBusy,
+ ) ?: return null
+ onStarted()
+ return viewModelScope.launch {
+ try {
+ block()
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (failure: Exception) {
+ messager.emit(SettingMessage.ExtensionOperationFailed)
+ } finally {
+ if (
+ extensionPluginOperationController
+ .finishAndConsumePendingRefresh(running)
+ ) {
+ requestExtensionPluginRefresh(queueIfBusy = false)
+ }
+ }
+ }
+ }
+
+ private fun launchDestructiveExtensionPluginOperation(
+ operation: ExtensionPluginOperation,
+ extensionId: String,
+ onStarted: () -> Unit,
+ block: suspend () -> Unit,
+ ): Job? {
+ val running = extensionPluginOperationController.tryStart(operation) ?: return null
+ onStarted()
+ return extensionSettingsOperationQueue.launchDestructive(
+ extensionId = extensionId,
+ operation = block,
+ ).also { job ->
+ job.invokeOnCompletion {
+ if (
+ extensionPluginOperationController
+ .finishAndConsumePendingRefresh(running)
+ ) {
+ requestExtensionPluginRefresh(queueIfBusy = false)
+ }
+ }
+ }
+ }
+
+ private suspend fun refreshExtensionPluginsInternal() {
+ val previousState = _extensionPluginDiscoveryState.value
+ val previousPlugins = previousState.plugins
+ _extensionPluginDiscoveryState.value =
+ ExtensionPluginDiscoveryState.Loading(previousPlugins)
+ try {
+ val plugins = withContext(Dispatchers.IO) {
+ extensionPluginRepository.installedPlugins()
+ }
+ _extensionPluginDiscoveryState.value =
+ plugins.toExtensionPluginDiscoveryState()
+ refreshSubscriptionProviders()
+ } catch (cancelled: CancellationException) {
+ if (_extensionPluginDiscoveryState.value is ExtensionPluginDiscoveryState.Loading) {
+ _extensionPluginDiscoveryState.value = previousState
+ }
+ throw cancelled
+ } catch (failure: Exception) {
+ _extensionPluginDiscoveryState.value =
+ ExtensionPluginDiscoveryState.Error(previousPlugins)
+ throw failure
+ }
+ }
+
+ private sealed interface ExtensionSettingsRefreshResult {
+ data class Updated(
+ val configuration: ExtensionSettingsConfiguration?,
+ ) : ExtensionSettingsRefreshResult
+
+ data class Rejected(
+ val configuration: ExtensionSettingsConfiguration?,
+ ) : ExtensionSettingsRefreshResult
}
val epgs: StateFlow> = playlistRepository
@@ -76,6 +780,35 @@ class SettingViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L)
)
+ val playlists: StateFlow