diff --git a/app/smartphone/src/main/AndroidManifest.xml b/app/smartphone/src/main/AndroidManifest.xml
index c97d65f12..73f404a18 100644
--- a/app/smartphone/src/main/AndroidManifest.xml
+++ b/app/smartphone/src/main/AndroidManifest.xml
@@ -17,6 +17,11 @@
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
+
+
+
defaultTitle
SettingDestination.Playlists -> playlistTitle
SettingDestination.Appearance -> appearanceTitle
SettingDestination.Optional -> optionalTitle
+ SettingDestination.Security -> securityTitle
}
.title()
.let(::AnnotatedString)
@@ -225,6 +228,14 @@ private fun SettingScreen(
)
}
},
+ navigateToSecurity = {
+ coroutineScope.launch {
+ navigator.navigateTo(
+ pane = ListDetailPaneScaffoldRole.Detail,
+ contentKey = SettingDestination.Security
+ )
+ }
+ },
modifier = Modifier.fillMaxSize()
)
},
@@ -266,6 +277,13 @@ private fun SettingScreen(
)
}
+ SettingDestination.Security -> {
+ SecurityFragment(
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+
else -> {}
}
},
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/USBLockScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/USBLockScreen.kt
new file mode 100644
index 000000000..05c771d70
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/USBLockScreen.kt
@@ -0,0 +1,140 @@
+package com.m3u.smartphone.ui.business.setting
+
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Lock
+import androidx.compose.material.icons.rounded.Usb
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+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.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.m3u.i18n.R.string
+
+@Composable
+fun USBLockScreen(
+ deviceName: String? = null,
+ modifier: Modifier = Modifier
+) {
+ // Pulsating animation for USB icon
+ val infiniteTransition = rememberInfiniteTransition(label = "usb_pulse")
+ val alpha by infiniteTransition.animateFloat(
+ initialValue = 0.3f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1500, easing = FastOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "alpha"
+ )
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(32.dp),
+ modifier = Modifier
+ .padding(48.dp)
+ .widthIn(max = 600.dp)
+ ) {
+ // Lock Icon
+ Icon(
+ imageVector = Icons.Rounded.Lock,
+ contentDescription = null,
+ modifier = Modifier.size(96.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ // Title
+ Text(
+ text = stringResource(string.feat_setting_usb_encryption_locked_title),
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center
+ )
+
+ // Description
+ Text(
+ text = stringResource(string.feat_setting_usb_encryption_locked_message),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // USB Prompt Card
+ OutlinedCard(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Usb,
+ contentDescription = null,
+ modifier = Modifier
+ .size(64.dp)
+ .alpha(alpha),
+ tint = MaterialTheme.colorScheme.tertiary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(string.feat_setting_usb_encryption_insert_usb),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.tertiary,
+ textAlign = TextAlign.Center
+ )
+
+ if (deviceName != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(string.feat_setting_usb_encryption_waiting_for_device, deviceName),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Warning Text
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Lock,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(string.feat_setting_usb_encryption_no_access_without_key),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/USBEncryptionContent.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/USBEncryptionContent.kt
new file mode 100644
index 000000000..f2ee71220
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/USBEncryptionContent.kt
@@ -0,0 +1,289 @@
+package com.m3u.smartphone.ui.business.setting.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Circle
+import androidx.compose.material.icons.rounded.Lock
+import androidx.compose.material.icons.rounded.LockOpen
+import androidx.compose.material.icons.rounded.Usb
+import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+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.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.m3u.business.setting.SettingProperties
+import com.m3u.data.repository.usbkey.USBKeyState
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.material.model.LocalSpacing
+
+@Composable
+context(properties: SettingProperties)
+internal fun USBEncryptionContent(
+ usbKeyState: USBKeyState,
+ onEnableEncryption: () -> Unit,
+ onDisableEncryption: () -> Unit,
+ onRequestUSBPermission: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val spacing = LocalSpacing.current
+ var showEnableDialog by remember { mutableStateOf(false) }
+ var showDisableDialog by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(spacing.small)
+ ) {
+ // Status Indicator
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = when {
+ usbKeyState.error != null -> MaterialTheme.colorScheme.errorContainer
+ usbKeyState.isDatabaseUnlocked -> MaterialTheme.colorScheme.primaryContainer
+ usbKeyState.isEncryptionEnabled -> MaterialTheme.colorScheme.tertiaryContainer
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(spacing.medium),
+ horizontalArrangement = Arrangement.spacedBy(spacing.small),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Circle,
+ contentDescription = null,
+ tint = when {
+ usbKeyState.error != null -> MaterialTheme.colorScheme.error
+ usbKeyState.isDatabaseUnlocked -> MaterialTheme.colorScheme.primary
+ usbKeyState.isEncryptionEnabled -> MaterialTheme.colorScheme.tertiary
+ else -> MaterialTheme.colorScheme.outline
+ },
+ modifier = Modifier.size(12.dp)
+ )
+ Text(
+ text = when {
+ usbKeyState.error != null ->
+ stringResource(string.feat_setting_usb_encryption_status_error, usbKeyState.error ?: "Unknown")
+ usbKeyState.isDatabaseUnlocked && usbKeyState.isEncryptionEnabled ->
+ stringResource(string.feat_setting_usb_encryption_status_unlocked)
+ usbKeyState.isEncryptionEnabled && !usbKeyState.isDatabaseUnlocked ->
+ stringResource(string.feat_setting_usb_encryption_status_locked)
+ else ->
+ stringResource(string.feat_setting_usb_encryption_status_disabled)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = when {
+ usbKeyState.error != null -> MaterialTheme.colorScheme.onErrorContainer
+ usbKeyState.isDatabaseUnlocked -> MaterialTheme.colorScheme.onPrimaryContainer
+ usbKeyState.isEncryptionEnabled -> MaterialTheme.colorScheme.onTertiaryContainer
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ }
+ }
+
+ // USB Connection Status
+ AnimatedVisibility(visible = usbKeyState.isConnected) {
+ OutlinedCard(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(spacing.medium),
+ horizontalArrangement = Arrangement.spacedBy(spacing.small),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Usb,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(string.feat_setting_usb_encryption_device_connected),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ usbKeyState.deviceName?.let { deviceName ->
+ Text(
+ text = deviceName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Control Buttons
+ if (!usbKeyState.isEncryptionEnabled) {
+ // Enable Encryption Button
+ FilledTonalButton(
+ onClick = {
+ if (usbKeyState.isConnected) {
+ showEnableDialog = true
+ } else {
+ onRequestUSBPermission()
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Lock,
+ contentDescription = null
+ )
+ Spacer(Modifier.width(spacing.small))
+ Text(
+ text = if (usbKeyState.isConnected) {
+ stringResource(string.feat_setting_usb_encryption_enable)
+ } else {
+ stringResource(string.feat_setting_usb_encryption_connect_usb)
+ }
+ )
+ }
+ } else {
+ // Disable Encryption Button
+ FilledTonalButton(
+ onClick = { showDisableDialog = true },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.LockOpen,
+ contentDescription = null
+ )
+ Spacer(Modifier.width(spacing.small))
+ Text(
+ text = stringResource(string.feat_setting_usb_encryption_disable)
+ )
+ }
+ }
+
+ // Info Section
+ OutlinedCard(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(spacing.medium),
+ horizontalArrangement = Arrangement.spacedBy(spacing.small)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp)
+ )
+ Text(
+ text = stringResource(string.feat_setting_usb_encryption_info),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ // Enable Encryption Confirmation Dialog
+ if (showEnableDialog) {
+ AlertDialog(
+ onDismissRequest = { showEnableDialog = false },
+ icon = {
+ Icon(
+ imageVector = Icons.Rounded.Warning,
+ contentDescription = null
+ )
+ },
+ title = {
+ Text(text = stringResource(string.feat_setting_usb_encryption_enable_title))
+ },
+ text = {
+ Text(text = stringResource(string.feat_setting_usb_encryption_enable_message))
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showEnableDialog = false
+ onEnableEncryption()
+ }
+ ) {
+ Text(text = stringResource(string.feat_setting_usb_encryption_enable_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = { showEnableDialog = false }
+ ) {
+ Text(text = stringResource(string.feat_setting_usb_encryption_cancel))
+ }
+ }
+ )
+ }
+
+ // Disable Encryption Confirmation Dialog
+ if (showDisableDialog) {
+ AlertDialog(
+ onDismissRequest = { showDisableDialog = false },
+ icon = {
+ Icon(
+ imageVector = Icons.Rounded.Warning,
+ contentDescription = null
+ )
+ },
+ title = {
+ Text(text = stringResource(string.feat_setting_usb_encryption_disable_title))
+ },
+ text = {
+ Text(text = stringResource(string.feat_setting_usb_encryption_disable_message))
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showDisableDialog = false
+ onDisableEncryption()
+ }
+ ) {
+ Text(text = stringResource(string.feat_setting_usb_encryption_disable_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = { showDisableDialog = false }
+ ) {
+ Text(text = stringResource(string.feat_setting_usb_encryption_cancel))
+ }
+ }
+ )
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/WebDropInputContent.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/WebDropInputContent.kt
new file mode 100644
index 000000000..bc0b28255
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/components/WebDropInputContent.kt
@@ -0,0 +1,206 @@
+package com.m3u.smartphone.ui.business.setting.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Circle
+import androidx.compose.material.icons.rounded.CloudUpload
+import androidx.compose.material.icons.rounded.ContentCopy
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Stop
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+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.platform.LocalClipboardManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import com.m3u.business.setting.SettingProperties
+import com.m3u.data.repository.webserver.WebServerState
+import com.m3u.i18n.R.string
+import com.m3u.smartphone.ui.material.model.LocalSpacing
+
+@Composable
+context(properties: SettingProperties)
+internal fun WebDropInputContent(
+ webServerState: WebServerState,
+ onStartServer: () -> Unit,
+ onStopServer: () -> Unit,
+ onCopyUrl: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val spacing = LocalSpacing.current
+ val clipboardManager = LocalClipboardManager.current
+
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(spacing.small)
+ ) {
+ // Status Indicator
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = when {
+ webServerState.error != null -> MaterialTheme.colorScheme.errorContainer
+ webServerState.isRunning -> MaterialTheme.colorScheme.primaryContainer
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(spacing.medium),
+ horizontalArrangement = Arrangement.spacedBy(spacing.small),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Circle,
+ contentDescription = null,
+ tint = when {
+ webServerState.error != null -> MaterialTheme.colorScheme.error
+ webServerState.isRunning -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.outline
+ },
+ modifier = Modifier.size(12.dp)
+ )
+ Text(
+ text = when {
+ webServerState.error != null ->
+ stringResource(string.feat_setting_webdrop_status_error, webServerState.error ?: "Unknown")
+ webServerState.isRunning ->
+ stringResource(string.feat_setting_webdrop_status_running)
+ else ->
+ stringResource(string.feat_setting_webdrop_status_stopped)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = when {
+ webServerState.error != null -> MaterialTheme.colorScheme.onErrorContainer
+ webServerState.isRunning -> MaterialTheme.colorScheme.onPrimaryContainer
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ }
+ }
+
+ // URL Display (only when running)
+ AnimatedVisibility(visible = webServerState.accessUrl != null) {
+ OutlinedCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ webServerState.accessUrl?.let {
+ clipboardManager.setText(AnnotatedString(it))
+ onCopyUrl(it)
+ }
+ }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(spacing.medium),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(string.feat_setting_webdrop_access_url),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = webServerState.accessUrl ?: "",
+ style = MaterialTheme.typography.bodyLarge,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ IconButton(
+ onClick = {
+ webServerState.accessUrl?.let {
+ clipboardManager.setText(AnnotatedString(it))
+ onCopyUrl(it)
+ }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.ContentCopy,
+ contentDescription = stringResource(string.feat_setting_webdrop_copy_url)
+ )
+ }
+ }
+ }
+ }
+
+ // Control Button
+ FilledTonalButton(
+ onClick = {
+ if (webServerState.isRunning) {
+ onStopServer()
+ } else {
+ onStartServer()
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ colors = if (webServerState.isRunning) {
+ ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer
+ )
+ } else {
+ ButtonDefaults.filledTonalButtonColors()
+ }
+ ) {
+ Icon(
+ imageVector = if (webServerState.isRunning) {
+ Icons.Rounded.Stop
+ } else {
+ Icons.Rounded.CloudUpload
+ },
+ contentDescription = null
+ )
+ Spacer(Modifier.width(spacing.small))
+ Text(
+ text = if (webServerState.isRunning) {
+ stringResource(string.feat_setting_webdrop_stop_server)
+ } else {
+ stringResource(string.feat_setting_webdrop_start_server)
+ }
+ )
+ }
+
+ // Info Section
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(spacing.small),
+ horizontalArrangement = Arrangement.spacedBy(spacing.small)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Info,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp)
+ )
+ Text(
+ text = stringResource(string.feat_setting_webdrop_info),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SecurityFragment.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SecurityFragment.kt
new file mode 100644
index 000000000..5e4045439
--- /dev/null
+++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/setting/fragments/SecurityFragment.kt
@@ -0,0 +1,42 @@
+package com.m3u.smartphone.ui.business.setting.fragments
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.m3u.business.setting.SettingViewModel
+import com.m3u.smartphone.ui.business.setting.components.USBEncryptionContent
+import com.m3u.smartphone.ui.material.ktx.plus
+import com.m3u.smartphone.ui.material.model.LocalSpacing
+
+@Composable
+internal fun SecurityFragment(
+ contentPadding: PaddingValues,
+ modifier: Modifier = Modifier
+) {
+ val spacing = LocalSpacing.current
+ val viewModel: SettingViewModel = hiltViewModel()
+ val usbKeyState by viewModel.usbKeyState.collectAsStateWithLifecycle()
+
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = contentPadding + PaddingValues(spacing.medium),
+ verticalArrangement = Arrangement.spacedBy(spacing.medium)
+ ) {
+ item {
+ with(viewModel.properties) {
+ USBEncryptionContent(
+ usbKeyState = usbKeyState,
+ onEnableEncryption = { viewModel.enableUSBEncryption() },
+ onDisableEncryption = { viewModel.disableUSBEncryption() },
+ onRequestUSBPermission = { viewModel.requestUSBPermission() }
+ )
+ }
+ }
+ }
+}
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
index 7ef959d36..185fba4e1 100644
--- 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
@@ -44,6 +44,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
@@ -63,6 +65,8 @@ 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.WebDropInputContent
+import com.m3u.business.playlist.PlaylistViewModel
import com.m3u.smartphone.ui.common.helper.LocalHelper
import com.m3u.smartphone.ui.material.components.HorizontalPagerIndicator
import com.m3u.smartphone.ui.material.components.PlaceholderField
@@ -182,7 +186,8 @@ private fun MainContentImpl(
DataSource.EPG,
DataSource.Xtream,
DataSource.Emby,
- DataSource.Dropbox
+ DataSource.Dropbox,
+ DataSource.WebDrop
)
)
}
@@ -194,6 +199,19 @@ private fun MainContentImpl(
DataSource.Xtream -> XtreamInputContent()
DataSource.Emby -> {}
DataSource.Dropbox -> {}
+ DataSource.WebDrop -> {
+ val playlistViewModel: PlaylistViewModel = hiltViewModel()
+ val webServerState by playlistViewModel.webServerState.collectAsStateWithLifecycle()
+
+ WebDropInputContent(
+ webServerState = webServerState,
+ onStartServer = { playlistViewModel.startWebServer() },
+ onStopServer = { playlistViewModel.stopWebServer() },
+ onCopyUrl = { url ->
+ // URL copied notification handled internally
+ }
+ )
+ }
}
}
@@ -212,6 +230,7 @@ private fun MainContentImpl(
SplitButtonDefaults.LeadingButton(
shapes = SplitButtonDefaults.leadingButtonShapesFor(size),
contentPadding = SplitButtonDefaults.leadingButtonContentPaddingFor(size),
+ enabled = properties.selectedState.value != DataSource.WebDrop,
onClick = {
postNotificationPermission.checkPermissionOrRationale(
showRationale = {
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 d315741e5..77fb254aa 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
@@ -20,6 +20,7 @@ internal fun PreferencesFragment(
navigateToPlaylistManagement: () -> Unit,
navigateToThemeSelector: () -> Unit,
navigateToOptional: () -> Unit,
+ navigateToSecurity: () -> Unit,
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
@@ -34,7 +35,8 @@ internal fun PreferencesFragment(
fragment = fragment,
navigateToPlaylistManagement = navigateToPlaylistManagement,
navigateToThemeSelector = navigateToThemeSelector,
- navigateToOptional = navigateToOptional
+ navigateToOptional = navigateToOptional,
+ navigateToSecurity = navigateToSecurity
)
}
item {
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 5130a45b1..db0e19505 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
@@ -6,6 +6,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ColorLens
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.MusicNote
+import androidx.compose.material.icons.rounded.Security
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -21,6 +22,7 @@ internal fun RegularPreferences(
navigateToPlaylistManagement: () -> Unit,
navigateToThemeSelector: () -> Unit,
navigateToOptional: () -> Unit,
+ navigateToSecurity: () -> Unit,
modifier: Modifier = Modifier
) {
val spacing = LocalSpacing.current
@@ -46,5 +48,11 @@ internal fun RegularPreferences(
enabled = fragment != SettingDestination.Optional,
onClick = navigateToOptional
)
+ Preference(
+ title = stringResource(string.feat_setting_security).title(),
+ icon = Icons.Rounded.Security,
+ enabled = fragment != SettingDestination.Security,
+ onClick = navigateToSecurity
+ )
}
}
\ No newline at end of file
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 be01d4772..5c2cb1cba 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
@@ -70,4 +70,8 @@ sealed interface SettingDestination : Parcelable {
@Immutable
@Parcelize
data object Optional : SettingDestination
+
+ @Immutable
+ @Parcelize
+ data object Security : SettingDestination
}
diff --git a/app/tv/src/main/AndroidManifest.xml b/app/tv/src/main/AndroidManifest.xml
index e42ef5dc9..910ab712d 100644
--- a/app/tv/src/main/AndroidManifest.xml
+++ b/app/tv/src/main/AndroidManifest.xml
@@ -12,6 +12,18 @@
+
+
+
+
+
+
+
+
@@ -19,6 +31,16 @@
android:name="android.hardware.touchscreen"
android:required="false" />
+
+
+
+
+
+
+ timber.d("Found encryption key - verifying fingerprint...")
+ val verified = keyVerificationManager.verifyKey(key)
+
+ if (verified) {
+ timber.d("USB key verified successfully on startup")
+ } else {
+ timber.w("USB key verification failed on startup")
+ }
+ } ?: run {
+ timber.w("No encryption key found on startup - USB may be disconnected")
+ }
+ } else {
+ timber.d("Encryption is not enabled - skipping startup verification")
+ }
+
+ timber.d("=== STARTUP VERIFICATION COMPLETE ===")
+ } catch (e: Exception) {
+ timber.e(e, "Error during startup verification")
+ }
}
}
\ No newline at end of file
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 9b2829d18..fa4ab2259 100644
--- a/app/tv/src/main/java/com/m3u/tv/MainActivity.kt
+++ b/app/tv/src/main/java/com/m3u/tv/MainActivity.kt
@@ -1,38 +1,241 @@
package com.m3u.tv
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
import android.os.Bundle
+import android.os.Environment
+import android.provider.Settings
+import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
+import com.m3u.business.setting.UnlockManager
+import com.m3u.tv.screens.common.ErrorScreen
+import com.m3u.tv.screens.common.LoadingScreen
+import com.m3u.tv.screens.security.PINUnlockScreen
import com.m3u.tv.utils.Helper
import com.m3u.tv.utils.LocalHelper
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+ @Inject
+ lateinit var unlockManager: UnlockManager
+
private val helper = Helper(this)
+
+ private val timber = Timber.tag("MainActivity")
+
+ // Storage permission request launcher
+ private val storagePermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ timber.d("Storage permissions result: $permissions")
+ val allGranted = permissions.values.all { it }
+ if (allGranted) {
+ timber.d("✓ All storage permissions granted")
+ } else {
+ timber.w("⚠ Some storage permissions denied: ${permissions.filter { !it.value }}")
+ }
+ }
+
+ // Manage all files permission launcher for Android 11+
+ private val manageStorageLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ if (Environment.isExternalStorageManager()) {
+ timber.d("✓ All files access granted")
+ } else {
+ timber.w("⚠ All files access denied")
+ }
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+
+ timber.d("=== MAIN ACTIVITY ONCREATE ===")
+
+ // Request storage permissions on first launch
+ requestStoragePermissions()
+
+ // Initialize unlock manager BEFORE setContent
+ // This checks if PIN encryption is enabled and sets initial lock state
+ lifecycleScope.launch {
+ timber.d("Initializing unlock manager...")
+ unlockManager.initialize()
+ timber.d("Unlock manager initialized")
+ }
+
setContent {
MaterialTheme(
colorScheme = darkColorScheme()
) {
Box(Modifier.background(MaterialTheme.colorScheme.background)) {
- CompositionLocalProvider(
- LocalHelper provides helper,
- LocalContentColor provides MaterialTheme.colorScheme.onBackground
- ) {
- App {
- onBackPressedDispatcher.onBackPressed()
+ // ========================================
+ // AUTHENTICATION GATE
+ // ========================================
+ // Observe the lock state and show different screens based on it
+ val lockState by unlockManager.lockState.collectAsStateWithLifecycle()
+
+ timber.d("Current lock state: $lockState")
+
+ when (lockState) {
+ is UnlockManager.LockState.Initializing -> {
+ // Show loading while checking encryption status
+ timber.d("Showing loading screen")
+ LoadingScreen(message = "Initializing...")
+ }
+
+ is UnlockManager.LockState.Locked -> {
+ // Database is encrypted - show PIN unlock screen
+ // This BLOCKS access to the main app
+ timber.d("Showing PIN unlock screen")
+
+ var errorMessage by remember { mutableStateOf(null) }
+
+ PINUnlockScreen(
+ onPINEntered = { pin ->
+ timber.d("PIN entered in unlock screen, attempting unlock...")
+ lifecycleScope.launch {
+ val result = unlockManager.attemptUnlock(pin)
+ if (result.isFailure) {
+ timber.w("Unlock failed: ${result.exceptionOrNull()?.message}")
+ errorMessage = "Incorrect PIN. Please try again."
+ } else {
+ timber.d("✓ Unlock successful!")
+ errorMessage = null
+ // State will automatically change to Unlocked
+ }
+ }
+ },
+ errorMessage = errorMessage
+ )
+ }
+
+ is UnlockManager.LockState.NoEncryption,
+ is UnlockManager.LockState.Unlocked -> {
+ // No encryption OR successfully unlocked - proceed to main app
+ timber.d("Proceeding to main app (unlocked or no encryption)")
+
+ CompositionLocalProvider(
+ LocalHelper provides helper,
+ LocalContentColor provides MaterialTheme.colorScheme.onBackground
+ ) {
+ App {
+ onBackPressedDispatcher.onBackPressed()
+ }
+ }
}
+
+ is UnlockManager.LockState.Error -> {
+ // Error during initialization
+ val error = lockState as UnlockManager.LockState.Error
+ timber.e("Error state: ${error.message}")
+
+ ErrorScreen(
+ message = error.message,
+ onRetry = {
+ lifecycleScope.launch {
+ unlockManager.initialize()
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ timber.d("=== MAIN ACTIVITY SETUP COMPLETE ===")
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
+ // Handle DELETE and PAGE_DOWN as back navigation
+ return when (keyCode) {
+ KeyEvent.KEYCODE_DEL,
+ KeyEvent.KEYCODE_PAGE_DOWN -> {
+ timber.d("Back navigation triggered by key: $keyCode")
+ onBackPressedDispatcher.onBackPressed()
+ true
+ }
+ else -> super.onKeyDown(keyCode, event)
+ }
+ }
+
+ private fun requestStoragePermissions() {
+ timber.d("=== STORAGE PERMISSION CHECK ===")
+ timber.d("Android SDK Version: ${Build.VERSION.SDK_INT}")
+
+ when {
+ // Android 11+ (API 30+) requires special "All files access" permission
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
+ timber.d("Android 11+ detected - checking All Files Access")
+ if (!Environment.isExternalStorageManager()) {
+ timber.d("All Files Access not granted - attempting to open settings")
+ try {
+ val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
+ data = Uri.parse("package:$packageName")
+ }
+ manageStorageLauncher.launch(intent)
+ timber.d("✓ Launched settings for All Files Access")
+ } catch (e: Exception) {
+ timber.w(e, "Unable to request All Files Access on this device (TV doesn't support this)")
+ // On Android TV, this permission doesn't exist - just skip it
+ // The app can still access its own cache/data directories without this permission
}
+ } else {
+ timber.d("✓ All Files Access already granted")
+ }
+ }
+ // Android 6-10 (API 23-29) requires runtime permissions
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
+ timber.d("Android 6-10 detected - checking storage permissions")
+ val permissionsToRequest = mutableListOf()
+
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED) {
+ permissionsToRequest.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ timber.d("WRITE_EXTERNAL_STORAGE not granted")
+ }
+
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED) {
+ permissionsToRequest.add(Manifest.permission.READ_EXTERNAL_STORAGE)
+ timber.d("READ_EXTERNAL_STORAGE not granted")
}
+
+ if (permissionsToRequest.isNotEmpty()) {
+ timber.d("Requesting ${permissionsToRequest.size} storage permissions")
+ storagePermissionLauncher.launch(permissionsToRequest.toTypedArray())
+ } else {
+ timber.d("✓ All storage permissions already granted")
+ }
+ }
+ // Android 5 and below (API <23) - permissions granted at install time
+ else -> {
+ timber.d("Android 5 or below - permissions granted at install time")
}
}
}
diff --git a/app/tv/src/main/java/com/m3u/tv/screens/common/ErrorScreen.kt b/app/tv/src/main/java/com/m3u/tv/screens/common/ErrorScreen.kt
new file mode 100644
index 000000000..8814d3001
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/screens/common/ErrorScreen.kt
@@ -0,0 +1,70 @@
+package com.m3u.tv.screens.common
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Error
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Button
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+
+/**
+ * Error screen shown when initialization or critical operations fail.
+ * Displays error message and optional retry button.
+ */
+@Composable
+fun ErrorScreen(
+ message: String,
+ onRetry: (() -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ modifier = Modifier.padding(48.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Error,
+ contentDescription = null,
+ modifier = Modifier.size(72.dp),
+ tint = MaterialTheme.colorScheme.error
+ )
+
+ Text(
+ text = "Error",
+ style = MaterialTheme.typography.displaySmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ onRetry?.let {
+ Button(onClick = it) {
+ Text("Retry")
+ }
+ }
+ }
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/screens/common/LoadingScreen.kt b/app/tv/src/main/java/com/m3u/tv/screens/common/LoadingScreen.kt
new file mode 100644
index 000000000..68af86982
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/screens/common/LoadingScreen.kt
@@ -0,0 +1,105 @@
+package com.m3u.tv.screens.common
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * Professional loading screen shown during app initialization.
+ * Displays an animated loading indicator and status message.
+ */
+@Composable
+fun LoadingScreen(
+ message: String = "Initializing...",
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ modifier = Modifier.padding(32.dp)
+ ) {
+ // Custom loading indicator for TV
+ LoadingIndicator(
+ modifier = Modifier.size(64.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ Text(
+ text = message,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+}
+
+/**
+ * Custom loading indicator with rotating dots animation
+ */
+@Composable
+private fun LoadingIndicator(
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.primary
+) {
+ val infiniteTransition = rememberInfiniteTransition(label = "loadingRotation")
+
+ val rotation by infiniteTransition.animateFloat(
+ initialValue = 0f,
+ targetValue = 360f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1200, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "rotation"
+ )
+
+ Canvas(modifier = modifier) {
+ val dotCount = 8
+ val radius = size.minDimension / 3f
+ val dotRadius = size.minDimension / 16f
+
+ for (i in 0 until dotCount) {
+ val angle = (rotation + (i * 360f / dotCount)) * (Math.PI / 180f).toFloat()
+ val x = center.x + radius * cos(angle)
+ val y = center.y + radius * sin(angle)
+
+ // Fade dots based on position for trail effect
+ val alpha = ((i + 1) / dotCount.toFloat()).coerceIn(0.3f, 1f)
+
+ drawCircle(
+ color = color.copy(alpha = alpha),
+ radius = dotRadius,
+ center = Offset(x, y)
+ )
+ }
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/screens/profile/ProfileScreen.kt b/app/tv/src/main/java/com/m3u/tv/screens/profile/ProfileScreen.kt
index 74a6cdd98..d7b23d666 100644
--- a/app/tv/src/main/java/com/m3u/tv/screens/profile/ProfileScreen.kt
+++ b/app/tv/src/main/java/com/m3u/tv/screens/profile/ProfileScreen.kt
@@ -164,6 +164,9 @@ fun ProfileScreen(
// }
composable(ProfileScreens.Optional()) {
+ }
+ composable(ProfileScreens.Security()) {
+ viewModel.SecuritySection()
}
composable(ProfileScreens.HelpAndSupport()) {
AboutSection()
diff --git a/app/tv/src/main/java/com/m3u/tv/screens/profile/ProfileScreens.kt b/app/tv/src/main/java/com/m3u/tv/screens/profile/ProfileScreens.kt
index 08434ca6c..48877a24b 100644
--- a/app/tv/src/main/java/com/m3u/tv/screens/profile/ProfileScreens.kt
+++ b/app/tv/src/main/java/com/m3u/tv/screens/profile/ProfileScreens.kt
@@ -3,6 +3,7 @@ package com.m3u.tv.screens.profile
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Support
import androidx.compose.material.icons.filled.Translate
import androidx.compose.ui.graphics.vector.ImageVector
@@ -15,6 +16,7 @@ enum class ProfileScreens(
Subscribe(Icons.Default.MusicNote, R.string.feat_setting_label_subscribe),
// Appearance(Icons.Default.ColorLens, R.string.feat_setting_appearance),
Optional(Icons.Default.Translate, R.string.feat_setting_optional_features),
+ Security(Icons.Default.Security, R.string.feat_setting_security),
HelpAndSupport(Icons.Default.Support, R.string.feat_about_title);
operator fun invoke() = name
diff --git a/app/tv/src/main/java/com/m3u/tv/screens/profile/SecuritySection.kt b/app/tv/src/main/java/com/m3u/tv/screens/profile/SecuritySection.kt
new file mode 100644
index 000000000..884e75068
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/screens/profile/SecuritySection.kt
@@ -0,0 +1,178 @@
+package com.m3u.tv.screens.profile
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Lock
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.tv.material3.Button
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.m3u.business.setting.SettingViewModel
+import com.m3u.core.architecture.preferences.settings
+import com.m3u.i18n.R
+import com.m3u.tv.screens.security.PINInputScreen
+import timber.log.Timber
+
+@Composable
+internal fun SettingViewModel.SecuritySection() {
+ val context = LocalContext.current
+ var showPINSetup by remember { mutableStateOf(false) }
+ var pinEncryptionEnabled by remember { mutableStateOf(false) }
+
+ // Check PIN encryption status
+ LaunchedEffect(Unit) {
+ pinEncryptionEnabled = isPINEncryptionEnabled()
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Main content
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(32.dp)
+ ) {
+ // =========================
+ // PIN ENCRYPTION SECTION
+ // =========================
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // PIN Section Title
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.feat_setting_pin_encryption_group),
+ style = MaterialTheme.typography.headlineMedium
+ )
+
+ // PIN Status Icon
+ Icon(
+ imageVector = Icons.Rounded.Lock,
+ contentDescription = null,
+ tint = if (pinEncryptionEnabled)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ // PIN Status Text
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Lock,
+ contentDescription = null,
+ tint = if (pinEncryptionEnabled)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Column {
+ Text(
+ text = stringResource(R.string.feat_setting_pin_encryption_status_enabled.takeIf { pinEncryptionEnabled }
+ ?: R.string.feat_setting_pin_encryption_status_disabled),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Text(
+ text = if (pinEncryptionEnabled)
+ "Database encrypted with 6-digit PIN"
+ else
+ "No PIN protection",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // PIN Action Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ if (!pinEncryptionEnabled) {
+ Button(
+ onClick = {
+ Timber.tag("SecuritySection").d("=== ENABLE PIN ENCRYPTION BUTTON CLICKED ===")
+ showPINSetup = true
+ }
+ ) {
+ Text(stringResource(R.string.feat_setting_pin_encryption_enable))
+ }
+ } else {
+ Button(
+ onClick = {
+ Timber.tag("SecuritySection").d("=== DISABLE PIN ENCRYPTION BUTTON CLICKED ===")
+ // TODO: Prompt for PIN confirmation before disabling
+ Timber.tag("SecuritySection").w("Disable not yet implemented - need PIN confirmation")
+ }
+ ) {
+ Text(stringResource(R.string.feat_setting_pin_encryption_disable))
+ }
+ }
+ }
+
+ // PIN Warning Text
+ if (!pinEncryptionEnabled) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.feat_setting_pin_encryption_warning),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ }
+
+ // Show PIN setup dialog
+ if (showPINSetup) {
+ PINInputScreen(
+ title = stringResource(R.string.feat_setting_pin_encryption_setup_title),
+ subtitle = stringResource(R.string.feat_setting_pin_encryption_setup_subtitle),
+ onPINEntered = { pin ->
+ Timber.tag("SecuritySection").d("=== PIN ENTERED: length=${pin.length} ===")
+ Timber.tag("SecuritySection").d("Calling enablePINEncryption()...")
+ enablePINEncryption(pin)
+ showPINSetup = false
+ pinEncryptionEnabled = true
+ Timber.tag("SecuritySection").d("PIN setup complete")
+ },
+ onCancel = {
+ Timber.tag("SecuritySection").d("PIN setup cancelled")
+ showPINSetup = false
+ }
+ )
+ }
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/screens/profile/SubscribeSection.kt b/app/tv/src/main/java/com/m3u/tv/screens/profile/SubscribeSection.kt
index 72d69a922..f81e46946 100644
--- a/app/tv/src/main/java/com/m3u/tv/screens/profile/SubscribeSection.kt
+++ b/app/tv/src/main/java/com/m3u/tv/screens/profile/SubscribeSection.kt
@@ -2,6 +2,7 @@ package com.m3u.tv.screens.profile
import android.view.KeyEvent.KEYCODE_DPAD_UP
import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,6 +29,9 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Tab
import androidx.tv.material3.TabRow
import androidx.tv.material3.Text
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.m3u.business.playlist.PlaylistViewModel
import com.m3u.business.setting.SettingViewModel
import com.m3u.core.foundation.ui.thenIf
import com.m3u.data.database.model.DataSource
@@ -49,10 +53,12 @@ data class AccountsSectionData(
@Composable
fun SettingViewModel.SubscribeSection() {
val childPadding = rememberChildPadding()
+ val playlistViewModel: PlaylistViewModel = hiltViewModel()
val dataSources = listOf(
DataSource.M3U,
DataSource.EPG,
- DataSource.Xtream
+ DataSource.Xtream,
+ DataSource.WebDrop
)
val focusRequesters = remember { List(size = dataSources.size + 1) { FocusRequester() } }
@@ -64,7 +70,7 @@ fun SettingViewModel.SubscribeSection() {
item {
val (parent, child) = createInitialFocusRestorerModifiers()
val tabIndex =
- remember(selectedState.value) { dataSources.indexOf(selectedState.value) }
+ remember(properties.selectedState.value) { dataSources.indexOf(properties.selectedState.value) }
var isTabRowFocused by remember { mutableStateOf(false) }
TabRow(
selectedTabIndex = tabIndex,
@@ -89,10 +95,10 @@ fun SettingViewModel.SubscribeSection() {
.then(parent)
) {
dataSources.forEachIndexed { index, dataSource ->
- val isSelected = dataSource == selectedState.value
+ val isSelected = dataSource == properties.selectedState.value
Tab(
selected = isSelected,
- onFocus = { selectedState.value = dataSource },
+ onFocus = { properties.selectedState.value = dataSource },
modifier = Modifier
.height(32.dp)
.focusRequester(focusRequesters[index + 1])
@@ -112,10 +118,11 @@ fun SettingViewModel.SubscribeSection() {
}
}
- when (selectedState.value) {
+ when (properties.selectedState.value) {
DataSource.M3U -> m3uPageConfiguration(this)
DataSource.EPG -> epgPageConfiguration(this)
DataSource.Xtream -> xtreamPageConfiguration(this)
+ DataSource.WebDrop -> webDropPageConfiguration(this, playlistViewModel)
else -> {}
}
}
@@ -126,13 +133,13 @@ private fun SettingViewModel.m3uPageConfiguration(
) {
with(scope) {
input(
- value = titleState.value,
- onValueChanged = { titleState.value = it },
+ value = properties.titleState.value,
+ onValueChanged = { properties.titleState.value = it },
placeholder = R.string.feat_setting_placeholder_title
)
input(
- value = urlState.value,
- onValueChanged = { urlState.value = it },
+ value = properties.urlState.value,
+ onValueChanged = { properties.urlState.value = it },
placeholder = R.string.feat_setting_placeholder_url
)
item {
@@ -153,13 +160,13 @@ private fun SettingViewModel.epgPageConfiguration(
) {
with(scope) {
input(
- value = titleState.value,
- onValueChanged = { titleState.value = it },
+ value = properties.titleState.value,
+ onValueChanged = { properties.titleState.value = it },
placeholder = R.string.feat_setting_placeholder_epg_title
)
input(
- value = epgState.value,
- onValueChanged = { epgState.value = it },
+ value = properties.epgState.value,
+ onValueChanged = { properties.epgState.value = it },
placeholder = R.string.feat_setting_placeholder_epg
)
item {
@@ -180,23 +187,23 @@ private fun SettingViewModel.xtreamPageConfiguration(
) {
with(scope) {
input(
- value = titleState.value,
- onValueChanged = { titleState.value = it },
+ value = properties.titleState.value,
+ onValueChanged = { properties.titleState.value = it },
placeholder = R.string.feat_setting_placeholder_title
)
input(
- value = urlState.value,
- onValueChanged = { urlState.value = it },
+ value = properties.urlState.value,
+ onValueChanged = { properties.urlState.value = it },
placeholder = R.string.feat_setting_placeholder_url
)
input(
- value = usernameState.value,
- onValueChanged = { usernameState.value = it },
+ value = properties.usernameState.value,
+ onValueChanged = { properties.usernameState.value = it },
placeholder = R.string.feat_setting_placeholder_username
)
input(
- value = passwordState.value,
- onValueChanged = { passwordState.value = it },
+ value = properties.passwordState.value,
+ onValueChanged = { properties.passwordState.value = it },
placeholder = R.string.feat_setting_placeholder_password
)
item {
@@ -212,6 +219,60 @@ private fun SettingViewModel.xtreamPageConfiguration(
}
}
+private fun SettingViewModel.webDropPageConfiguration(
+ scope: LazyListScope,
+ playlistViewModel: PlaylistViewModel
+) {
+ with(scope) {
+ item {
+ val webServerState by playlistViewModel.webServerState.collectAsStateWithLifecycle()
+
+ Column(
+ modifier = Modifier.padding(vertical = 16.dp),
+ verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp)
+ ) {
+ // Info text
+ Text(
+ text = stringResource(R.string.feat_setting_webdrop_info),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Server URL (when running)
+ if (webServerState.accessUrl != null) {
+ Column(
+ verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.feat_setting_webdrop_access_url),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = webServerState.accessUrl ?: "",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+
+ // Start/Stop button
+ Button(
+ onClick = { playlistViewModel.toggleWebServer() }
+ ) {
+ Text(
+ text = if (webServerState.isRunning) {
+ stringResource(R.string.feat_setting_webdrop_stop_server)
+ } else {
+ stringResource(R.string.feat_setting_webdrop_start_server)
+ }.uppercase()
+ )
+ }
+ }
+ }
+ }
+}
+
private fun LazyListScope.input(
value: String,
onValueChanged: (String) -> Unit,
diff --git a/app/tv/src/main/java/com/m3u/tv/screens/security/EncryptionLockScreen.kt b/app/tv/src/main/java/com/m3u/tv/screens/security/EncryptionLockScreen.kt
new file mode 100644
index 000000000..e2d3577cd
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/screens/security/EncryptionLockScreen.kt
@@ -0,0 +1,126 @@
+package com.m3u.tv.screens.security
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Usb
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Button
+import androidx.tv.material3.ButtonDefaults
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+
+/**
+ * Enhancement #3: Auto-Lock on USB Removal
+ * Full-screen lock overlay when USB is removed
+ */
+@Composable
+fun EncryptionLockScreen(
+ lockReason: String,
+ onUnlockAttempt: () -> Unit,
+ onDisableEncryption: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.95f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ modifier = Modifier.padding(48.dp)
+ ) {
+ // Lock Icon
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = "Locked",
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(80.dp)
+ )
+
+ // Title
+ Text(
+ text = "Application Locked",
+ style = MaterialTheme.typography.displayMedium,
+ color = Color.White
+ )
+
+ // Lock Reason
+ Text(
+ text = lockReason,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.error
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Instructions
+ Text(
+ text = "The application has been locked because the USB encryption key was removed.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = Color.White.copy(alpha = 0.7f)
+ )
+
+ Text(
+ text = "To unlock, please insert the USB device with the encryption key.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = Color.White.copy(alpha = 0.7f)
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Unlock Button
+ Button(
+ onClick = onUnlockAttempt,
+ colors = ButtonDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Usb,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text("Check USB and Unlock")
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Disable Encryption Button (secondary action)
+ Button(
+ onClick = onDisableEncryption,
+ colors = ButtonDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer
+ )
+ ) {
+ Text("Disable Encryption (Requires USB)")
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "Warning: Disabling encryption will require the USB key to decrypt the database.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
+ )
+ }
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/screens/security/EncryptionStatusDashboard.kt b/app/tv/src/main/java/com/m3u/tv/screens/security/EncryptionStatusDashboard.kt
new file mode 100644
index 000000000..60deb7bf5
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/screens/security/EncryptionStatusDashboard.kt
@@ -0,0 +1,308 @@
+package com.m3u.tv.screens.security
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.filled.Storage
+import androidx.compose.material.icons.filled.Usb
+import androidx.compose.material.icons.filled.Verified
+import androidx.compose.material.icons.filled.Warning
+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.setValue
+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.unit.dp
+import androidx.tv.material3.Card
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.m3u.data.repository.usbkey.HealthStatus
+import com.m3u.data.repository.usbkey.USBKeyState
+import com.m3u.data.security.EncryptionMetricsCalculator
+import kotlinx.coroutines.delay
+
+/**
+ * Enhancement #9: Encryption Status Dashboard
+ * Comprehensive dashboard showing encryption system metrics
+ */
+@Composable
+fun EncryptionStatusDashboard(
+ usbKeyState: USBKeyState,
+ metricsCalculator: EncryptionMetricsCalculator,
+ modifier: Modifier = Modifier
+) {
+ var lastVerifiedTime by remember { mutableStateOf(null) }
+
+ LaunchedEffect(usbKeyState.lastVerificationTime) {
+ while (true) {
+ lastVerifiedTime = metricsCalculator.getLastVerifiedRelativeTime()
+ delay(60000) // Update every minute
+ }
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Encryption Status",
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Encryption Status Card
+ StatusCard(
+ title = "Encryption",
+ value = if (usbKeyState.isEncryptionEnabled) "Enabled" else "Disabled",
+ icon = Icons.Default.Security,
+ status = if (usbKeyState.isEncryptionEnabled) HealthStatus.HEALTHY else HealthStatus.DISABLED,
+ modifier = Modifier.weight(1f)
+ )
+
+ // USB Connection Card
+ StatusCard(
+ title = "USB Device",
+ value = if (usbKeyState.isConnected) "Connected" else "Disconnected",
+ icon = Icons.Default.Usb,
+ status = when {
+ usbKeyState.isConnected -> HealthStatus.HEALTHY
+ usbKeyState.isEncryptionEnabled -> HealthStatus.WARNING
+ else -> HealthStatus.DISABLED
+ },
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Key Verification Card
+ StatusCard(
+ title = "Key Verification",
+ value = when {
+ !usbKeyState.isEncryptionEnabled -> "N/A"
+ usbKeyState.keyVerified -> "Verified"
+ usbKeyState.verificationError != null -> "Failed"
+ else -> "Not Verified"
+ },
+ icon = Icons.Default.Verified,
+ status = when {
+ !usbKeyState.isEncryptionEnabled -> HealthStatus.DISABLED
+ usbKeyState.keyVerified -> HealthStatus.HEALTHY
+ else -> HealthStatus.WARNING
+ },
+ modifier = Modifier.weight(1f)
+ )
+
+ // Database Size Card
+ StatusCard(
+ title = "Database Size",
+ value = usbKeyState.databaseSize?.let {
+ metricsCalculator.formatBytes(it)
+ } ?: "N/A",
+ icon = Icons.Default.Storage,
+ status = HealthStatus.DISABLED,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Last Verified Card
+ StatusCard(
+ title = "Last Verified",
+ value = lastVerifiedTime ?: "Never",
+ icon = Icons.Default.Check,
+ status = HealthStatus.DISABLED,
+ modifier = Modifier.weight(1f)
+ )
+
+ // Encryption Algorithm Card
+ StatusCard(
+ title = "Algorithm",
+ value = if (usbKeyState.isEncryptionEnabled) {
+ "AES-256"
+ } else {
+ "None"
+ },
+ icon = Icons.Default.Lock,
+ status = HealthStatus.DISABLED,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ // Overall Health Status
+ Spacer(modifier = Modifier.height(8.dp))
+ OverallHealthCard(
+ healthStatus = usbKeyState.healthStatus,
+ isLocked = usbKeyState.isLocked,
+ lockReason = usbKeyState.lockReason
+ )
+ }
+}
+
+@Composable
+private fun StatusCard(
+ title: String,
+ value: String,
+ icon: ImageVector,
+ status: HealthStatus,
+ modifier: Modifier = Modifier
+) {
+ val (backgroundColor, iconColor) = when (status) {
+ HealthStatus.HEALTHY -> Pair(
+ Color(0xFF4CAF50).copy(alpha = 0.1f),
+ Color(0xFF4CAF50)
+ )
+ HealthStatus.WARNING -> Pair(
+ Color(0xFFFFC107).copy(alpha = 0.1f),
+ Color(0xFFFFC107)
+ )
+ HealthStatus.CRITICAL -> Pair(
+ MaterialTheme.colorScheme.errorContainer,
+ MaterialTheme.colorScheme.error
+ )
+ HealthStatus.DISABLED -> Pair(
+ MaterialTheme.colorScheme.surfaceVariant,
+ MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Card(
+ onClick = { /* Status card */ },
+ modifier = modifier.height(100.dp),
+ colors = CardDefaults.colors(
+ containerColor = backgroundColor
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = title,
+ tint = iconColor,
+ modifier = Modifier.size(32.dp)
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.titleMedium,
+ color = iconColor
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun OverallHealthCard(
+ healthStatus: HealthStatus,
+ isLocked: Boolean,
+ lockReason: String?
+) {
+ val (title, icon, color) = when {
+ isLocked -> Triple(
+ "System Locked",
+ Icons.Default.Lock,
+ MaterialTheme.colorScheme.error
+ )
+ healthStatus == HealthStatus.HEALTHY -> Triple(
+ "System Healthy",
+ Icons.Default.Check,
+ Color(0xFF4CAF50)
+ )
+ healthStatus == HealthStatus.WARNING -> Triple(
+ "Warning",
+ Icons.Default.Warning,
+ Color(0xFFFFC107)
+ )
+ healthStatus == HealthStatus.CRITICAL -> Triple(
+ "Critical Issue",
+ Icons.Default.Close,
+ MaterialTheme.colorScheme.error
+ )
+ else -> Triple(
+ "Encryption Disabled",
+ Icons.Default.Security,
+ MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Card(
+ onClick = { /* Overall health */ },
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.colors(
+ containerColor = color.copy(alpha = 0.1f)
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = title,
+ tint = color,
+ modifier = Modifier.size(40.dp)
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = color
+ )
+ if (lockReason != null) {
+ Text(
+ text = lockReason,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/screens/security/PINInputScreen.kt b/app/tv/src/main/java/com/m3u/tv/screens/security/PINInputScreen.kt
new file mode 100644
index 000000000..c1271929c
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/screens/security/PINInputScreen.kt
@@ -0,0 +1,468 @@
+package com.m3u.tv.screens.security
+
+import android.view.KeyEvent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.width
+import androidx.compose.foundation.shape.CircleShape
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Button
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+
+/**
+ * PIN input screen for Android TV with D-pad navigation and numeric keypad
+ * Enforces exactly 6 digits with enterprise-grade TV UX
+ */
+@Composable
+fun PINInputScreen(
+ title: String,
+ subtitle: String? = null,
+ errorMessage: String? = null,
+ onPINEntered: (String) -> Unit,
+ onCancel: (() -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ var pin by remember { mutableStateOf("") }
+ var confirmPin by remember { mutableStateOf("") }
+ var showConfirm by remember { mutableStateOf(false) }
+ var localError by remember { mutableStateOf(null) }
+
+ // Handle hardware keyboard input for emulator/testing
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .onKeyEvent { keyEvent ->
+ if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
+ val keyCode = keyEvent.nativeKeyEvent.keyCode
+ when {
+ // Handle number keys (0-9)
+ keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> {
+ val digit = (keyCode - KeyEvent.KEYCODE_0).toString()
+ if (pin.length < 6) {
+ pin += digit
+ localError = null
+
+ // Auto-proceed when 6 digits entered
+ if (pin.length == 6) {
+ if (!showConfirm) {
+ confirmPin = pin
+ showConfirm = true
+ pin = ""
+ } else {
+ if (confirmPin == pin) {
+ onPINEntered(pin)
+ } else {
+ localError = "PINs do not match"
+ showConfirm = false
+ pin = ""
+ confirmPin = ""
+ }
+ }
+ }
+ }
+ true
+ }
+ // Handle numpad keys
+ keyCode in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 -> {
+ val digit = (keyCode - KeyEvent.KEYCODE_NUMPAD_0).toString()
+ if (pin.length < 6) {
+ pin += digit
+ localError = null
+
+ if (pin.length == 6) {
+ if (!showConfirm) {
+ confirmPin = pin
+ showConfirm = true
+ pin = ""
+ } else {
+ if (confirmPin == pin) {
+ onPINEntered(pin)
+ } else {
+ localError = "PINs do not match"
+ showConfirm = false
+ pin = ""
+ confirmPin = ""
+ }
+ }
+ }
+ }
+ true
+ }
+ // Backspace to delete last digit
+ keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_BACK -> {
+ if (pin.isNotEmpty()) {
+ pin = pin.dropLast(1)
+ localError = null
+ }
+ true
+ }
+ else -> false
+ }
+ } else {
+ false
+ }
+ }
+ .padding(48.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ modifier = Modifier.width(600.dp)
+ ) {
+ // Title
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ // Subtitle
+ subtitle?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // PIN indicator dots
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(vertical = 16.dp)
+ ) {
+ repeat(6) { index ->
+ Box(
+ modifier = Modifier
+ .size(20.dp)
+ .clip(CircleShape)
+ .background(
+ if (index < pin.length)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.surfaceVariant
+ )
+ )
+ }
+ }
+
+ // Status text
+ Text(
+ text = if (!showConfirm) "Enter 6-digit PIN" else "Confirm PIN",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Error message
+ val displayError = localError ?: errorMessage
+ if (displayError != null) {
+ Text(
+ text = displayError,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Numeric keypad with D-pad navigation
+ NumericKeypad(
+ onDigitClick = { digit ->
+ if (pin.length < 6) {
+ pin += digit
+ localError = null
+
+ if (pin.length == 6) {
+ if (!showConfirm) {
+ confirmPin = pin
+ showConfirm = true
+ pin = ""
+ } else {
+ if (confirmPin == pin) {
+ onPINEntered(pin)
+ } else {
+ localError = "PINs do not match"
+ showConfirm = false
+ pin = ""
+ confirmPin = ""
+ }
+ }
+ }
+ }
+ },
+ onBackspaceClick = {
+ if (pin.isNotEmpty()) {
+ pin = pin.dropLast(1)
+ localError = null
+ }
+ },
+ onClearClick = {
+ pin = ""
+ localError = null
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Cancel button
+ onCancel?.let { cancelAction ->
+ Button(
+ onClick = cancelAction
+ ) {
+ Text("Cancel")
+ }
+ }
+
+ // Instructions
+ Text(
+ text = "Use D-pad or number keys (0-9) to enter PIN",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+/**
+ * TV-optimized numeric keypad with D-pad navigation
+ */
+@Composable
+private fun NumericKeypad(
+ onDigitClick: (String) -> Unit,
+ onBackspaceClick: () -> Unit,
+ onClearClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Rows 1-3 (digits 1-9)
+ for (row in 0..2) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ for (col in 0..2) {
+ val digit = (row * 3 + col + 1).toString()
+ Button(
+ onClick = { onDigitClick(digit) },
+ modifier = Modifier.size(width = 80.dp, height = 60.dp)
+ ) {
+ Text(
+ text = digit,
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+ }
+ }
+ }
+
+ // Row 4 (Clear, 0, Backspace)
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Button(
+ onClick = onClearClick,
+ modifier = Modifier.size(width = 80.dp, height = 60.dp)
+ ) {
+ Text(
+ text = "CLR",
+ style = MaterialTheme.typography.titleSmall
+ )
+ }
+
+ Button(
+ onClick = { onDigitClick("0") },
+ modifier = Modifier.size(width = 80.dp, height = 60.dp)
+ ) {
+ Text(
+ text = "0",
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+
+ Button(
+ onClick = onBackspaceClick,
+ modifier = Modifier.size(width = 80.dp, height = 60.dp)
+ ) {
+ Text(
+ text = "←",
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Simplified PIN unlock screen (no confirmation needed)
+ */
+@Composable
+fun PINUnlockScreen(
+ errorMessage: String? = null,
+ onPINEntered: (String) -> Unit,
+ onCancel: (() -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ var pin by remember { mutableStateOf("") }
+
+ // Handle hardware keyboard input for emulator/testing
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .onKeyEvent { keyEvent ->
+ if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
+ val keyCode = keyEvent.nativeKeyEvent.keyCode
+ when {
+ // Handle number keys (0-9)
+ keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> {
+ val digit = (keyCode - KeyEvent.KEYCODE_0).toString()
+ if (pin.length < 6) {
+ pin += digit
+ if (pin.length == 6) {
+ onPINEntered(pin)
+ pin = "" // Clear for retry if wrong
+ }
+ }
+ true
+ }
+ // Handle numpad keys
+ keyCode in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 -> {
+ val digit = (keyCode - KeyEvent.KEYCODE_NUMPAD_0).toString()
+ if (pin.length < 6) {
+ pin += digit
+ if (pin.length == 6) {
+ onPINEntered(pin)
+ pin = "" // Clear for retry if wrong
+ }
+ }
+ true
+ }
+ // Backspace to delete last digit
+ keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_BACK -> {
+ if (pin.isNotEmpty()) {
+ pin = pin.dropLast(1)
+ }
+ true
+ }
+ else -> false
+ }
+ } else {
+ false
+ }
+ }
+ .padding(48.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ modifier = Modifier.width(600.dp)
+ ) {
+ // Title
+ Text(
+ text = "Enter PIN to Unlock",
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // PIN indicator dots
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(vertical = 16.dp)
+ ) {
+ repeat(6) { index ->
+ Box(
+ modifier = Modifier
+ .size(20.dp)
+ .clip(CircleShape)
+ .background(
+ if (index < pin.length)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.surfaceVariant
+ )
+ )
+ }
+ }
+
+ // Error message
+ errorMessage?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Numeric keypad with D-pad navigation
+ NumericKeypad(
+ onDigitClick = { digit ->
+ if (pin.length < 6) {
+ pin += digit
+ if (pin.length == 6) {
+ onPINEntered(pin)
+ pin = "" // Clear for retry if wrong
+ }
+ }
+ },
+ onBackspaceClick = {
+ if (pin.isNotEmpty()) {
+ pin = pin.dropLast(1)
+ }
+ },
+ onClearClick = {
+ pin = ""
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Cancel button
+ onCancel?.let { cancelAction ->
+ Button(
+ onClick = cancelAction
+ ) {
+ Text("Cancel")
+ }
+ }
+
+ // Instructions
+ Text(
+ text = "Use D-pad or number keys (0-9) to enter PIN",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/screens/security/PINUnlockScreen.kt b/app/tv/src/main/java/com/m3u/tv/screens/security/PINUnlockScreen.kt
new file mode 100644
index 000000000..440ddbe1b
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/screens/security/PINUnlockScreen.kt
@@ -0,0 +1,250 @@
+package com.m3u.tv.screens.security
+
+import android.view.KeyEvent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.shape.CircleShape
+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.clip
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Button
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import timber.log.Timber
+
+/**
+ * Full-screen PIN unlock overlay shown on app startup when PIN encryption is enabled.
+ * Blocks all app access until correct PIN is entered.
+ */
+@Composable
+fun PINUnlockScreen(
+ onPINEntered: (String) -> Unit,
+ errorMessage: String? = null,
+ modifier: Modifier = Modifier
+) {
+ var pin by remember { mutableStateOf("") }
+ var localError by remember { mutableStateOf(errorMessage) }
+
+ // Handle hardware keyboard input for emulator/testing
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .onKeyEvent { keyEvent ->
+ if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
+ val keyCode = keyEvent.nativeKeyEvent.keyCode
+ when {
+ // Handle number keys (0-9)
+ keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> {
+ val digit = (keyCode - KeyEvent.KEYCODE_0).toString()
+ if (pin.length < 6) {
+ pin += digit
+ localError = null
+
+ // Auto-submit when 6 digits entered
+ if (pin.length == 6) {
+ Timber.tag("PINUnlockScreen").d("6 digits entered, submitting...")
+ onPINEntered(pin)
+ // Don't clear PIN here - let the parent handle success/failure
+ }
+ }
+ true
+ }
+ // Handle numpad keys
+ keyCode in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 -> {
+ val digit = (keyCode - KeyEvent.KEYCODE_NUMPAD_0).toString()
+ if (pin.length < 6) {
+ pin += digit
+ localError = null
+
+ if (pin.length == 6) {
+ Timber.tag("PINUnlockScreen").d("6 digits entered via numpad, submitting...")
+ onPINEntered(pin)
+ }
+ }
+ true
+ }
+ // Backspace to delete last digit
+ keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_BACK -> {
+ if (pin.isNotEmpty()) {
+ pin = pin.dropLast(1)
+ localError = null
+ }
+ true
+ }
+ else -> false
+ }
+ } else {
+ false
+ }
+ }
+ ) {
+ Column(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ // Title
+ Text(
+ text = "Enter PIN to Unlock",
+ style = MaterialTheme.typography.displaySmall
+ )
+
+ // Subtitle
+ Text(
+ text = "Database is encrypted",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // PIN indicator dots (6 circles)
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(vertical = 16.dp)
+ ) {
+ repeat(6) { index ->
+ Box(
+ modifier = Modifier
+ .size(20.dp)
+ .clip(CircleShape)
+ .background(
+ if (index < pin.length)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.surfaceVariant
+ )
+ )
+ }
+ }
+
+ // Error message
+ if (localError != null) {
+ Text(
+ text = localError!!,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Numeric keypad
+ NumericKeypad(
+ onDigitClick = { digit ->
+ if (pin.length < 6) {
+ pin += digit
+ localError = null
+
+ // Auto-submit when 6 digits entered
+ if (pin.length == 6) {
+ Timber.tag("PINUnlockScreen").d("6 digits entered via keypad, submitting...")
+ onPINEntered(pin)
+ }
+ }
+ },
+ onBackspaceClick = {
+ if (pin.isNotEmpty()) {
+ pin = pin.dropLast(1)
+ localError = null
+ }
+ },
+ onClearClick = {
+ pin = ""
+ localError = null
+ }
+ )
+ }
+ }
+}
+
+/**
+ * Numeric keypad with 3x4 layout optimized for TV D-pad navigation.
+ * Layout:
+ * 1 2 3
+ * 4 5 6
+ * 7 8 9
+ * CLR 0 ←
+ */
+@Composable
+private fun NumericKeypad(
+ onDigitClick: (String) -> Unit,
+ onBackspaceClick: () -> Unit,
+ onClearClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Rows 1-3 (digits 1-9)
+ for (row in 0..2) {
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ for (col in 0..2) {
+ val digit = (row * 3 + col + 1).toString()
+ Button(
+ onClick = { onDigitClick(digit) },
+ modifier = Modifier.size(width = 80.dp, height = 60.dp)
+ ) {
+ Text(
+ text = digit,
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+ }
+ }
+ }
+
+ // Row 4 (Clear, 0, Backspace)
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(
+ onClick = onClearClick,
+ modifier = Modifier.size(width = 80.dp, height = 60.dp)
+ ) {
+ Text(
+ text = "CLR",
+ style = MaterialTheme.typography.titleSmall
+ )
+ }
+
+ Button(
+ onClick = { onDigitClick("0") },
+ modifier = Modifier.size(width = 80.dp, height = 60.dp)
+ ) {
+ Text(
+ text = "0",
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+
+ Button(
+ onClick = onBackspaceClick,
+ modifier = Modifier.size(width = 80.dp, height = 60.dp)
+ ) {
+ Text(
+ text = "←",
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+ }
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/ui/components/EncryptionProgressDialog.kt b/app/tv/src/main/java/com/m3u/tv/ui/components/EncryptionProgressDialog.kt
new file mode 100644
index 000000000..b36da8e78
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/ui/components/EncryptionProgressDialog.kt
@@ -0,0 +1,116 @@
+package com.m3u.tv.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+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.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Card
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.m3u.data.repository.usbkey.EncryptionProgress
+
+/**
+ * Enhancement #6: Encryption Progress Dialog
+ * Shows real-time progress during encryption/decryption operations
+ */
+@Composable
+fun EncryptionProgressDialog(
+ progress: EncryptionProgress?,
+ onDismiss: (() -> Unit)? = null
+) {
+ if (progress == null) return
+
+ // Full screen overlay
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.7f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Card(
+ onClick = { /* Prevent dismiss */ },
+ modifier = Modifier.padding(32.dp),
+ colors = CardDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(32.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Title
+ Text(
+ text = when (progress.step) {
+ com.m3u.data.repository.usbkey.EncryptionStep.PREPARING -> "Preparing..."
+ com.m3u.data.repository.usbkey.EncryptionStep.GENERATING_KEY -> "Generating Key..."
+ com.m3u.data.repository.usbkey.EncryptionStep.CREATING_DATABASE -> "Creating Database..."
+ com.m3u.data.repository.usbkey.EncryptionStep.MIGRATING_DATA -> "Migrating Data..."
+ com.m3u.data.repository.usbkey.EncryptionStep.VERIFYING -> "Verifying..."
+ com.m3u.data.repository.usbkey.EncryptionStep.FINALIZING -> "Finalizing..."
+ com.m3u.data.repository.usbkey.EncryptionStep.COMPLETE -> "Complete!"
+ },
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ // Current operation description
+ Text(
+ text = progress.currentOperation,
+ style = MaterialTheme.typography.bodyLarge
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Simple progress bar (box-based)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp)
+ .background(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = RoundedCornerShape(4.dp)
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(progress.percentage / 100f)
+ .height(8.dp)
+ .background(
+ color = MaterialTheme.colorScheme.primary,
+ shape = RoundedCornerShape(4.dp)
+ )
+ )
+ }
+
+ // Percentage text
+ Text(
+ text = "${progress.percentage}%",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Warning message
+ if (progress.percentage < 100) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Please do not remove the USB device or close the app.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/ui/components/USBStatusIndicator.kt b/app/tv/src/main/java/com/m3u/tv/ui/components/USBStatusIndicator.kt
new file mode 100644
index 000000000..c16a32608
--- /dev/null
+++ b/app/tv/src/main/java/com/m3u/tv/ui/components/USBStatusIndicator.kt
@@ -0,0 +1,85 @@
+package com.m3u.tv.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+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.filled.Lock
+import androidx.compose.material.icons.filled.LockOpen
+import androidx.compose.material.icons.filled.Usb
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import com.m3u.data.repository.usbkey.USBKeyState
+
+/**
+ * Enhancement #5: USB Status Indicator
+ * Compact status indicator showing USB connection and encryption status
+ */
+@Composable
+fun USBStatusIndicator(
+ usbKeyState: USBKeyState,
+ modifier: Modifier = Modifier
+) {
+ val (icon, tint, description) = when {
+ // Locked state - red
+ usbKeyState.isLocked -> Triple(
+ Icons.Default.Lock,
+ MaterialTheme.colorScheme.error,
+ "Locked - USB removed"
+ )
+
+ // Connected and encrypted - green
+ usbKeyState.isConnected && usbKeyState.isEncryptionEnabled && usbKeyState.isDatabaseUnlocked -> Triple(
+ Icons.Default.LockOpen,
+ Color(0xFF4CAF50), // Green
+ "Encrypted and unlocked"
+ )
+
+ // Encryption enabled but not connected - yellow warning
+ usbKeyState.isEncryptionEnabled && !usbKeyState.isConnected -> Triple(
+ Icons.Default.Warning,
+ Color(0xFFFFC107), // Amber/Yellow
+ "USB disconnected"
+ )
+
+ // Connected but not encrypted - blue
+ usbKeyState.isConnected && !usbKeyState.isEncryptionEnabled -> Triple(
+ Icons.Default.Usb,
+ MaterialTheme.colorScheme.primary,
+ "USB connected"
+ )
+
+ // Not connected, not encrypted - gray
+ else -> Triple(
+ Icons.Default.Usb,
+ MaterialTheme.colorScheme.onSurfaceVariant,
+ "No USB device"
+ )
+ }
+
+ Box(
+ modifier = modifier
+ .size(40.dp)
+ .background(
+ color = tint.copy(alpha = 0.2f),
+ shape = CircleShape
+ )
+ .padding(8.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = description,
+ tint = tint,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+}
diff --git a/app/tv/src/main/java/com/m3u/tv/utils/ModifierUtils.kt b/app/tv/src/main/java/com/m3u/tv/utils/ModifierUtils.kt
index 1af0932a2..bf5bb21ff 100644
--- a/app/tv/src/main/java/com/m3u/tv/utils/ModifierUtils.kt
+++ b/app/tv/src/main/java/com/m3u/tv/utils/ModifierUtils.kt
@@ -16,8 +16,10 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.onPlaced
/**
- * Handles horizontal (Left & Right) D-Pad Keys and consumes the event(s) so that the focus doesn't
- * accidentally move to another element.
+ * Handles horizontal (Left & Right) D-Pad Keys and keyboard arrow keys.
+ * Consumes the event(s) so that the focus doesn't accidentally move to another element.
+ * Also supports mouse/keyboard input for emulator development.
+ * Note: Keyboard arrow keys map to DPAD keycodes in Android.
* */
fun Modifier.handleDPadKeyEvents(
onLeft: (() -> Unit)? = null,
@@ -29,21 +31,26 @@ fun Modifier.handleDPadKeyEvents(
}
when (it.nativeKeyEvent.keyCode) {
- KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
+ KeyEvent.KEYCODE_DPAD_LEFT,
+ KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
onLeft?.apply {
onActionUp(::invoke)
return@onPreviewKeyEvent true
}
}
- KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
+ KeyEvent.KEYCODE_DPAD_RIGHT,
+ KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
onRight?.apply {
onActionUp(::invoke)
return@onPreviewKeyEvent true
}
}
- KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
+ KeyEvent.KEYCODE_DPAD_CENTER,
+ KeyEvent.KEYCODE_ENTER,
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
+ KeyEvent.KEYCODE_SPACE -> {
onEnter?.apply {
onActionUp(::invoke)
return@onPreviewKeyEvent true
@@ -55,7 +62,9 @@ fun Modifier.handleDPadKeyEvents(
}
/**
- * Handles all D-Pad Keys
+ * Handles all D-Pad Keys and keyboard arrow keys.
+ * Also supports mouse/keyboard input for emulator development.
+ * Note: Keyboard arrow keys map to DPAD keycodes in Android.
* */
fun Modifier.handleDPadKeyEvents(
onLeft: (() -> Unit)? = null,
@@ -67,23 +76,30 @@ fun Modifier.handleDPadKeyEvents(
if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) {
when (it.nativeKeyEvent.keyCode) {
- KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
+ KeyEvent.KEYCODE_DPAD_LEFT,
+ KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
onLeft?.invoke().also { return@onKeyEvent true }
}
- KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
+ KeyEvent.KEYCODE_DPAD_RIGHT,
+ KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
onRight?.invoke().also { return@onKeyEvent true }
}
- KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP -> {
+ KeyEvent.KEYCODE_DPAD_UP,
+ KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP -> {
onUp?.invoke().also { return@onKeyEvent true }
}
- KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN -> {
+ KeyEvent.KEYCODE_DPAD_DOWN,
+ KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN -> {
onDown?.invoke().also { return@onKeyEvent true }
}
- KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
+ KeyEvent.KEYCODE_DPAD_CENTER,
+ KeyEvent.KEYCODE_ENTER,
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
+ KeyEvent.KEYCODE_SPACE -> {
onEnter?.invoke().also { return@onKeyEvent true }
}
}
diff --git a/app/tv/src/main/res/xml/device_features.xml b/app/tv/src/main/res/xml/device_features.xml
new file mode 100644
index 000000000..4efabf7c8
--- /dev/null
+++ b/app/tv/src/main/res/xml/device_features.xml
@@ -0,0 +1,11 @@
+
+
+
+ true
+
+
+ true
+
+
+ true
+
diff --git a/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistMessage.kt b/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistMessage.kt
index 9819a2055..0a6dbfdf1 100644
--- a/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistMessage.kt
+++ b/business/playlist/src/main/java/com/m3u/business/playlist/PlaylistMessage.kt
@@ -31,4 +31,23 @@ sealed class PlaylistMessage(
resId = string.feat_playlist_success_save_cover,
formatArgs = arrayOf(path)
)
+
+ data object WebServerStarted : PlaylistMessage(
+ level = LEVEL_INFO,
+ type = TYPE_SNACK,
+ resId = string.feat_playlist_web_server_started
+ )
+
+ data object WebServerStopped : PlaylistMessage(
+ level = LEVEL_INFO,
+ type = TYPE_SNACK,
+ resId = string.feat_playlist_web_server_stopped
+ )
+
+ data class WebServerError(val error: String) : PlaylistMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ resId = string.feat_playlist_web_server_error,
+ formatArgs = arrayOf(error)
+ )
}
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 c2c9d1aee..9b5dfbd33 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,6 +40,8 @@ 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.programme.ProgrammeRepository
+import com.m3u.data.repository.webserver.WebServerRepository
+import com.m3u.data.repository.webserver.WebServerState
import com.m3u.data.service.MediaCommand
import com.m3u.data.service.Messager
import com.m3u.data.service.PlayerManager
@@ -77,9 +79,10 @@ class PlaylistViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository,
private val mediaRepository: MediaRepository,
private val programmeRepository: ProgrammeRepository,
+ private val webServerRepository: WebServerRepository,
private val messager: Messager,
private val playerManager: PlayerManager,
- settings: Settings,
+ private val settings: Settings,
workManager: WorkManager,
) : ViewModel() {
private val timber = Timber.tag("PlaylistViewModel")
@@ -368,4 +371,44 @@ class PlaylistViewModel @Inject constructor(
// don't lose
started = SharingStarted.Lazily
)
+
+ // Web Server
+ val webServerState: StateFlow = webServerRepository.state
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = WebServerState(),
+ started = SharingStarted.WhileSubscribed(5_000L)
+ )
+
+ fun startWebServer() {
+ viewModelScope.launch {
+ val port = settings.flowOf(PreferencesKeys.WEB_SERVER_PORT).take(1).stateIn(viewModelScope).value
+ webServerRepository.start(port).onSuccess {
+ timber.d("Web server started successfully")
+ messager.emit(PlaylistMessage.WebServerStarted)
+ }.onFailure { error ->
+ timber.e(error, "Failed to start web server")
+ messager.emit(PlaylistMessage.WebServerError(error.message ?: "Failed to start server"))
+ }
+ }
+ }
+
+ fun stopWebServer() {
+ viewModelScope.launch {
+ webServerRepository.stop().onSuccess {
+ timber.d("Web server stopped successfully")
+ messager.emit(PlaylistMessage.WebServerStopped)
+ }.onFailure { error ->
+ timber.e(error, "Failed to stop web server")
+ }
+ }
+ }
+
+ fun toggleWebServer() {
+ if (webServerState.value.isRunning) {
+ stopWebServer()
+ } else {
+ startWebServer()
+ }
+ }
}
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 614ffe73e..13ff4f2cb 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
@@ -63,4 +63,77 @@ sealed class SettingMessage(
type = TYPE_SNACK,
resId = string.feat_setting_restoring
)
+
+ data object WebDropNoSubscribe : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_error_webdrop_no_subscribe
+ )
+
+ data object USBEncryptionEnabled : SettingMessage(
+ level = LEVEL_INFO,
+ type = TYPE_SNACK,
+ duration = 5.seconds,
+ resId = string.feat_setting_usb_encryption_enabled_success
+ )
+
+ data object USBEncryptionDisabled : SettingMessage(
+ level = LEVEL_INFO,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_usb_encryption_disabled_success
+ )
+
+ data class USBEncryptionError(val message: String) : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ duration = 5.seconds,
+ resId = string.feat_setting_usb_encryption_error,
+ formatArgs = arrayOf(message)
+ )
+
+ data object USBNotConnected : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_usb_encryption_not_connected
+ )
+
+ // PIN Encryption Messages
+ data object PINInvalid : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_pin_invalid
+ )
+
+ data object PINEncryptionEnabled : SettingMessage(
+ level = LEVEL_INFO,
+ type = TYPE_SNACK,
+ duration = 5.seconds,
+ resId = string.feat_setting_pin_encryption_enabled_success
+ )
+
+ data object PINEncryptionDisabled : SettingMessage(
+ level = LEVEL_INFO,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_pin_encryption_disabled_success
+ )
+
+ data class PINEncryptionError(val message: String) : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ duration = 5.seconds,
+ resId = string.feat_setting_pin_encryption_error,
+ formatArgs = arrayOf(message)
+ )
+
+ data object PINIncorrect : SettingMessage(
+ level = LEVEL_ERROR,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_pin_incorrect
+ )
+
+ data object PINUnlocked : SettingMessage(
+ level = LEVEL_INFO,
+ type = TYPE_SNACK,
+ resId = string.feat_setting_pin_unlocked
+ )
}
\ No newline at end of file
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 beaad1d85..36f32db50 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
@@ -24,6 +24,8 @@ 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.playlist.PlaylistRepository
+import com.m3u.data.repository.usbkey.USBKeyRepository
+import com.m3u.data.repository.encryption.PINEncryptionRepository
import com.m3u.data.service.Messager
import com.m3u.data.worker.BackupWorker
import com.m3u.data.worker.RestoreWorker
@@ -40,6 +42,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
+import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@@ -49,10 +52,15 @@ class SettingViewModel @Inject constructor(
private val workManager: WorkManager,
private val settings: Settings,
private val messager: Messager,
+ private val usbKeyRepository: USBKeyRepository,
+ private val pinEncryptionRepository: PINEncryptionRepository,
+ val metricsCalculator: com.m3u.data.security.EncryptionMetricsCalculator,
publisher: Publisher,
// FIXME: do not use dao in viewmodel
private val colorSchemeDao: ColorSchemeDao,
) : ViewModel() {
+ private val timber = Timber.tag("SettingViewModel")
+
val epgs: StateFlow> = playlistRepository
.observeAllEpgs()
.stateIn(
@@ -202,6 +210,11 @@ class SettingViewModel @Inject constructor(
messager.emit(SettingMessage.Enqueued)
}
+ DataSource.WebDrop -> {
+ messager.emit(SettingMessage.WebDropNoSubscribe)
+ return
+ }
+
else -> return
}
}
@@ -316,5 +329,159 @@ class SettingViewModel @Inject constructor(
val versionName: String = publisher.versionName
val versionCode: Int = publisher.versionCode
+ val usbKeyState = usbKeyRepository.state
+ .stateIn(
+ scope = viewModelScope,
+ initialValue = com.m3u.data.repository.usbkey.USBKeyState(),
+ started = SharingStarted.WhileSubscribed(5_000L)
+ )
+
+ fun enableUSBEncryption() {
+ viewModelScope.launch {
+ timber.d("=== enableUSBEncryption() CALLED ===")
+ timber.d("Current USB state: ${usbKeyState.value}")
+ timber.d("Is connected: ${usbKeyState.value.isConnected}")
+ timber.d("Device name: ${usbKeyState.value.deviceName}")
+ timber.d("Is encryption enabled: ${usbKeyState.value.isEncryptionEnabled}")
+
+ if (!usbKeyState.value.isConnected) {
+ timber.w("USB not connected - sending USBNotConnected message")
+ messager.emit(SettingMessage.USBNotConnected)
+ return@launch
+ }
+
+ timber.d("Calling usbKeyRepository.initializeEncryption()...")
+ val result = usbKeyRepository.initializeEncryption()
+
+ result.onSuccess {
+ timber.d("✓ USB encryption initialized successfully")
+ messager.emit(SettingMessage.USBEncryptionEnabled)
+ }.onFailure { error ->
+ timber.e(error, "✗ USB encryption initialization failed")
+ timber.e("Error type: ${error.javaClass.simpleName}")
+ timber.e("Error message: ${error.message}")
+ timber.e("Stack trace: ${error.stackTraceToString()}")
+ messager.emit(SettingMessage.USBEncryptionError(error.message ?: "Unknown error"))
+ }
+
+ timber.d("=== enableUSBEncryption() COMPLETED ===")
+ }
+ }
+
+ fun disableUSBEncryption() {
+ viewModelScope.launch {
+ usbKeyRepository.disableEncryption().onSuccess {
+ messager.emit(SettingMessage.USBEncryptionDisabled)
+ }.onFailure { error ->
+ messager.emit(SettingMessage.USBEncryptionError(error.message ?: "Unknown error"))
+ }
+ }
+ }
+
+ fun requestUSBPermission() {
+ viewModelScope.launch {
+ usbKeyRepository.requestUSBPermission().onFailure { error ->
+ messager.emit(SettingMessage.USBEncryptionError(error.message ?: "Permission denied"))
+ }
+ }
+ }
+
+ // ========================================
+ // PIN Encryption Methods
+ // ========================================
+
+ /**
+ * Enable database encryption with a 6-digit PIN
+ * @param pin Must be exactly 6 digits
+ */
+ fun enablePINEncryption(pin: String) {
+ viewModelScope.launch {
+ timber.d("=== enablePINEncryption() CALLED ===")
+ timber.d("PIN length: ${pin.length}")
+
+ // Validate PIN format
+ if (!pinEncryptionRepository.isValidPIN(pin)) {
+ timber.w("Invalid PIN format")
+ messager.emit(SettingMessage.PINInvalid)
+ return@launch
+ }
+
+ timber.d("Calling pinEncryptionRepository.initializeEncryption()...")
+ val result = pinEncryptionRepository.initializeEncryption(pin)
+
+ result.onSuccess {
+ timber.d("✓ PIN encryption initialized successfully")
+ messager.emit(SettingMessage.PINEncryptionEnabled)
+ }.onFailure { error ->
+ timber.e(error, "✗ PIN encryption initialization failed")
+ timber.e("Error type: ${error.javaClass.simpleName}")
+ timber.e("Error message: ${error.message}")
+ messager.emit(SettingMessage.PINEncryptionError(error.message ?: "Unknown error"))
+ }
+
+ timber.d("=== enablePINEncryption() COMPLETED ===")
+ }
+ }
+
+ /**
+ * Unlock the encrypted database with PIN
+ * @param pin The 6-digit PIN
+ */
+ fun unlockWithPIN(pin: String) {
+ viewModelScope.launch {
+ timber.d("=== unlockWithPIN() CALLED ===")
+
+ val result = pinEncryptionRepository.unlockWithPIN(pin)
+
+ result.onSuccess {
+ timber.d("✓ Database unlocked successfully")
+ messager.emit(SettingMessage.PINUnlocked)
+ }.onFailure { error ->
+ timber.w("✗ PIN unlock failed: ${error.message}")
+ messager.emit(SettingMessage.PINIncorrect)
+ }
+
+ timber.d("=== unlockWithPIN() COMPLETED ===")
+ }
+ }
+
+ /**
+ * Disable PIN encryption and decrypt database
+ * @param pin Current PIN for verification
+ */
+ fun disablePINEncryption(pin: String) {
+ viewModelScope.launch {
+ timber.d("=== disablePINEncryption() CALLED ===")
+
+ val result = pinEncryptionRepository.disableEncryption(pin)
+
+ result.onSuccess {
+ timber.d("✓ PIN encryption disabled successfully")
+ messager.emit(SettingMessage.PINEncryptionDisabled)
+ }.onFailure { error ->
+ timber.e(error, "✗ PIN encryption disable failed")
+ if (error is SecurityException) {
+ messager.emit(SettingMessage.PINIncorrect)
+ } else {
+ messager.emit(SettingMessage.PINEncryptionError(error.message ?: "Unknown error"))
+ }
+ }
+
+ timber.d("=== disablePINEncryption() COMPLETED ===")
+ }
+ }
+
+ /**
+ * Check if PIN encryption is currently enabled
+ */
+ suspend fun isPINEncryptionEnabled(): Boolean {
+ return pinEncryptionRepository.isEncryptionEnabled()
+ }
+
+ /**
+ * Get current encryption progress (if any operation is in progress)
+ */
+ suspend fun getPINEncryptionProgress() = pinEncryptionRepository.getEncryptionProgress()
+
val properties = SettingProperties()
}
diff --git a/business/setting/src/main/java/com/m3u/business/setting/UnlockManager.kt b/business/setting/src/main/java/com/m3u/business/setting/UnlockManager.kt
new file mode 100644
index 000000000..869691352
--- /dev/null
+++ b/business/setting/src/main/java/com/m3u/business/setting/UnlockManager.kt
@@ -0,0 +1,154 @@
+package com.m3u.business.setting
+
+import com.m3u.data.repository.encryption.PINEncryptionRepository
+import com.m3u.data.security.PINKeyManager
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Manages application-wide lock/unlock state for PIN encryption.
+ *
+ * This acts as an authentication gate - the app cannot access the database
+ * until the user successfully unlocks with their PIN.
+ *
+ * Lifecycle:
+ * 1. App starts → `initialize()` called
+ * 2. Check if PIN encryption enabled → State becomes Locked or NoEncryption
+ * 3. If Locked → Show PIN unlock screen
+ * 4. User enters PIN → `attemptUnlock()` called
+ * 5. On success → State becomes Unlocked → Main app can proceed
+ */
+@Singleton
+class UnlockManager @Inject constructor(
+ private val pinKeyManager: PINKeyManager,
+ private val pinRepository: PINEncryptionRepository
+) {
+ private val timber = Timber.tag("UnlockManager")
+
+ /**
+ * Represents the current lock state of the application.
+ */
+ sealed class LockState {
+ /** Checking encryption status */
+ object Initializing : LockState()
+
+ /** No encryption enabled - app can proceed normally */
+ object NoEncryption : LockState()
+
+ /** Database is locked - user must enter PIN */
+ object Locked : LockState()
+
+ /** Database is unlocked - app has full access */
+ object Unlocked : LockState()
+
+ /** An error occurred during initialization */
+ data class Error(val message: String) : LockState()
+ }
+
+ private val _lockState = MutableStateFlow(LockState.Initializing)
+
+ /**
+ * Observable lock state for UI
+ */
+ val lockState: StateFlow = _lockState.asStateFlow()
+
+ /**
+ * Initialize the unlock manager by checking if PIN encryption is enabled.
+ * Should be called in MainActivity.onCreate() BEFORE setContent.
+ */
+ suspend fun initialize() {
+ try {
+ timber.d("=== INITIALIZING UNLOCK MANAGER ===")
+ _lockState.value = LockState.Initializing
+
+ // Check if PIN encryption is enabled
+ val encryptionEnabled = pinRepository.isEncryptionEnabled()
+ timber.d("PIN encryption enabled: $encryptionEnabled")
+
+ if (!encryptionEnabled) {
+ timber.d("No encryption - proceeding to normal startup")
+ _lockState.value = LockState.NoEncryption
+ } else {
+ timber.d("Encryption enabled - database is locked")
+ _lockState.value = LockState.Locked
+ }
+
+ timber.d("=== UNLOCK MANAGER INITIALIZED ===")
+ } catch (e: Exception) {
+ timber.e(e, "Failed to initialize unlock manager")
+ _lockState.value = LockState.Error("Failed to check encryption status: ${e.message}")
+ }
+ }
+
+ /**
+ * Attempts to unlock the database with the provided PIN.
+ *
+ * On success:
+ * - The encryption key is cached in memory by PINKeyManager
+ * - State changes to Unlocked
+ * - Database can now be accessed
+ *
+ * On failure:
+ * - State remains Locked
+ * - User can try again
+ *
+ * @param pin The 6-digit PIN to verify
+ * @return Result.success if PIN is correct, Result.failure otherwise
+ */
+ suspend fun attemptUnlock(pin: String): Result {
+ return try {
+ timber.d("=== ATTEMPTING UNLOCK ===")
+ timber.d("PIN length: ${pin.length}")
+
+ // Verify PIN and derive encryption key
+ val result = pinRepository.unlockWithPIN(pin)
+
+ if (result.isSuccess) {
+ timber.d("✓ PIN correct - unlocking database")
+ _lockState.value = LockState.Unlocked
+ Result.success(Unit)
+ } else {
+ timber.w("✗ PIN incorrect")
+ // State remains Locked
+ Result.failure(result.exceptionOrNull() ?: SecurityException("Unlock failed"))
+ }
+ } catch (e: Exception) {
+ timber.e(e, "Error during unlock attempt")
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Locks the database and clears the cached encryption key.
+ * User will need to enter PIN again.
+ *
+ * This is useful for:
+ * - Manual lock feature
+ * - Auto-lock after timeout
+ * - Security-sensitive operations
+ */
+ fun lock() {
+ timber.d("=== LOCKING DATABASE ===")
+ pinKeyManager.lockDatabase()
+ _lockState.value = LockState.Locked
+ timber.d("✓ Database locked")
+ }
+
+ /**
+ * Checks if the database is currently unlocked.
+ */
+ fun isUnlocked(): Boolean {
+ return _lockState.value is LockState.Unlocked
+ }
+
+ /**
+ * Checks if the database is locked and requires PIN.
+ */
+ fun isLocked(): Boolean {
+ return _lockState.value is LockState.Locked
+ }
+}
diff --git a/core/src/main/java/com/m3u/core/architecture/preferences/Preferences.kt b/core/src/main/java/com/m3u/core/architecture/preferences/Preferences.kt
index 784d63fd1..bfc1ba541 100644
--- a/core/src/main/java/com/m3u/core/architecture/preferences/Preferences.kt
+++ b/core/src/main/java/com/m3u/core/architecture/preferences/Preferences.kt
@@ -17,6 +17,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
@@ -122,7 +123,21 @@ private val PREFERENCES: Map, *> = listOf(
PreferencesKeys.SLIDER to true,
PreferencesKeys.ALWAYS_SHOW_REPLAY to false,
PreferencesKeys.PLAYER_PANEL to true,
- PreferencesKeys.COMPACT_DIMENSION to false
+ PreferencesKeys.COMPACT_DIMENSION to false,
+ PreferencesKeys.WEB_SERVER_ENABLED to false,
+ PreferencesKeys.WEB_SERVER_PORT to 8080,
+ PreferencesKeys.USB_ENCRYPTION_ENABLED to false,
+ PreferencesKeys.USB_ENCRYPTION_DEVICE_ID to "",
+ PreferencesKeys.USB_ENCRYPTION_KEY_FINGERPRINT to "",
+ PreferencesKeys.USB_ENCRYPTION_LAST_VERIFIED to 0L,
+ PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS to false,
+ PreferencesKeys.USB_ENCRYPTION_LAST_OPERATION to "",
+ PreferencesKeys.USB_ENCRYPTION_AUTO_LOCK to true,
+ PreferencesKeys.DIAGNOSTIC_LOG_SANITIZATION_ENABLED to true,
+ PreferencesKeys.PIN_ENCRYPTION_ENABLED to false,
+ PreferencesKeys.ENCRYPTED_DATABASE_KEY to "",
+ PreferencesKeys.ENCRYPTION_KEY_IV to "",
+ PreferencesKeys.ENCRYPTION_SALT to ""
)
.associateBy { it.key }
.mapValues { it.value.value }
@@ -172,4 +187,28 @@ object PreferencesKeys {
val PLAYER_PANEL = booleanPreferencesKey("player_panel")
val COMPACT_DIMENSION = booleanPreferencesKey("compact-dimension")
+
+ // Web Server
+ val WEB_SERVER_ENABLED = booleanPreferencesKey("web-server-enabled")
+ val WEB_SERVER_PORT = intPreferencesKey("web-server-port")
+
+ // USB Encryption
+ val USB_ENCRYPTION_ENABLED = booleanPreferencesKey("usb-encryption-enabled")
+ val USB_ENCRYPTION_DEVICE_ID = stringPreferencesKey("usb-encryption-device-id")
+
+ // USB Encryption - Enhanced Features
+ val USB_ENCRYPTION_KEY_FINGERPRINT = stringPreferencesKey("usb-encryption-key-fingerprint")
+ val USB_ENCRYPTION_LAST_VERIFIED = longPreferencesKey("usb-encryption-last-verified")
+ val USB_ENCRYPTION_IN_PROGRESS = booleanPreferencesKey("usb-encryption-in-progress")
+ val USB_ENCRYPTION_LAST_OPERATION = stringPreferencesKey("usb-encryption-last-operation")
+ val USB_ENCRYPTION_AUTO_LOCK = booleanPreferencesKey("usb-encryption-auto-lock")
+
+ // Diagnostic Logs
+ val DIAGNOSTIC_LOG_SANITIZATION_ENABLED = booleanPreferencesKey("diagnostic-log-sanitization-enabled")
+
+ // PIN Encryption
+ val PIN_ENCRYPTION_ENABLED = booleanPreferencesKey("pin-encryption-enabled")
+ val ENCRYPTED_DATABASE_KEY = stringPreferencesKey("encrypted-database-key")
+ val ENCRYPTION_KEY_IV = stringPreferencesKey("encryption-key-iv")
+ val ENCRYPTION_SALT = stringPreferencesKey("encryption-salt")
}
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index 69a8453e7..f01ed21de 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -72,7 +72,7 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
- implementation(libs.ktor.server.netty)
+ implementation("io.ktor:ktor-server-cio:3.3.1")
implementation(libs.ktor.server.websockets)
implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.content.negotiation)
@@ -88,4 +88,11 @@ dependencies {
// auto
implementation(libs.auto.service.annotations)
ksp(libs.auto.service.ksp)
+
+ // SQLCipher for database encryption
+ implementation("net.zetetic:android-database-sqlcipher:4.5.4")
+ implementation("androidx.sqlite:sqlite-ktx:2.4.0")
+
+ // Security crypto for key verification
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
}
\ No newline at end of file
diff --git a/data/src/main/java/com/m3u/data/api/ApiModule.kt b/data/src/main/java/com/m3u/data/api/ApiModule.kt
index 10f44bc32..e7d70a8b5 100644
--- a/data/src/main/java/com/m3u/data/api/ApiModule.kt
+++ b/data/src/main/java/com/m3u/data/api/ApiModule.kt
@@ -20,6 +20,8 @@ import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.Retrofit
+import timber.log.Timber
+import java.util.concurrent.TimeUnit
import javax.inject.Qualifier
import javax.inject.Singleton
@@ -53,19 +55,87 @@ internal object ApiModule {
@Singleton
@OkhttpClient(false)
fun provideOkhttpClient(): OkHttpClient {
+ val timber = Timber.tag("OkHttpClient")
+
return OkHttpClient.Builder()
.authenticator(Authenticator.JAVA_NET_AUTHENTICATOR)
+ // ========================================
+ // TIMEOUT CONFIGURATION FOR LARGE FILES
+ // ========================================
+ // Based on OkHttp best practices for streaming large files (40MB+ M3U playlists)
+ // - connectTimeout: Time to establish TCP connection (30s for slow networks)
+ // - readTimeout: Time between each data chunk (90s for slow servers/networks)
+ // - writeTimeout: Time to send request data (30s sufficient for GET requests)
+ // - callTimeout: Total time for entire call (5 minutes for large downloads)
+ //
+ // Key insight: With streaming, readTimeout resets with each received chunk,
+ // so even large files work as long as data flows within 90s intervals.
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(90, TimeUnit.SECONDS) // Critical for slow M3U servers
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .callTimeout(5, TimeUnit.MINUTES) // Total max time for large downloads
+ // ========================================
+ // LOGGING AND ERROR HANDLING INTERCEPTOR
+ // ========================================
.addInterceptor { chain ->
val request = chain.request()
+ val url = request.url.toString()
+
+ timber.d("→ HTTP ${request.method} ${url.take(100)}")
+ val startTime = System.currentTimeMillis()
+
try {
- chain.proceed(request)
+ val response = chain.proceed(request)
+ val duration = System.currentTimeMillis() - startTime
+
+ timber.d("← ${response.code} ${url.take(100)} (${duration}ms)")
+
+ // Log response body size for debugging
+ response.body?.contentLength()?.let { size ->
+ timber.d(" Response size: ${size / 1024}KB")
+ }
+
+ response
+ } catch (e: java.net.SocketTimeoutException) {
+ // CRITICAL: Socket timeout - log detailed info for debugging
+ val duration = System.currentTimeMillis() - startTime
+ timber.e(e, "✗ TIMEOUT after ${duration}ms for ${url.take(100)}")
+ timber.e(" This usually means:")
+ timber.e(" - Server took too long to respond (>90s between chunks)")
+ timber.e(" - Network is very slow")
+ timber.e(" - Server is overloaded")
+
+ // Return error response with detailed timeout information
+ Response.Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(408) // Request Timeout (proper HTTP status)
+ .message("Request Timeout after ${duration}ms")
+ .body("{\"error\":\"SocketTimeoutException\",\"message\":\"${e.message}\",\"duration_ms\":$duration}".toResponseBody())
+ .build()
+ } catch (e: java.io.IOException) {
+ // Network I/O error (connection failed, host unreachable, etc.)
+ val duration = System.currentTimeMillis() - startTime
+ timber.e(e, "✗ NETWORK ERROR after ${duration}ms for ${url.take(100)}")
+
+ Response.Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(503) // Service Unavailable
+ .message("Network I/O Error: ${e.message}")
+ .body("{\"error\":\"IOException\",\"message\":\"${e.message}\",\"duration_ms\":$duration}".toResponseBody())
+ .build()
} catch (e: Exception) {
+ // Catch-all for unexpected errors
+ val duration = System.currentTimeMillis() - startTime
+ timber.e(e, "✗ UNEXPECTED ERROR after ${duration}ms for ${url.take(100)}")
+
Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
- .code(999)
- .message(e.message.orEmpty())
- .body("{${e}}".toResponseBody())
+ .code(500) // Internal Server Error
+ .message("Unexpected Error: ${e.message}")
+ .body("{\"error\":\"${e.javaClass.simpleName}\",\"message\":\"${e.message}\",\"duration_ms\":$duration}".toResponseBody())
.build()
}
}
diff --git a/data/src/main/java/com/m3u/data/database/DatabaseMigrationHelper.kt b/data/src/main/java/com/m3u/data/database/DatabaseMigrationHelper.kt
new file mode 100644
index 000000000..18a779bbd
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/database/DatabaseMigrationHelper.kt
@@ -0,0 +1,565 @@
+package com.m3u.data.database
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import net.sqlcipher.database.SQLiteDatabase
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Helper class for migrating an unencrypted Room database to an encrypted one.
+ *
+ * This handles the critical operation of:
+ * 1. Backing up the existing unencrypted database
+ * 2. Creating a new encrypted database with the same schema
+ * 3. Copying all data from the old database to the new encrypted one
+ * 4. Replacing the original database file
+ *
+ * The operation is atomic - if anything fails, the original database is restored.
+ */
+@Singleton
+class DatabaseMigrationHelper @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val timber = Timber.tag("DatabaseMigrationHelper")
+
+ companion object {
+ private const val DATABASE_NAME = "m3u-database"
+ private const val BACKUP_SUFFIX = ".backup"
+ private const val TEMP_SUFFIX = ".temp-encrypted"
+ private const val EXPECTED_KEY_SIZE_BYTES = 32 // 256 bits
+ }
+
+ /**
+ * Migrates an existing unencrypted database to encrypted format.
+ *
+ * @param encryptionKey The 256-bit encryption key to use
+ * @param progressCallback Optional callback for progress updates (0-100)
+ * @return Result indicating success or failure with error message
+ */
+ suspend fun migrateToEncrypted(
+ encryptionKey: ByteArray,
+ progressCallback: ((Int) -> Unit)? = null
+ ): Result = withContext(Dispatchers.IO) {
+ timber.d("=== STARTING DATABASE ENCRYPTION MIGRATION ===")
+
+ return@withContext try {
+ // Validate encryption key
+ if (encryptionKey.size != EXPECTED_KEY_SIZE_BYTES) {
+ val error = "Invalid encryption key size: ${encryptionKey.size} bytes. Expected $EXPECTED_KEY_SIZE_BYTES bytes (256 bits)"
+ timber.e(error)
+ return@withContext Result.failure(Exception(error))
+ }
+ timber.d("Encryption key validated: ${encryptionKey.size} bytes")
+
+ // Load SQLCipher library
+ timber.d("Loading SQLCipher library...")
+ System.loadLibrary("sqlcipher")
+ timber.d("SQLCipher library loaded successfully")
+
+ val dbFile = context.getDatabasePath(DATABASE_NAME)
+ timber.d("Database path: ${dbFile.absolutePath}")
+
+ if (!dbFile.exists()) {
+ val error = "Database file does not exist at: ${dbFile.absolutePath}"
+ timber.e(error)
+ return@withContext Result.failure(Exception(error))
+ }
+ timber.d("Database file exists, size: ${dbFile.length()} bytes")
+
+ val backupFile = File(dbFile.parentFile, "$DATABASE_NAME$BACKUP_SUFFIX")
+ val tempEncryptedFile = File(dbFile.parentFile, "$DATABASE_NAME$TEMP_SUFFIX")
+
+ try {
+ // Step 1: Create backup of original database
+ timber.d("Step 1: Creating backup...")
+ progressCallback?.invoke(10)
+ dbFile.copyTo(backupFile, overwrite = true)
+ timber.d("Backup created: ${backupFile.absolutePath}, size: ${backupFile.length()} bytes")
+ progressCallback?.invoke(20)
+
+ // Step 2: Open the unencrypted database
+ timber.d("Step 2: Opening unencrypted database...")
+ progressCallback?.invoke(30)
+
+ val unencryptedDb = try {
+ SQLiteDatabase.openOrCreateDatabase(
+ dbFile.absolutePath,
+ "", // Empty passphrase for unencrypted
+ null,
+ null
+ )
+ } catch (e: Exception) {
+ timber.e(e, "Failed to open unencrypted database")
+ throw Exception("Failed to open unencrypted database: ${e.message}", e)
+ }
+
+ timber.d("Unencrypted database opened successfully")
+ progressCallback?.invoke(40)
+
+ try {
+ // Step 3: Attach the new encrypted database with CORRECT SQL syntax
+ timber.d("Step 3: Attaching encrypted database...")
+ progressCallback?.invoke(50)
+
+ // Convert ByteArray key to hex string for SQL command
+ val keyHex = encryptionKey.joinToString("") { "%02x".format(it) }
+ timber.d("Encryption key converted to hex format (length: ${keyHex.length} chars)")
+
+ // CRITICAL FIX: SQLCipher KEY syntax requires x'hexstring' WITHOUT outer quotes
+ // KEY x'...' treats it as raw bytes
+ // KEY "x'...'" treats it as a text passphrase (WRONG!)
+ val attachSql = "ATTACH DATABASE '${tempEncryptedFile.absolutePath}' AS encrypted KEY x'$keyHex'"
+ timber.d("Executing ATTACH command...")
+ timber.d("SQL: $attachSql")
+
+ try {
+ unencryptedDb.execSQL(attachSql)
+ timber.d("ATTACH DATABASE executed successfully")
+ } catch (e: Exception) {
+ timber.e(e, "ATTACH DATABASE failed")
+ throw Exception("Failed to attach encrypted database: ${e.message}. SQL was: $attachSql", e)
+ }
+
+ // Step 4: Export all data to the encrypted database
+ timber.d("Step 4: Exporting data to encrypted database...")
+ progressCallback?.invoke(60)
+
+ try {
+ // CRITICAL FIX: Delete android_metadata table from attached database
+ // to prevent "table android_metadata already exists" error
+ // See: https://github.com/sqlcipher/android-database-sqlcipher/issues/55
+ timber.d("Deleting android_metadata table from attached database...")
+ try {
+ unencryptedDb.execSQL("DROP TABLE IF EXISTS encrypted.android_metadata")
+ timber.d("android_metadata table dropped successfully")
+ } catch (e: Exception) {
+ timber.w(e, "Failed to drop android_metadata (may not exist yet, continuing...)")
+ }
+
+ timber.d("Executing sqlcipher_export...")
+ // CRITICAL FIX: Use rawQuery() for SELECT statements, not execSQL()
+ // execSQL() cannot be used for queries that return results
+ val exportCursor = unencryptedDb.rawQuery("SELECT sqlcipher_export('encrypted')", null)
+ exportCursor.moveToFirst() // Execute the query
+ exportCursor.close()
+ timber.d("sqlcipher_export completed successfully")
+ } catch (e: Exception) {
+ timber.e(e, "sqlcipher_export failed")
+ throw Exception("Failed to export data to encrypted database: ${e.message}", e)
+ }
+
+ progressCallback?.invoke(80)
+
+ // Step 5: Detach the encrypted database
+ timber.d("Step 5: Detaching encrypted database...")
+ try {
+ unencryptedDb.execSQL("DETACH DATABASE encrypted")
+ timber.d("DETACH DATABASE executed successfully")
+ } catch (e: Exception) {
+ timber.e(e, "DETACH DATABASE failed (non-critical)")
+ // Non-critical error, continue
+ }
+ } finally {
+ timber.d("Closing unencrypted database...")
+ unencryptedDb.close()
+ timber.d("Unencrypted database closed")
+ }
+
+ // Step 6: Verify the encrypted database can be opened
+ timber.d("Step 6: Verifying encrypted database...")
+ progressCallback?.invoke(85)
+
+ if (!tempEncryptedFile.exists()) {
+ throw Exception("Encrypted database file was not created at: ${tempEncryptedFile.absolutePath}")
+ }
+ timber.d("Encrypted database file exists, size: ${tempEncryptedFile.length()} bytes")
+
+ val encryptedDb = try {
+ SQLiteDatabase.openDatabase(
+ tempEncryptedFile.absolutePath,
+ encryptionKey,
+ null,
+ SQLiteDatabase.OPEN_READONLY,
+ null,
+ null
+ )
+ } catch (e: Exception) {
+ timber.e(e, "Failed to open encrypted database for verification")
+ throw Exception("Failed to open encrypted database: ${e.message}. The encryption may have failed.", e)
+ }
+
+ // Verify we can read from it
+ timber.d("Reading table list from encrypted database...")
+ val cursor = try {
+ encryptedDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null)
+ } catch (e: Exception) {
+ encryptedDb.close()
+ timber.e(e, "Failed to query encrypted database")
+ throw Exception("Failed to read from encrypted database: ${e.message}", e)
+ }
+
+ val tableCount = cursor.count
+ cursor.close()
+ encryptedDb.close()
+ timber.d("Encrypted database contains $tableCount tables")
+
+ if (tableCount == 0) {
+ throw Exception("Encrypted database appears to be empty (0 tables found)")
+ }
+
+ // Step 7: Replace the original database with the encrypted one
+ timber.d("Step 7: Replacing original database with encrypted version...")
+ progressCallback?.invoke(90)
+
+ if (!dbFile.delete()) {
+ throw Exception("Failed to delete original database file")
+ }
+ timber.d("Original database deleted")
+
+ if (!tempEncryptedFile.renameTo(dbFile)) {
+ throw Exception("Failed to rename encrypted database to original location")
+ }
+ timber.d("Encrypted database renamed to original location")
+
+ progressCallback?.invoke(95)
+
+ // Step 8: Final verification
+ timber.d("Step 8: Final verification...")
+ if (!dbFile.exists()) {
+ throw Exception("Database file missing after migration")
+ }
+ timber.d("Final verification passed, database file size: ${dbFile.length()} bytes")
+
+ timber.d("=== ENCRYPTION MIGRATION COMPLETED SUCCESSFULLY ===")
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Migration failed, attempting to restore from backup...")
+
+ // Restore from backup if anything went wrong
+ try {
+ if (backupFile.exists()) {
+ timber.d("Backup file exists, restoring...")
+
+ // Clean up failed encrypted file
+ if (tempEncryptedFile.exists()) {
+ tempEncryptedFile.delete()
+ timber.d("Deleted temporary encrypted file")
+ }
+
+ // Remove corrupted database
+ if (dbFile.exists()) {
+ dbFile.delete()
+ timber.d("Deleted corrupted database file")
+ }
+
+ // Restore backup
+ backupFile.copyTo(dbFile, overwrite = true)
+ timber.d("Backup restored successfully")
+ } else {
+ timber.e("Backup file does not exist, cannot restore!")
+ }
+ } catch (restoreException: Exception) {
+ timber.e(restoreException, "CRITICAL: Failed to restore backup")
+ }
+
+ timber.e("=== ENCRYPTION MIGRATION FAILED ===")
+ Result.failure(Exception("Migration failed: ${e.message}", e))
+ }
+ } catch (e: Exception) {
+ timber.e(e, "Migration preparation failed")
+ Result.failure(Exception("Migration preparation failed: ${e.message}", e))
+ }
+ }
+
+ /**
+ * Migrates an encrypted database back to unencrypted format.
+ *
+ * @param encryptionKey The current encryption key
+ * @param progressCallback Optional callback for progress updates (0-100)
+ * @return Result indicating success or failure
+ */
+ suspend fun migrateToUnencrypted(
+ encryptionKey: ByteArray,
+ progressCallback: ((Int) -> Unit)? = null
+ ): Result = withContext(Dispatchers.IO) {
+ timber.d("=== STARTING DATABASE DECRYPTION MIGRATION ===")
+
+ return@withContext try {
+ // Validate encryption key
+ if (encryptionKey.size != EXPECTED_KEY_SIZE_BYTES) {
+ val error = "Invalid encryption key size: ${encryptionKey.size} bytes. Expected $EXPECTED_KEY_SIZE_BYTES bytes (256 bits)"
+ timber.e(error)
+ return@withContext Result.failure(Exception(error))
+ }
+ timber.d("Encryption key validated: ${encryptionKey.size} bytes")
+
+ // Load SQLCipher library
+ timber.d("Loading SQLCipher library...")
+ System.loadLibrary("sqlcipher")
+ timber.d("SQLCipher library loaded successfully")
+
+ val dbFile = context.getDatabasePath(DATABASE_NAME)
+ timber.d("Database path: ${dbFile.absolutePath}")
+
+ if (!dbFile.exists()) {
+ val error = "Database file does not exist at: ${dbFile.absolutePath}"
+ timber.e(error)
+ return@withContext Result.failure(Exception(error))
+ }
+ timber.d("Database file exists, size: ${dbFile.length()} bytes")
+
+ val backupFile = File(dbFile.parentFile, "$DATABASE_NAME$BACKUP_SUFFIX")
+ val tempUnencryptedFile = File(dbFile.parentFile, "$DATABASE_NAME$TEMP_SUFFIX")
+
+ try {
+ // Step 1: Backup current encrypted database
+ timber.d("Step 1: Creating backup...")
+ progressCallback?.invoke(10)
+ dbFile.copyTo(backupFile, overwrite = true)
+ timber.d("Backup created: ${backupFile.absolutePath}, size: ${backupFile.length()} bytes")
+ progressCallback?.invoke(20)
+
+ // Step 2: Open the encrypted database
+ timber.d("Step 2: Opening encrypted database...")
+ progressCallback?.invoke(30)
+
+ val encryptedDb = try {
+ SQLiteDatabase.openDatabase(
+ dbFile.absolutePath,
+ encryptionKey,
+ null,
+ SQLiteDatabase.OPEN_READWRITE,
+ null,
+ null
+ )
+ } catch (e: Exception) {
+ timber.e(e, "Failed to open encrypted database")
+ throw Exception("Failed to open encrypted database: ${e.message}", e)
+ }
+
+ timber.d("Encrypted database opened successfully")
+ progressCallback?.invoke(40)
+
+ try {
+ // Step 3: Attach the new unencrypted database with CORRECT SQL syntax
+ timber.d("Step 3: Attaching unencrypted database...")
+ progressCallback?.invoke(50)
+
+ // CRITICAL FIX: Empty string key for plaintext database (no outer quotes!)
+ val attachSql = "ATTACH DATABASE '${tempUnencryptedFile.absolutePath}' AS plaintext KEY ''"
+ timber.d("Executing ATTACH command...")
+ timber.d("SQL: $attachSql")
+
+ try {
+ encryptedDb.execSQL(attachSql)
+ timber.d("ATTACH DATABASE executed successfully")
+ } catch (e: Exception) {
+ timber.e(e, "ATTACH DATABASE failed")
+ throw Exception("Failed to attach unencrypted database: ${e.message}. SQL was: $attachSql", e)
+ }
+
+ // Step 4: Export all data to the unencrypted database
+ timber.d("Step 4: Exporting data to unencrypted database...")
+ progressCallback?.invoke(60)
+
+ try {
+ timber.d("Executing sqlcipher_export...")
+ // CRITICAL FIX: Use rawQuery() for SELECT statements, not execSQL()
+ val exportCursor = encryptedDb.rawQuery("SELECT sqlcipher_export('plaintext')", null)
+ exportCursor.moveToFirst() // Execute the query
+ exportCursor.close()
+ timber.d("sqlcipher_export completed successfully")
+ } catch (e: Exception) {
+ timber.e(e, "sqlcipher_export failed")
+ throw Exception("Failed to export data to unencrypted database: ${e.message}", e)
+ }
+
+ progressCallback?.invoke(80)
+
+ // Step 5: Detach
+ timber.d("Step 5: Detaching unencrypted database...")
+ try {
+ encryptedDb.execSQL("DETACH DATABASE plaintext")
+ timber.d("DETACH DATABASE executed successfully")
+ } catch (e: Exception) {
+ timber.e(e, "DETACH DATABASE failed (non-critical)")
+ // Non-critical error, continue
+ }
+ } finally {
+ timber.d("Closing encrypted database...")
+ encryptedDb.close()
+ timber.d("Encrypted database closed")
+ }
+
+ // Step 6: Verify the unencrypted database
+ timber.d("Step 6: Verifying unencrypted database...")
+ progressCallback?.invoke(85)
+
+ if (!tempUnencryptedFile.exists()) {
+ throw Exception("Unencrypted database file was not created at: ${tempUnencryptedFile.absolutePath}")
+ }
+ timber.d("Unencrypted database file exists, size: ${tempUnencryptedFile.length()} bytes")
+
+ val unencryptedDb = try {
+ SQLiteDatabase.openOrCreateDatabase(
+ tempUnencryptedFile.absolutePath,
+ "",
+ null,
+ null
+ )
+ } catch (e: Exception) {
+ timber.e(e, "Failed to open unencrypted database for verification")
+ throw Exception("Failed to open unencrypted database: ${e.message}", e)
+ }
+
+ timber.d("Reading table list from unencrypted database...")
+ val cursor = try {
+ unencryptedDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null)
+ } catch (e: Exception) {
+ unencryptedDb.close()
+ timber.e(e, "Failed to query unencrypted database")
+ throw Exception("Failed to read from unencrypted database: ${e.message}", e)
+ }
+
+ val tableCount = cursor.count
+ cursor.close()
+ unencryptedDb.close()
+ timber.d("Unencrypted database contains $tableCount tables")
+
+ if (tableCount == 0) {
+ throw Exception("Unencrypted database appears to be empty (0 tables found)")
+ }
+
+ // Step 7: Replace with unencrypted version
+ timber.d("Step 7: Replacing original database with unencrypted version...")
+ progressCallback?.invoke(90)
+
+ if (!dbFile.delete()) {
+ throw Exception("Failed to delete original database file")
+ }
+ timber.d("Original database deleted")
+
+ if (!tempUnencryptedFile.renameTo(dbFile)) {
+ throw Exception("Failed to rename unencrypted database to original location")
+ }
+ timber.d("Unencrypted database renamed to original location")
+
+ progressCallback?.invoke(95)
+
+ // Step 8: Final verification
+ timber.d("Step 8: Final verification...")
+ if (!dbFile.exists()) {
+ throw Exception("Database file missing after migration")
+ }
+ timber.d("Final verification passed, database file size: ${dbFile.length()} bytes")
+
+ timber.d("=== DECRYPTION MIGRATION COMPLETED SUCCESSFULLY ===")
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Decryption failed, attempting to restore from backup...")
+
+ // Restore from backup if anything went wrong
+ try {
+ if (backupFile.exists()) {
+ timber.d("Backup file exists, restoring...")
+
+ // Clean up failed unencrypted file
+ if (tempUnencryptedFile.exists()) {
+ tempUnencryptedFile.delete()
+ timber.d("Deleted temporary unencrypted file")
+ }
+
+ // Remove corrupted database
+ if (dbFile.exists()) {
+ dbFile.delete()
+ timber.d("Deleted corrupted database file")
+ }
+
+ // Restore backup
+ backupFile.copyTo(dbFile, overwrite = true)
+ timber.d("Backup restored successfully")
+ } else {
+ timber.e("Backup file does not exist, cannot restore!")
+ }
+ } catch (restoreException: Exception) {
+ timber.e(restoreException, "CRITICAL: Failed to restore backup")
+ }
+
+ timber.e("=== DECRYPTION MIGRATION FAILED ===")
+ Result.failure(Exception("Decryption migration failed: ${e.message}", e))
+ }
+ } catch (e: Exception) {
+ timber.e(e, "Decryption preparation failed")
+ Result.failure(Exception("Decryption preparation failed: ${e.message}", e))
+ }
+ }
+
+ /**
+ * Cleans up backup files after successful migration verification.
+ */
+ suspend fun cleanupBackups(): Result = withContext(Dispatchers.IO) {
+ return@withContext try {
+ val backupFile = File(context.getDatabasePath(DATABASE_NAME).parentFile, "$DATABASE_NAME$BACKUP_SUFFIX")
+ if (backupFile.exists()) {
+ backupFile.delete()
+ }
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Checks if the database is currently encrypted by attempting to read it.
+ *
+ * @return true if encrypted, false if unencrypted, null if unable to determine
+ */
+ suspend fun isDatabaseEncrypted(): Boolean? = withContext(Dispatchers.IO) {
+ timber.d("Checking if database is encrypted...")
+
+ return@withContext try {
+ System.loadLibrary("sqlcipher")
+ val dbFile = context.getDatabasePath(DATABASE_NAME)
+ timber.d("Database path: ${dbFile.absolutePath}")
+
+ if (!dbFile.exists()) {
+ timber.d("Database file does not exist")
+ return@withContext null
+ }
+
+ // Try to open without password
+ try {
+ timber.d("Attempting to open database without password...")
+ val db = SQLiteDatabase.openOrCreateDatabase(
+ dbFile.absolutePath,
+ "",
+ null,
+ null
+ )
+
+ // Try to read from it
+ timber.d("Attempting to read from database...")
+ val cursor = db.rawQuery("SELECT name FROM sqlite_master LIMIT 1", null)
+ cursor.close()
+ db.close()
+
+ // If we got here, it's unencrypted
+ timber.d("Database is UNENCRYPTED (opened successfully without password)")
+ false
+ } catch (e: Exception) {
+ // If opening without password failed, it's likely encrypted
+ // (or corrupted, but we'll treat that as encrypted for safety)
+ timber.d("Failed to open without password - database is ENCRYPTED or corrupted: ${e.message}")
+ true
+ }
+ } catch (e: Exception) {
+ timber.e(e, "Unable to determine encryption status")
+ null
+ }
+ }
+}
diff --git a/data/src/main/java/com/m3u/data/database/DatabaseModule.kt b/data/src/main/java/com/m3u/data/database/DatabaseModule.kt
index a78b9c515..60759c1ee 100644
--- a/data/src/main/java/com/m3u/data/database/DatabaseModule.kt
+++ b/data/src/main/java/com/m3u/data/database/DatabaseModule.kt
@@ -12,11 +12,14 @@ import com.m3u.data.database.dao.EpisodeDao
import com.m3u.data.database.dao.PlaylistDao
import com.m3u.data.database.dao.ProgrammeDao
import com.m3u.data.database.example.ColorSchemeExample
+import com.m3u.data.repository.usbkey.USBKeyRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.runBlocking
+import net.sqlcipher.database.SupportFactory
import javax.inject.Singleton
@Module
@@ -25,26 +28,62 @@ internal object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
- @ApplicationContext context: Context
- ): M3UDatabase = Room.databaseBuilder(
- context,
- M3UDatabase::class.java,
- "m3u-database"
- )
- .fallbackToDestructiveMigration()
- .addCallback(
- object : RoomDatabase.Callback() {
- override fun onCreate(db: SupportSQLiteDatabase) {
- super.onCreate(db)
- ColorSchemeExample.invoke(db)
- }
- }
+ @ApplicationContext context: Context,
+ usbKeyRepository: USBKeyRepository,
+ pinKeyManager: com.m3u.data.security.PINKeyManager
+ ): M3UDatabase {
+ val builder = Room.databaseBuilder(
+ context,
+ M3UDatabase::class.java,
+ "m3u-database"
)
- .addMigrations(DatabaseMigrations.MIGRATION_1_2)
- .addMigrations(DatabaseMigrations.MIGRATION_2_3)
- .addMigrations(DatabaseMigrations.MIGRATION_7_8)
- .addMigrations(DatabaseMigrations.MIGRATION_10_11)
- .build()
+
+ // Load SQLCipher library
+ System.loadLibrary("sqlcipher")
+
+ // Check PIN encryption first (takes priority over USB)
+ val pinEncryptionEnabled = runBlocking {
+ pinKeyManager.isPINEncryptionEnabled()
+ }
+
+ if (pinEncryptionEnabled) {
+ // Get encryption key from PIN key manager
+ val encryptionKey = runBlocking {
+ pinKeyManager.getEncryptionKeyIfUnlocked()
+ }
+
+ if (encryptionKey != null) {
+ builder.openHelperFactory(SupportFactory(encryptionKey))
+ } else {
+ throw SecurityException("Database is locked - PIN unlock required")
+ }
+ } else if (usbKeyRepository.isEncryptionEnabled()) {
+ // Fallback to USB encryption if PIN is not enabled
+ val encryptionKey = runBlocking {
+ usbKeyRepository.getEncryptionKey()
+ }
+
+ if (encryptionKey != null) {
+ builder.openHelperFactory(SupportFactory(encryptionKey))
+ }
+ }
+
+ return builder
+ .fallbackToDestructiveMigration()
+ .addCallback(
+ object : RoomDatabase.Callback() {
+ override fun onCreate(db: SupportSQLiteDatabase) {
+ super.onCreate(db)
+ ColorSchemeExample.invoke(db)
+ }
+ }
+ )
+ .addMigrations(DatabaseMigrations.MIGRATION_1_2)
+ .addMigrations(DatabaseMigrations.MIGRATION_2_3)
+ .addMigrations(DatabaseMigrations.MIGRATION_7_8)
+ .addMigrations(DatabaseMigrations.MIGRATION_10_11)
+ .build()
+ }
@Provides
@Singleton
diff --git a/data/src/main/java/com/m3u/data/database/example/ColorSchemeExample.kt b/data/src/main/java/com/m3u/data/database/example/ColorSchemeExample.kt
index 35b84cd75..0f95973ae 100644
--- a/data/src/main/java/com/m3u/data/database/example/ColorSchemeExample.kt
+++ b/data/src/main/java/com/m3u/data/database/example/ColorSchemeExample.kt
@@ -25,7 +25,7 @@ object ColorSchemeExample {
db.execSQL(
"""
- INSERT INTO 'color_pack' ('argb', 'dark', 'name')
+ INSERT OR IGNORE INTO 'color_pack' ('argb', 'dark', 'name')
VALUES
$values
""".trimIndent()
diff --git a/data/src/main/java/com/m3u/data/database/model/Playlist.kt b/data/src/main/java/com/m3u/data/database/model/Playlist.kt
index d42a63522..0d82422a7 100644
--- a/data/src/main/java/com/m3u/data/database/model/Playlist.kt
+++ b/data/src/main/java/com/m3u/data/database/model/Playlist.kt
@@ -137,6 +137,8 @@ sealed class DataSource(
object Dropbox : DataSource(R.string.feat_setting_data_source_dropbox, "dropbox")
+ object WebDrop : DataSource(R.string.feat_setting_data_source_webdrop, "webdrop", true)
+
override fun toString(): String = value
companion object {
@@ -146,6 +148,7 @@ sealed class DataSource(
"xtream" -> Xtream
"emby" -> Emby
"dropbox" -> Dropbox
+ "webdrop" -> WebDrop
else -> throw UnsupportedOperationException()
}
diff --git a/data/src/main/java/com/m3u/data/logging/LogSanitizer.kt b/data/src/main/java/com/m3u/data/logging/LogSanitizer.kt
new file mode 100644
index 000000000..2780ec99a
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/logging/LogSanitizer.kt
@@ -0,0 +1,190 @@
+package com.m3u.data.logging
+
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Enhancement #10: Diagnostic Log Sanitization
+ * Sanitizes logs by redacting sensitive information before export
+ */
+@Singleton
+class LogSanitizer @Inject constructor() {
+
+ private val timber = Timber.tag("LogSanitizer")
+
+ private var urlsRedacted = 0
+ private var tokensRedacted = 0
+ private var ipsRedacted = 0
+ private var emailsRedacted = 0
+ private var pathsRedacted = 0
+ private var serialsRedacted = 0
+ private var macsRedacted = 0
+
+ companion object {
+ private const val REDACTED_URL_PARAMS = "[REDACTED_PARAMS]"
+ private const val REDACTED_TOKEN = "[REDACTED_TOKEN]"
+ private const val REDACTED_IP = "[REDACTED_IP]"
+ private const val REDACTED_EMAIL = "[REDACTED_EMAIL]"
+ private const val REDACTED_PATH = "[REDACTED_PATH]"
+ private const val REDACTED_SERIAL = "[REDACTED_SERIAL]"
+ private const val REDACTED_MAC = "[REDACTED_MAC]"
+ }
+
+ /**
+ * Main sanitization method - redacts sensitive information from log content
+ * @param logContent Raw log content with potential sensitive data
+ * @return Sanitized log content safe for export
+ */
+ fun sanitize(logContent: String): String {
+ timber.d("Starting log sanitization...")
+ resetCounters()
+
+ var sanitized = logContent
+
+ // Order matters - more specific patterns first
+ sanitized = sanitizeBearerTokens(sanitized)
+ sanitized = sanitizeAuthTokens(sanitized)
+ sanitized = sanitizeUrls(sanitized)
+ sanitized = sanitizeEmails(sanitized)
+ sanitized = sanitizeMacAddresses(sanitized)
+ sanitized = sanitizeIpAddresses(sanitized)
+ sanitized = sanitizeDeviceSerials(sanitized)
+ sanitized = sanitizeFilePaths(sanitized)
+
+ timber.d("Log sanitization complete: ${getSanitizationSummary()}")
+ return sanitized
+ }
+
+ /**
+ * Sanitize URLs by redacting query parameters
+ * @param url URL string that may contain sensitive parameters
+ * @return URL with parameters redacted
+ */
+ fun sanitizeUrl(url: String): String {
+ return if (url.contains("?")) {
+ val baseUrl = url.substringBefore("?")
+ "$baseUrl?$REDACTED_URL_PARAMS"
+ } else {
+ url
+ }
+ }
+
+ /**
+ * Sanitize authentication tokens
+ * @param token Token string to redact
+ * @return Redacted token
+ */
+ fun sanitizeToken(token: String): String {
+ return if (token.length > 8) {
+ "${token.take(4)}...${REDACTED_TOKEN}"
+ } else {
+ REDACTED_TOKEN
+ }
+ }
+
+ /**
+ * Get summary of what was sanitized
+ * @return Human-readable summary string
+ */
+ fun getSanitizationSummary(): String {
+ return buildString {
+ append("Sanitized: ")
+ val items = mutableListOf()
+ if (urlsRedacted > 0) items.add("$urlsRedacted URLs")
+ if (tokensRedacted > 0) items.add("$tokensRedacted tokens")
+ if (ipsRedacted > 0) items.add("$ipsRedacted IPs")
+ if (emailsRedacted > 0) items.add("$emailsRedacted emails")
+ if (pathsRedacted > 0) items.add("$pathsRedacted paths")
+ if (serialsRedacted > 0) items.add("$serialsRedacted serials")
+ if (macsRedacted > 0) items.add("$macsRedacted MACs")
+
+ if (items.isEmpty()) {
+ append("nothing (no sensitive data found)")
+ } else {
+ append(items.joinToString(", "))
+ }
+ }
+ }
+
+ private fun resetCounters() {
+ urlsRedacted = 0
+ tokensRedacted = 0
+ ipsRedacted = 0
+ emailsRedacted = 0
+ pathsRedacted = 0
+ serialsRedacted = 0
+ macsRedacted = 0
+ }
+
+ private fun sanitizeUrls(content: String): String {
+ return SanitizationPatterns.URL_WITH_PARAMS.replace(content) { matchResult ->
+ urlsRedacted++
+ sanitizeUrl(matchResult.value)
+ }
+ }
+
+ private fun sanitizeAuthTokens(content: String): String {
+ return SanitizationPatterns.AUTH_TOKEN.replace(content) { matchResult ->
+ tokensRedacted++
+ "${matchResult.groupValues[1]}=$REDACTED_TOKEN"
+ }
+ }
+
+ private fun sanitizeBearerTokens(content: String): String {
+ return SanitizationPatterns.BEARER_TOKEN.replace(content) {
+ tokensRedacted++
+ "Bearer $REDACTED_TOKEN"
+ }
+ }
+
+ private fun sanitizeIpAddresses(content: String): String {
+ return SanitizationPatterns.IP_ADDRESS.replace(content) {
+ // Preserve localhost and common private IPs for debugging
+ val ip = it.value
+ if (ip.startsWith("127.") || ip.startsWith("192.168.") ||
+ ip.startsWith("10.") || ip.startsWith("172.16.")) {
+ ip // Keep private IPs
+ } else {
+ ipsRedacted++
+ REDACTED_IP
+ }
+ }
+ }
+
+ private fun sanitizeEmails(content: String): String {
+ return SanitizationPatterns.EMAIL_ADDRESS.replace(content) {
+ emailsRedacted++
+ REDACTED_EMAIL
+ }
+ }
+
+ private fun sanitizeFilePaths(content: String): String {
+ return SanitizationPatterns.FILE_PATH.replace(content) { matchResult ->
+ val path = matchResult.value
+ // Keep app-specific paths but redact user paths
+ if (path.contains("com.m3u") || path.contains("/system/") ||
+ path.contains("/storage/")) {
+ // Keep for debugging - these don't expose user data
+ path
+ } else {
+ pathsRedacted++
+ REDACTED_PATH
+ }
+ }
+ }
+
+ private fun sanitizeDeviceSerials(content: String): String {
+ return SanitizationPatterns.DEVICE_SERIAL.replace(content) { matchResult ->
+ serialsRedacted++
+ "${matchResult.groupValues[1]}: $REDACTED_SERIAL"
+ }
+ }
+
+ private fun sanitizeMacAddresses(content: String): String {
+ return SanitizationPatterns.MAC_ADDRESS.replace(content) {
+ macsRedacted++
+ REDACTED_MAC
+ }
+ }
+}
diff --git a/data/src/main/java/com/m3u/data/logging/SanitizationPatterns.kt b/data/src/main/java/com/m3u/data/logging/SanitizationPatterns.kt
new file mode 100644
index 000000000..2dcaf468d
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/logging/SanitizationPatterns.kt
@@ -0,0 +1,32 @@
+package com.m3u.data.logging
+
+/**
+ * Enhancement #10: Diagnostic Log Sanitization
+ * Regex patterns for identifying and redacting sensitive information
+ * Patterns are compiled once and reused for performance
+ */
+object SanitizationPatterns {
+ /** Matches URLs with query parameters */
+ val URL_WITH_PARAMS by lazy { Regex("(https?://[^\\s]+\\?[^\\s]*)") }
+
+ /** Matches authentication tokens, keys, passwords in query strings */
+ val AUTH_TOKEN by lazy { Regex("(token|key|password|auth|secret|api[-_]?key)=([^&\\s]+)", RegexOption.IGNORE_CASE) }
+
+ /** Matches IPv4 addresses */
+ val IP_ADDRESS by lazy { Regex("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b") }
+
+ /** Matches email addresses */
+ val EMAIL_ADDRESS by lazy { Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}") }
+
+ /** Matches file paths (Unix and Windows style) */
+ val FILE_PATH by lazy { Regex("(?:/[^\\s]+|[A-Z]:\\\\[^\\s]+)") }
+
+ /** Matches device serial numbers and IDs */
+ val DEVICE_SERIAL by lazy { Regex("(serial|device[-_]?id)[:\\s=]+([A-Z0-9-]+)", RegexOption.IGNORE_CASE) }
+
+ /** Matches MAC addresses */
+ val MAC_ADDRESS by lazy { Regex("([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})") }
+
+ /** Matches bearer tokens in headers */
+ val BEARER_TOKEN by lazy { Regex("Bearer\\s+[A-Za-z0-9._~+/-]+=*", RegexOption.IGNORE_CASE) }
+}
diff --git a/data/src/main/java/com/m3u/data/parser/m3u/M3UParserImpl.kt b/data/src/main/java/com/m3u/data/parser/m3u/M3UParserImpl.kt
index 5f77e1f65..7f7195ed3 100644
--- a/data/src/main/java/com/m3u/data/parser/m3u/M3UParserImpl.kt
+++ b/data/src/main/java/com/m3u/data/parser/m3u/M3UParserImpl.kt
@@ -29,17 +29,38 @@ internal class M3UParserImpl @Inject constructor() : M3UParser {
}
override fun parse(input: InputStream): Flow = flow {
- val lines = input
- .bufferedReader()
- .lineSequence()
- .filter { it.isNotEmpty() }
- .map { it.trimEnd() }
- .dropWhile { it.startsWith(M3U_HEADER_MARK) }
- .iterator()
-
- var currentLine: String
- var infoMatch: MatchResult? = null
- val kodiMatches = mutableListOf()
+ try {
+ timber.d("=== M3U PARSER START ===")
+ timber.d("InputStream available (buffered): ${input.available()} bytes")
+
+ val bufferedReader = input.bufferedReader()
+ val allLines = mutableListOf()
+
+ // Read all lines and log them
+ bufferedReader.use { reader ->
+ reader.forEachLine { line ->
+ allLines.add(line)
+ }
+ }
+
+ timber.d("Total lines read from stream: ${allLines.size}")
+ if (allLines.isNotEmpty()) {
+ timber.d("First line: ${allLines.first().take(100)}")
+ timber.d("Last line: ${allLines.last().take(100)}")
+ }
+
+ val lines = allLines
+ .asSequence()
+ .filter { it.isNotEmpty() }
+ .map { it.trimEnd() }
+ .dropWhile { it.startsWith(M3U_HEADER_MARK) }
+ .iterator()
+
+ timber.d("Lines iterator created, starting parse loop")
+ var entryCount = 0
+ var currentLine: String
+ var infoMatch: MatchResult? = null
+ val kodiMatches = mutableListOf()
while (lines.hasNext()) {
currentLine = lines.next()
@@ -93,8 +114,16 @@ internal class M3UParserImpl @Inject constructor() : M3UParser {
infoMatch = null
kodiMatches.clear()
+ entryCount++
+ timber.d("Emitting entry #$entryCount: ${entry.title}")
emit(entry)
}
+
+ timber.d("=== M3U PARSER COMPLETE: $entryCount entries found ===")
+ } catch (e: Exception) {
+ timber.e(e, "M3U Parser error!")
+ throw e
+ }
}
.flowOn(Dispatchers.Default)
}
diff --git a/data/src/main/java/com/m3u/data/repository/RepositoryModule.kt b/data/src/main/java/com/m3u/data/repository/RepositoryModule.kt
index 2a33d82fc..4a1b9f3ff 100644
--- a/data/src/main/java/com/m3u/data/repository/RepositoryModule.kt
+++ b/data/src/main/java/com/m3u/data/repository/RepositoryModule.kt
@@ -10,6 +10,8 @@ import com.m3u.data.repository.playlist.PlaylistRepository
import com.m3u.data.repository.playlist.PlaylistRepositoryImpl
import com.m3u.data.repository.programme.ProgrammeRepository
import com.m3u.data.repository.programme.ProgrammeRepositoryImpl
+import com.m3u.data.repository.webserver.WebServerRepository
+import com.m3u.data.repository.webserver.WebServerRepositoryImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@@ -42,4 +44,10 @@ internal interface RepositoryModule {
fun bindMediaRepository(
repository: MediaRepositoryImpl
): MediaRepository
+
+ @Binds
+ @Singleton
+ fun bindWebServerRepository(
+ repository: WebServerRepositoryImpl
+ ): WebServerRepository
}
diff --git a/data/src/main/java/com/m3u/data/repository/encryption/PINEncryptionModule.kt b/data/src/main/java/com/m3u/data/repository/encryption/PINEncryptionModule.kt
new file mode 100644
index 000000000..045204570
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/encryption/PINEncryptionModule.kt
@@ -0,0 +1,17 @@
+package com.m3u.data.repository.encryption
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal interface PINEncryptionModule {
+ @Binds
+ @Singleton
+ fun bindPINEncryptionRepository(
+ impl: PINEncryptionRepositoryImpl
+ ): PINEncryptionRepository
+}
diff --git a/data/src/main/java/com/m3u/data/repository/encryption/PINEncryptionRepository.kt b/data/src/main/java/com/m3u/data/repository/encryption/PINEncryptionRepository.kt
new file mode 100644
index 000000000..1f2adfcc7
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/encryption/PINEncryptionRepository.kt
@@ -0,0 +1,41 @@
+package com.m3u.data.repository.encryption
+
+import com.m3u.data.repository.usbkey.EncryptionProgress
+
+interface PINEncryptionRepository {
+ /**
+ * Initialize encryption with a 6-digit PIN
+ * @param pin Must be exactly 6 digits
+ * @return Result containing Unit on success, Exception on failure
+ */
+ suspend fun initializeEncryption(pin: String): Result
+
+ /**
+ * Unlock the encrypted database with PIN
+ * @param pin The 6-digit PIN to unlock with
+ * @return Result containing Unit on success, Exception on failure (wrong PIN or error)
+ */
+ suspend fun unlockWithPIN(pin: String): Result
+
+ /**
+ * Disable encryption and decrypt the database
+ * @param pin Current PIN for verification
+ * @return Result containing Unit on success, Exception on failure
+ */
+ suspend fun disableEncryption(pin: String): Result
+
+ /**
+ * Check if PIN encryption is currently enabled
+ */
+ suspend fun isEncryptionEnabled(): Boolean
+
+ /**
+ * Get the current encryption progress (if any operation is in progress)
+ */
+ suspend fun getEncryptionProgress(): EncryptionProgress?
+
+ /**
+ * Validate PIN format (6 digits)
+ */
+ fun isValidPIN(pin: String): Boolean
+}
diff --git a/data/src/main/java/com/m3u/data/repository/encryption/PINEncryptionRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/encryption/PINEncryptionRepositoryImpl.kt
new file mode 100644
index 000000000..96fd0813c
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/encryption/PINEncryptionRepositoryImpl.kt
@@ -0,0 +1,249 @@
+package com.m3u.data.repository.encryption
+
+import android.content.Context
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.core.architecture.preferences.get
+import com.m3u.core.architecture.preferences.set
+import com.m3u.data.database.DatabaseMigrationHelper
+import com.m3u.data.repository.usbkey.EncryptionProgress
+import com.m3u.data.repository.usbkey.EncryptionStep
+import com.m3u.data.security.PINKeyManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+internal class PINEncryptionRepositoryImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val settings: Settings,
+ private val pinKeyManager: PINKeyManager,
+ private val migrationHelper: DatabaseMigrationHelper
+) : PINEncryptionRepository {
+
+ private val timber = Timber.tag("PINEncryptionRepository")
+
+ private val _progress = MutableStateFlow(null)
+ override suspend fun getEncryptionProgress(): EncryptionProgress? = _progress.value
+
+ override suspend fun initializeEncryption(pin: String): Result = withContext(Dispatchers.IO) {
+ try {
+ timber.d("=== initializeEncryption() STARTED ===")
+
+ // Validate PIN format
+ if (!pinKeyManager.isValidPIN(pin)) {
+ return@withContext Result.failure(
+ IllegalArgumentException("PIN must be exactly 6 digits")
+ )
+ }
+
+ updateProgress(EncryptionStep.PREPARING, 5, "Preparing encryption")
+
+ // Initialize PIN and get derived encryption key
+ updateProgress(EncryptionStep.VERIFYING, 10, "Generating encryption key")
+ val keyResult = pinKeyManager.initializeWithPIN(pin)
+ if (keyResult.isFailure) {
+ clearProgress()
+ return@withContext Result.failure(
+ keyResult.exceptionOrNull() ?: Exception("Failed to generate encryption key")
+ )
+ }
+
+ val encryptionKey = keyResult.getOrNull()!!
+ timber.d("Encryption key generated from PIN")
+
+ // Check if database exists and needs encryption
+ updateProgress(EncryptionStep.VERIFYING, 15, "Checking database status")
+ val isDatabaseEncrypted = migrationHelper.isDatabaseEncrypted()
+ timber.d("Database encryption status: $isDatabaseEncrypted")
+
+ if (isDatabaseEncrypted == false) {
+ // Database exists and is unencrypted - encrypt it
+ timber.d("Database exists and is unencrypted - starting encryption migration")
+ updateProgress(EncryptionStep.MIGRATING_DATA, 20, "Encrypting database")
+
+ val migrationResult = migrationHelper.migrateToEncrypted(encryptionKey) { progress ->
+ // Map migration progress (0-100) to our overall progress (20-85)
+ val overallProgress = 20 + (progress * 0.65).toInt()
+ updateProgress(
+ EncryptionStep.MIGRATING_DATA,
+ overallProgress,
+ "Encrypting database: $progress%"
+ )
+ }
+
+ if (migrationResult.isFailure) {
+ clearProgress()
+ // Clean up PIN data if migration fails
+ pinKeyManager.clearPINEncryption()
+ return@withContext Result.failure(
+ Exception("Database encryption failed: ${migrationResult.exceptionOrNull()?.message}")
+ )
+ }
+
+ timber.d("✓ Database encrypted successfully")
+ updateProgress(EncryptionStep.MIGRATING_DATA, 85, "Database encrypted")
+ } else if (isDatabaseEncrypted == true) {
+ // Database is already encrypted
+ timber.d("Database is already encrypted")
+ updateProgress(EncryptionStep.MIGRATING_DATA, 85, "Database already encrypted")
+ } else {
+ // No database exists yet - will be created encrypted on first use
+ timber.d("No existing database found - will be created encrypted")
+ updateProgress(EncryptionStep.MIGRATING_DATA, 85, "Ready for encrypted database")
+ }
+
+ // Mark encryption as enabled
+ updateProgress(EncryptionStep.FINALIZING, 90, "Finalizing encryption setup")
+ settings[PreferencesKeys.USB_ENCRYPTION_ENABLED] = true // Reusing this key for now
+ settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS] = false
+
+ // Clean up backup files
+ migrationHelper.cleanupBackups()
+
+ updateProgress(EncryptionStep.COMPLETE, 100, "Encryption enabled successfully")
+ timber.d("=== Encryption initialization COMPLETE ===")
+
+ // Clear progress after a delay
+ kotlinx.coroutines.delay(1000)
+ clearProgress()
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to initialize encryption")
+ clearProgress()
+ // Clean up on failure
+ pinKeyManager.clearPINEncryption()
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun unlockWithPIN(pin: String): Result = withContext(Dispatchers.IO) {
+ try {
+ timber.d("=== unlockWithPIN() STARTED ===")
+
+ // Validate PIN format
+ if (!pinKeyManager.isValidPIN(pin)) {
+ return@withContext Result.failure(
+ IllegalArgumentException("Invalid PIN format")
+ )
+ }
+
+ // Attempt to unlock with PIN
+ val unlockResult = pinKeyManager.unlockWithPIN(pin)
+ if (unlockResult.isFailure) {
+ timber.w("PIN unlock failed: ${unlockResult.exceptionOrNull()?.message}")
+ return@withContext Result.failure(
+ unlockResult.exceptionOrNull() ?: SecurityException("Incorrect PIN")
+ )
+ }
+
+ timber.d("✓ PIN verification successful - database unlocked")
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to unlock with PIN")
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun disableEncryption(pin: String): Result = withContext(Dispatchers.IO) {
+ try {
+ timber.d("=== disableEncryption() STARTED ===")
+
+ // Verify PIN before disabling
+ val verifyResult = pinKeyManager.unlockWithPIN(pin)
+ if (verifyResult.isFailure) {
+ return@withContext Result.failure(
+ SecurityException("Incorrect PIN - cannot disable encryption")
+ )
+ }
+
+ updateProgress(EncryptionStep.PREPARING, 5, "Verifying PIN")
+ val encryptionKey = verifyResult.getOrNull()!!
+
+ // Check if database is encrypted and needs decryption
+ updateProgress(EncryptionStep.VERIFYING, 10, "Checking database status")
+ val isDatabaseEncrypted = migrationHelper.isDatabaseEncrypted()
+ timber.d("Database encryption status: $isDatabaseEncrypted")
+
+ if (isDatabaseEncrypted == true) {
+ // Database is encrypted - decrypt it
+ updateProgress(EncryptionStep.MIGRATING_DATA, 20, "Decrypting database")
+ timber.d("Starting database decryption migration")
+
+ val migrationResult = migrationHelper.migrateToUnencrypted(encryptionKey) { progress ->
+ // Map migration progress (0-100) to our overall progress (20-85)
+ val overallProgress = 20 + (progress * 0.65).toInt()
+ updateProgress(
+ EncryptionStep.MIGRATING_DATA,
+ overallProgress,
+ "Decrypting database: $progress%"
+ )
+ }
+
+ if (migrationResult.isFailure) {
+ clearProgress()
+ return@withContext Result.failure(
+ Exception("Database decryption failed: ${migrationResult.exceptionOrNull()?.message}")
+ )
+ }
+
+ timber.d("✓ Database decrypted successfully")
+ } else {
+ timber.d("Database is not encrypted or does not exist")
+ }
+
+ // Clear PIN encryption data
+ updateProgress(EncryptionStep.FINALIZING, 90, "Removing encryption keys")
+ pinKeyManager.clearPINEncryption()
+
+ // Clear encryption settings
+ settings[PreferencesKeys.USB_ENCRYPTION_ENABLED] = false
+ settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS] = false
+
+ // Clean up backup files
+ migrationHelper.cleanupBackups()
+
+ updateProgress(EncryptionStep.COMPLETE, 100, "Encryption disabled successfully")
+ timber.d("=== Encryption disabled COMPLETE ===")
+
+ // Clear progress after a delay
+ kotlinx.coroutines.delay(1000)
+ clearProgress()
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to disable encryption")
+ clearProgress()
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun isEncryptionEnabled(): Boolean {
+ return try {
+ pinKeyManager.isPINEncryptionEnabled()
+ } catch (e: Exception) {
+ timber.e(e, "Failed to check encryption status")
+ false
+ }
+ }
+
+ override fun isValidPIN(pin: String): Boolean {
+ return pinKeyManager.isValidPIN(pin)
+ }
+
+ private fun updateProgress(step: EncryptionStep, percentage: Int, operation: String) {
+ timber.d("Progress: $step - $percentage% - $operation")
+ _progress.value = EncryptionProgress(step, percentage, null, operation)
+ }
+
+ private fun clearProgress() {
+ _progress.value = null
+ }
+}
diff --git a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt
index e844024e8..ce2ce459e 100644
--- a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt
+++ b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt
@@ -127,11 +127,34 @@ internal class PlaylistRepositoryImpl @Inject constructor(
}
channelFlow {
- when {
- url.isSupportedNetworkUrl() -> openNetworkInput(internalUrl)
- url.isSupportedAndroidUrl() -> openAndroidInput(internalUrl)
- else -> null
- }?.use { input ->
+ timber.d("=== OPENING INPUT STREAM ===")
+ timber.d("url: $url")
+ timber.d("internalUrl: $internalUrl")
+ timber.d("isSupportedNetworkUrl: ${url.isSupportedNetworkUrl()}")
+ timber.d("isSupportedAndroidUrl: ${url.isSupportedAndroidUrl()}")
+
+ val inputStream = when {
+ url.isSupportedNetworkUrl() -> {
+ timber.d("Using openNetworkInput")
+ openNetworkInput(internalUrl)
+ }
+ url.isSupportedAndroidUrl() -> {
+ timber.d("Using openAndroidInput")
+ openAndroidInput(internalUrl)
+ }
+ else -> {
+ timber.w("No supported URL type matched!")
+ null
+ }
+ }
+
+ if (inputStream == null) {
+ timber.e("Failed to open input stream!")
+ } else {
+ timber.d("Input stream opened, available: ${inputStream.available()} bytes")
+ }
+
+ inputStream?.use { input ->
m3uParser
.parse(input.buffered())
.filterNot {
diff --git a/data/src/main/java/com/m3u/data/repository/usbkey/EncryptionProgress.kt b/data/src/main/java/com/m3u/data/repository/usbkey/EncryptionProgress.kt
new file mode 100644
index 000000000..51f6afe8e
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/usbkey/EncryptionProgress.kt
@@ -0,0 +1,27 @@
+package com.m3u.data.repository.usbkey
+
+import androidx.compose.runtime.Immutable
+
+/**
+ * Represents the progress of an encryption or decryption operation
+ */
+@Immutable
+data class EncryptionProgress(
+ val step: EncryptionStep,
+ val percentage: Int,
+ val estimatedTimeRemaining: Long? = null,
+ val currentOperation: String
+)
+
+/**
+ * The different steps involved in encryption/decryption
+ */
+enum class EncryptionStep {
+ PREPARING,
+ GENERATING_KEY,
+ CREATING_DATABASE,
+ MIGRATING_DATA,
+ VERIFYING,
+ FINALIZING,
+ COMPLETE
+}
diff --git a/data/src/main/java/com/m3u/data/repository/usbkey/HealthStatus.kt b/data/src/main/java/com/m3u/data/repository/usbkey/HealthStatus.kt
new file mode 100644
index 000000000..f81474dc4
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/usbkey/HealthStatus.kt
@@ -0,0 +1,18 @@
+package com.m3u.data.repository.usbkey
+
+/**
+ * Health status indicator for encryption system
+ */
+enum class HealthStatus {
+ /** Encryption enabled, USB connected, recently verified */
+ HEALTHY,
+
+ /** Encryption enabled, USB disconnected (app locked) */
+ WARNING,
+
+ /** Key mismatch, verification failed */
+ CRITICAL,
+
+ /** Encryption not enabled */
+ DISABLED
+}
diff --git a/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyModule.kt b/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyModule.kt
new file mode 100644
index 000000000..c8b41c24f
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyModule.kt
@@ -0,0 +1,17 @@
+package com.m3u.data.repository.usbkey
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal interface USBKeyModule {
+ @Binds
+ @Singleton
+ fun bindUSBKeyRepository(
+ repository: USBKeyRepositoryImpl
+ ): USBKeyRepository
+}
diff --git a/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyRepository.kt b/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyRepository.kt
new file mode 100644
index 000000000..32ff4c9d7
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyRepository.kt
@@ -0,0 +1,44 @@
+package com.m3u.data.repository.usbkey
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface USBKeyRepository {
+ val state: StateFlow
+
+ /**
+ * Initialize USB key encryption for the database
+ * Creates encryption key file on USB stick
+ */
+ suspend fun initializeEncryption(): Result
+
+ /**
+ * Disable encryption and decrypt the database
+ */
+ suspend fun disableEncryption(): Result
+
+ /**
+ * Check if USB stick with valid key is connected
+ */
+ suspend fun validateUSBKey(): Result
+
+ /**
+ * Get encryption key from USB stick if available
+ */
+ suspend fun getEncryptionKey(): ByteArray?
+
+ /**
+ * Check if encryption is currently enabled
+ */
+ fun isEncryptionEnabled(): Boolean
+
+ /**
+ * Request USB permission from user
+ */
+ suspend fun requestUSBPermission(): Result
+
+ /**
+ * Perform pending encryption that was deferred due to database being open
+ * This is called on app startup when ENCRYPTION_PENDING flag is detected
+ */
+ suspend fun performPendingEncryption(): Result
+}
diff --git a/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyRepositoryImpl.kt
new file mode 100644
index 000000000..efc35f18d
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyRepositoryImpl.kt
@@ -0,0 +1,882 @@
+package com.m3u.data.repository.usbkey
+
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbManager
+import android.os.Build
+import androidx.core.content.ContextCompat
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.core.architecture.preferences.get
+import com.m3u.core.architecture.preferences.set
+import com.m3u.data.database.DatabaseMigrationHelper
+import com.m3u.data.logging.LogSanitizer
+import com.m3u.data.security.EncryptionLockManager
+import com.m3u.data.security.EncryptionMetricsCalculator
+import com.m3u.data.security.KeyVerificationManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.io.File
+import java.security.SecureRandom
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val ACTION_USB_PERMISSION = "com.m3u.USB_PERMISSION"
+private const val USB_KEY_FILE_NAME = ".m3u_enc_key"
+
+@Singleton
+internal class USBKeyRepositoryImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val settings: Settings,
+ private val migrationHelper: DatabaseMigrationHelper,
+ private val keyVerificationManager: KeyVerificationManager,
+ private val lockManager: EncryptionLockManager,
+ private val logSanitizer: LogSanitizer,
+ private val metricsCalculator: EncryptionMetricsCalculator
+) : USBKeyRepository {
+
+ private val timber = Timber.tag("USBKeyRepository")
+ private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
+ private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ private val _state = MutableStateFlow(USBKeyState())
+ override val state: StateFlow = _state.asStateFlow()
+
+ private val _progress = MutableStateFlow(null)
+
+ private var usbReceiver: BroadcastReceiver? = null
+
+ init {
+ registerUSBReceiver()
+ checkUSBConnection()
+
+ // Update metrics periodically in background
+ repositoryScope.launch {
+ updateStateMetrics()
+ }
+ }
+
+ /**
+ * Update state with current metrics
+ */
+ private suspend fun updateStateMetrics() {
+ try {
+ val autoLockEnabled = lockManager.isAutoLockEnabled()
+ val databaseSize = metricsCalculator.calculateDatabaseSize()
+ val encryptionAlgorithm = metricsCalculator.getEncryptionAlgorithm()
+ val healthStatus = metricsCalculator.calculateHealthStatus(_state.value)
+
+ _state.value = _state.value.copy(
+ autoLockEnabled = autoLockEnabled,
+ databaseSize = databaseSize,
+ encryptionAlgorithm = encryptionAlgorithm,
+ healthStatus = healthStatus
+ )
+ } catch (e: Exception) {
+ timber.e(e, "Failed to update state metrics")
+ }
+ }
+
+ private fun registerUSBReceiver() {
+ usbReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
+ timber.d("USB device attached")
+ checkUSBConnection()
+ }
+ UsbManager.ACTION_USB_DEVICE_DETACHED -> {
+ timber.d("USB device detached")
+
+ // Handle USB detachment in background to avoid blocking broadcast receiver
+ repositoryScope.launch {
+ val isEncryptionEnabled = _state.value.isEncryptionEnabled
+ val autoLockEnabled = lockManager.isAutoLockEnabled()
+
+ if (isEncryptionEnabled && autoLockEnabled) {
+ timber.d("Auto-lock enabled - locking application")
+ lockManager.lockApplication("USB_REMOVED")
+ _state.value = _state.value.copy(
+ isConnected = false,
+ deviceName = null,
+ isDatabaseUnlocked = false,
+ isLocked = true,
+ lockReason = "USB device removed"
+ )
+ } else {
+ _state.value = _state.value.copy(
+ isConnected = false,
+ deviceName = null,
+ isDatabaseUnlocked = false
+ )
+ }
+ }
+ }
+ ACTION_USB_PERMISSION -> {
+ synchronized(this) {
+ val device: UsbDevice? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
+ }
+ if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
+ device?.let {
+ timber.d("Permission granted for device ${it.deviceName}")
+ checkUSBConnection()
+ }
+ } else {
+ timber.w("Permission denied for device ${device?.deviceName}")
+ _state.value = _state.value.copy(error = "USB permission denied")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val filter = IntentFilter().apply {
+ addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
+ addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
+ addAction(ACTION_USB_PERMISSION)
+ }
+
+ ContextCompat.registerReceiver(
+ context,
+ usbReceiver,
+ filter,
+ ContextCompat.RECEIVER_NOT_EXPORTED
+ )
+ }
+
+ private fun checkUSBConnection() {
+ timber.d("=== Checking USB Connection ===")
+
+ // Method 1: Try StorageManager to check for removable storage
+ var isConnected = false
+ var deviceName: String? = null
+
+ try {
+ val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as? android.os.storage.StorageManager
+ if (storageManager != null) {
+ val getVolumeListMethod = storageManager.javaClass.getMethod("getVolumeList")
+ val volumes = getVolumeListMethod.invoke(storageManager) as? Array<*>
+
+ timber.d("StorageManager found ${volumes?.size ?: 0} volumes")
+
+ volumes?.forEach { volume ->
+ try {
+ val getPathMethod = volume!!.javaClass.getMethod("getPath")
+ val isRemovableMethod = volume.javaClass.getMethod("isRemovable")
+ val getStateMethod = volume.javaClass.getMethod("getState")
+
+ val path = getPathMethod.invoke(volume) as? String
+ val isRemovable = isRemovableMethod.invoke(volume) as? Boolean ?: false
+ val state = getStateMethod.invoke(volume) as? String
+
+ if (path != null && isRemovable && state == "mounted") {
+ timber.d("✓ Found mounted removable storage: $path")
+ isConnected = true
+ deviceName = path
+ }
+ } catch (e: Exception) {
+ timber.w(e, "Failed to inspect volume")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ timber.w(e, "StorageManager check failed")
+ }
+
+ // Method 2: Fallback to UsbManager check
+ if (!isConnected) {
+ timber.d("Falling back to UsbManager check...")
+ val deviceList = usbManager.deviceList
+ val massStorageDevice = deviceList.values.firstOrNull { device ->
+ // Check for mass storage device (class 8)
+ device.interfaceCount > 0 && device.getInterface(0).interfaceClass == 8
+ }
+
+ if (massStorageDevice != null) {
+ timber.d("✓ Found USB mass storage device via UsbManager: ${massStorageDevice.deviceName}")
+ isConnected = true
+ deviceName = massStorageDevice.deviceName
+ }
+ }
+
+ // Method 3: Check if getUSBMountPoint() finds anything
+ if (!isConnected) {
+ timber.d("Falling back to mount point check...")
+ val mountPoint = getUSBMountPoint()
+ if (mountPoint != null) {
+ timber.d("✓ Found USB mount point: ${mountPoint.absolutePath}")
+ isConnected = true
+ deviceName = mountPoint.absolutePath
+ }
+ }
+
+ timber.d("Final USB connection state: connected=$isConnected, device=$deviceName")
+
+ _state.value = _state.value.copy(
+ isConnected = isConnected,
+ deviceName = deviceName,
+ error = if (!isConnected) "No USB device detected" else null
+ )
+ }
+
+ override suspend fun requestUSBPermission(): Result = withContext(Dispatchers.IO) {
+ try {
+ val deviceList = usbManager.deviceList
+ val device = deviceList.values.firstOrNull { device ->
+ device.interfaceCount > 0 && device.getInterface(0).interfaceClass == 8
+ }
+
+ if (device == null) {
+ return@withContext Result.failure(Exception("No USB device found"))
+ }
+
+ if (usbManager.hasPermission(device)) {
+ return@withContext Result.success(Unit)
+ }
+
+ val permissionIntent = PendingIntent.getBroadcast(
+ context,
+ 0,
+ Intent(ACTION_USB_PERMISSION),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.FLAG_MUTABLE
+ } else {
+ 0
+ }
+ )
+ usbManager.requestPermission(device, permissionIntent)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to request USB permission")
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun initializeEncryption(): Result = withContext(Dispatchers.IO) {
+ try {
+ timber.d("=== initializeEncryption() STARTED ===")
+
+ // Export diagnostic logs to USB first
+ exportLogsToUSB()
+
+ // Check if USB is connected
+ if (!_state.value.isConnected) {
+ timber.e("USB device not connected")
+ return@withContext Result.failure(Exception("No USB device connected"))
+ }
+ timber.d("USB device is connected: ${_state.value.deviceName}")
+
+ // Generate 256-bit encryption key
+ val key = ByteArray(32)
+ SecureRandom().nextBytes(key)
+ timber.d("Generated 256-bit encryption key")
+
+ // Generate and store key fingerprint
+ val fingerprint = keyVerificationManager.generateFingerprint(key)
+ timber.d("Generated key fingerprint: ${fingerprint.takeLast(8)}")
+
+ // Get USB mount point
+ val usbMountPoint = getUSBMountPoint()
+ if (usbMountPoint == null) {
+ return@withContext Result.failure(Exception("Could not access USB storage"))
+ }
+
+ // Write key file to USB
+ val keyFile = File(usbMountPoint, USB_KEY_FILE_NAME)
+ keyFile.writeBytes(key)
+ timber.d("Encryption key written to USB: ${keyFile.absolutePath}")
+
+ // Mark file as hidden on compatible systems
+ if (!keyFile.setReadable(true, true)) {
+ timber.w("Could not set file permissions")
+ }
+
+ // Store key fingerprint
+ keyVerificationManager.storeFingerprint(fingerprint)
+ timber.d("Key fingerprint stored")
+
+ // Store USB device serial/identifier for verification
+ val device = getCurrentUSBDevice()
+ if (device != null) {
+ settings[PreferencesKeys.USB_ENCRYPTION_DEVICE_ID] = device.serialNumber ?: device.deviceName
+ timber.d("Stored USB device ID")
+ }
+
+ // Set the "encryption pending" flag - this will be checked on next app startup
+ settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS] = true
+ settings[PreferencesKeys.USB_ENCRYPTION_LAST_OPERATION] = "ENCRYPTION_PENDING"
+ timber.d("Set ENCRYPTION_PENDING flag")
+
+ timber.d("=== Preparation complete. App will now restart to perform encryption with database closed ===")
+
+ // CRITICAL: Kill the app process so the database gets closed
+ // When Android restarts the app, the Application class will check the flag
+ // and perform the encryption with NO database connections open
+ withContext(Dispatchers.Main) {
+ timber.d("Killing app process in 500ms...")
+ kotlinx.coroutines.delay(500) // Give time for UI to show message
+ android.os.Process.killProcess(android.os.Process.myPid())
+ }
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to initialize USB encryption")
+ clearProgress()
+ settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS] = false
+ _state.value = _state.value.copy(error = e.message)
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun performPendingEncryption(): Result = withContext(Dispatchers.IO) {
+ try {
+ timber.d("=== performPendingEncryption() STARTED ===")
+ timber.d("Database is now CLOSED - ready to perform encryption!")
+
+ // Read the encryption key from USB
+ val key = getEncryptionKey()
+ if (key == null) {
+ timber.e("Encryption key not found on USB")
+ return@withContext Result.failure(Exception("Encryption key not found"))
+ }
+ timber.d("Encryption key loaded from USB")
+
+ // Check if database exists and needs migration
+ val isDatabaseEncrypted = migrationHelper.isDatabaseEncrypted()
+ timber.d("Database encryption status: $isDatabaseEncrypted")
+
+ if (isDatabaseEncrypted == false) {
+ timber.d("Database exists and is unencrypted - starting migration NOW")
+
+ // CRITICAL: Database is CLOSED - we can now safely encrypt it!
+ val migrationResult = migrationHelper.migrateToEncrypted(key) { progress ->
+ timber.d("Migration progress: $progress%")
+ }
+
+ if (migrationResult.isFailure) {
+ timber.e("Database migration failed: ${migrationResult.exceptionOrNull()?.message}")
+ return@withContext Result.failure(
+ Exception("Database migration failed: ${migrationResult.exceptionOrNull()?.message}")
+ )
+ }
+ timber.d("✓ Database encrypted successfully!")
+
+ // Cleanup backups after successful encryption
+ timber.d("Cleaning up backup files...")
+ migrationHelper.cleanupBackups()
+ timber.d("✓ Backups cleaned up")
+ } else if (isDatabaseEncrypted == true) {
+ timber.w("Database is already encrypted")
+ } else {
+ timber.d("No existing database found")
+ }
+
+ timber.d("=== performPendingEncryption() COMPLETED SUCCESSFULLY ===")
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to perform pending encryption")
+ Result.failure(e)
+ }
+ }
+
+ private fun updateProgress(step: EncryptionStep, percentage: Int, operation: String) {
+ timber.d("Progress: $step - $percentage% - $operation")
+ _progress.value = EncryptionProgress(step, percentage, null, operation)
+ _state.value = _state.value.copy(encryptionProgress = _progress.value)
+ }
+
+ private fun clearProgress() {
+ _progress.value = null
+ _state.value = _state.value.copy(encryptionProgress = null)
+ }
+
+ private fun restartApp() {
+ try {
+ timber.d("=== RESTARTING APP ===")
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ context.startActivity(intent)
+
+ // Kill the current process to force full restart
+ android.os.Process.killProcess(android.os.Process.myPid())
+ } catch (e: Exception) {
+ timber.e(e, "Failed to restart app")
+ }
+ }
+
+ override suspend fun disableEncryption(): Result = withContext(Dispatchers.IO) {
+ try {
+ // Mark decryption operation as in progress
+ settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS] = true
+ settings[PreferencesKeys.USB_ENCRYPTION_LAST_OPERATION] = "DECRYPTION"
+
+ updateProgress(EncryptionStep.PREPARING, 5, "Preparing decryption")
+
+ // Get current encryption key before deleting it
+ val key = getEncryptionKey()
+
+ // Check if database is encrypted and needs decryption
+ updateProgress(EncryptionStep.VERIFYING, 10, "Checking database status")
+ val isDatabaseEncrypted = migrationHelper.isDatabaseEncrypted()
+ timber.d("Database encryption status before disable: $isDatabaseEncrypted")
+
+ if (isDatabaseEncrypted == true && key != null) {
+ // Database is encrypted - decrypt it
+ updateProgress(EncryptionStep.MIGRATING_DATA, 20, "Decrypting database")
+ timber.d("Starting database decryption migration")
+
+ val migrationResult = migrationHelper.migrateToUnencrypted(key) { progress ->
+ // Map migration progress (0-100) to our overall progress (20-85)
+ val overallProgress = 20 + (progress * 0.65).toInt()
+ updateProgress(EncryptionStep.MIGRATING_DATA, overallProgress, "Decrypting database: $progress%")
+ }
+
+ if (migrationResult.isFailure) {
+ clearProgress()
+ settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS] = false
+ return@withContext Result.failure(
+ Exception("Database decryption failed: ${migrationResult.exceptionOrNull()?.message}")
+ )
+ }
+ timber.d("Database decrypted successfully")
+ } else if (isDatabaseEncrypted == false) {
+ // Database is already unencrypted
+ timber.d("Database is already unencrypted, skipping migration")
+ updateProgress(EncryptionStep.MIGRATING_DATA, 85, "Database already unencrypted")
+ } else {
+ // No database exists
+ timber.d("No existing database found")
+ updateProgress(EncryptionStep.MIGRATING_DATA, 85, "No database to decrypt")
+ }
+
+ // Delete key file from USB if available
+ updateProgress(EncryptionStep.FINALIZING, 90, "Removing encryption key")
+ val usbMountPoint = getUSBMountPoint()
+ if (usbMountPoint != null) {
+ val keyFile = File(usbMountPoint, USB_KEY_FILE_NAME)
+ if (keyFile.exists()) {
+ keyFile.delete()
+ timber.d("Deleted encryption key file from USB")
+ }
+ }
+
+ // Clear key fingerprint
+ keyVerificationManager.clearFingerprint()
+
+ // Clear encryption settings
+ settings[PreferencesKeys.USB_ENCRYPTION_ENABLED] = false
+ settings[PreferencesKeys.USB_ENCRYPTION_DEVICE_ID] = ""
+
+ updateProgress(EncryptionStep.FINALIZING, 95, "Cleaning up")
+
+ _state.value = _state.value.copy(
+ isEncryptionEnabled = false,
+ isDatabaseUnlocked = false,
+ keyVerified = false,
+ lastVerificationTime = null
+ )
+
+ // Clean up backup files after successful decryption
+ migrationHelper.cleanupBackups()
+
+ timber.d("USB encryption disabled successfully")
+
+ updateProgress(EncryptionStep.COMPLETE, 100, "Decryption complete")
+
+ // Restart app to reinitialize database without encryption
+ timber.d("Restarting app to apply decryption...")
+ restartApp()
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to disable USB encryption")
+ clearProgress()
+ settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS] = false
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun validateUSBKey(): Result = withContext(Dispatchers.IO) {
+ try {
+ if (!isEncryptionEnabled()) {
+ return@withContext Result.success(false)
+ }
+
+ if (!_state.value.isConnected) {
+ return@withContext Result.success(false)
+ }
+
+ // Verify it's the correct USB device
+ val device = getCurrentUSBDevice()
+ val storedDeviceId = settings[PreferencesKeys.USB_ENCRYPTION_DEVICE_ID] ?: ""
+ val currentDeviceId = device?.serialNumber ?: device?.deviceName ?: ""
+
+ if (storedDeviceId.isNotEmpty() && currentDeviceId != storedDeviceId) {
+ timber.w("USB device ID mismatch. Expected: $storedDeviceId, Got: $currentDeviceId")
+ return@withContext Result.success(false)
+ }
+
+ // Check if key file exists
+ val key = getEncryptionKey()
+ val isValid = key != null && key.size == 32
+
+ // Verify fingerprint if key is valid
+ if (isValid && key != null) {
+ validateKeyFingerprint(key)
+ }
+
+ _state.value = _state.value.copy(isDatabaseUnlocked = isValid)
+
+ Result.success(isValid)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to validate USB key")
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Validate that the current USB key matches the stored fingerprint
+ */
+ private suspend fun validateKeyFingerprint(key: ByteArray) {
+ try {
+ timber.d("Validating key fingerprint...")
+ val verified = keyVerificationManager.verifyKey(key)
+
+ if (verified) {
+ timber.d("Key fingerprint validation successful")
+ _state.value = _state.value.copy(
+ keyVerified = true,
+ lastVerificationTime = System.currentTimeMillis(),
+ verificationError = null
+ )
+ } else {
+ timber.w("Key fingerprint validation failed - mismatch detected")
+ _state.value = _state.value.copy(
+ keyVerified = false,
+ verificationError = "Key fingerprint mismatch"
+ )
+ }
+ } catch (e: Exception) {
+ timber.e(e, "Error during key fingerprint validation")
+ _state.value = _state.value.copy(
+ keyVerified = false,
+ verificationError = "Verification error: ${e.message}"
+ )
+ }
+ }
+
+ override suspend fun getEncryptionKey(): ByteArray? = withContext(Dispatchers.IO) {
+ try {
+ val usbMountPoint = getUSBMountPoint() ?: return@withContext null
+ val keyFile = File(usbMountPoint, USB_KEY_FILE_NAME)
+
+ if (!keyFile.exists()) {
+ timber.w("Key file not found on USB")
+ return@withContext null
+ }
+
+ val key = keyFile.readBytes()
+ if (key.size != 32) {
+ timber.e("Invalid key size: ${key.size} bytes")
+ return@withContext null
+ }
+
+ timber.d("Successfully read encryption key from USB")
+ key
+ } catch (e: Exception) {
+ timber.e(e, "Failed to read encryption key from USB")
+ null
+ }
+ }
+
+ override fun isEncryptionEnabled(): Boolean {
+ return runBlocking {
+ settings[PreferencesKeys.USB_ENCRYPTION_ENABLED] ?: false
+ }
+ }
+
+ private fun getCurrentUSBDevice(): UsbDevice? {
+ val deviceList = usbManager.deviceList
+ return deviceList.values.firstOrNull { device ->
+ device.interfaceCount > 0 && device.getInterface(0).interfaceClass == 8
+ }
+ }
+
+ private suspend fun exportLogsToUSB() {
+ try {
+ timber.d("=== EXPORTING LOGS TO USB ===")
+
+ // Get USB mount point
+ var usbMountPoint = getUSBMountPoint()
+ if (usbMountPoint == null) {
+ timber.w("Cannot export logs - no writable USB mount point found")
+ return
+ }
+
+ timber.d("USB mount point for logs: ${usbMountPoint.absolutePath}")
+
+ // Try to write to root of USB first, not subdirectories
+ // Navigate up to the actual USB root if we're in a subdirectory
+ while (usbMountPoint != null &&
+ (usbMountPoint.absolutePath.contains("/Android/data") ||
+ usbMountPoint.absolutePath.contains("/files"))) {
+ usbMountPoint = usbMountPoint.parentFile
+ timber.d("Navigating up to: ${usbMountPoint?.absolutePath}")
+ }
+
+ if (usbMountPoint == null || !usbMountPoint.canWrite()) {
+ timber.e("USB root is not writable")
+ return
+ }
+
+ timber.d("Final USB root for logs: ${usbMountPoint.absolutePath}")
+
+ // Capture logcat output
+ val timestamp = System.currentTimeMillis()
+ val logFileName = "m3u_diagnostics_$timestamp.txt"
+ val logFile = File(usbMountPoint, logFileName)
+
+ timber.d("Log file path: ${logFile.absolutePath}")
+
+ // Execute logcat command to capture logs
+ val process = Runtime.getRuntime().exec(
+ arrayOf(
+ "logcat",
+ "-d", // Dump existing logs
+ "-v", "time", // Include timestamps
+ "USBKeyRepository:D",
+ "WebServerRepository:D",
+ "SecuritySection:D",
+ "SettingViewModel:D",
+ "*:S" // Silence all other logs
+ )
+ )
+
+ // Read the output
+ var logContent = process.inputStream.bufferedReader().use { it.readText() }
+
+ // Check if sanitization is enabled
+ val sanitizationEnabled = settings[PreferencesKeys.DIAGNOSTIC_LOG_SANITIZATION_ENABLED] ?: true
+ if (sanitizationEnabled) {
+ timber.d("Log sanitization enabled - sanitizing logs...")
+ logContent = logSanitizer.sanitize(logContent)
+ timber.d("Log sanitization complete: ${logSanitizer.getSanitizationSummary()}")
+ } else {
+ timber.w("Log sanitization disabled - exporting raw logs")
+ }
+
+ // Add diagnostic header
+ val diagnosticReport = buildString {
+ appendLine("=".repeat(70))
+ appendLine("M3U ANDROID - DIAGNOSTIC LOG EXPORT")
+ appendLine("=".repeat(70))
+ appendLine("Timestamp: ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date(timestamp))}")
+ appendLine()
+ appendLine("DEVICE HARDWARE INFORMATION:")
+ appendLine("-".repeat(70))
+ appendLine("Device Manufacturer: ${android.os.Build.MANUFACTURER}")
+ appendLine("Device Brand: ${android.os.Build.BRAND}")
+ appendLine("Device Model: ${android.os.Build.MODEL}")
+ appendLine("Device Product: ${android.os.Build.PRODUCT}")
+ appendLine("Device Hardware: ${android.os.Build.HARDWARE}")
+ appendLine("Device Board: ${android.os.Build.BOARD}")
+ appendLine("Device Type: ${android.os.Build.TYPE}")
+ appendLine("Build Fingerprint: ${android.os.Build.FINGERPRINT}")
+ appendLine("Android Version: ${android.os.Build.VERSION.RELEASE} (SDK ${android.os.Build.VERSION.SDK_INT})")
+ appendLine()
+ appendLine("USB ENCRYPTION STATUS:")
+ appendLine("-".repeat(70))
+ appendLine("USB Mount Point: ${usbMountPoint.absolutePath}")
+ appendLine("USB Device Connected: ${_state.value.isConnected}")
+ appendLine("USB Device Name: ${_state.value.deviceName ?: "N/A"}")
+ appendLine("Encryption Enabled: ${_state.value.isEncryptionEnabled}")
+ appendLine("Database Unlocked: ${_state.value.isDatabaseUnlocked}")
+ appendLine("=".repeat(70))
+ appendLine()
+ appendLine("CAPTURED LOGS:")
+ appendLine("=".repeat(70))
+ appendLine(logContent)
+ }
+
+ // Write to file
+ logFile.writeText(diagnosticReport)
+
+ timber.d("✓ Logs exported successfully to: ${logFile.absolutePath}")
+ timber.d("✓ Log file size: ${logFile.length()} bytes")
+
+ } catch (e: Exception) {
+ timber.e(e, "Failed to export logs to USB")
+ timber.e("Error type: ${e.javaClass.simpleName}")
+ timber.e("Error message: ${e.message}")
+ }
+ }
+
+ private fun getUSBMountPoint(): File? {
+ timber.d("=== Starting USB mount point search ===")
+
+ // Method 1: Try StorageManager API (best for Android TV)
+ try {
+ val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as? android.os.storage.StorageManager
+ if (storageManager != null) {
+ timber.d("Using StorageManager to find USB storage...")
+
+ // Use reflection to access getVolumeList which is not in public API
+ val getVolumeListMethod = storageManager.javaClass.getMethod("getVolumeList")
+ val volumes = getVolumeListMethod.invoke(storageManager) as? Array<*>
+
+ timber.d("Found ${volumes?.size ?: 0} storage volumes")
+
+ volumes?.forEachIndexed { index, volume ->
+ try {
+ val getPathMethod = volume!!.javaClass.getMethod("getPath")
+ val isRemovableMethod = volume.javaClass.getMethod("isRemovable")
+ val getStateMethod = volume.javaClass.getMethod("getState")
+
+ val path = getPathMethod.invoke(volume) as? String
+ val isRemovable = isRemovableMethod.invoke(volume) as? Boolean ?: false
+ val state = getStateMethod.invoke(volume) as? String
+
+ timber.d("Volume $index: path=$path, removable=$isRemovable, state=$state")
+
+ if (path != null && isRemovable && state == "mounted") {
+ val volumeDir = File(path)
+ timber.d("Checking removable volume: ${volumeDir.absolutePath}")
+ timber.d(" exists: ${volumeDir.exists()}, canRead: ${volumeDir.canRead()}, canWrite: ${volumeDir.canWrite()}")
+
+ if (volumeDir.exists() && volumeDir.canRead()) {
+ // Try to write directly to USB root
+ if (volumeDir.canWrite()) {
+ timber.d("✓ Found writable USB root: ${volumeDir.absolutePath}")
+ return volumeDir
+ }
+
+ // Try app-specific directory on USB
+ val appDir = File(volumeDir, "Android/data/${context.packageName}/files")
+ timber.d("Trying app-specific directory: ${appDir.absolutePath}")
+ if (!appDir.exists()) {
+ val created = appDir.mkdirs()
+ timber.d("Created app directory: $created")
+ }
+ if (appDir.exists() && appDir.canWrite()) {
+ timber.d("✓ Found writable app-specific USB directory: ${appDir.absolutePath}")
+ return appDir
+ }
+ }
+ }
+ } catch (e: Exception) {
+ timber.w(e, "Failed to inspect volume $index")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ timber.w(e, "StorageManager method failed, trying fallback")
+ }
+
+ // Method 2: Try common USB mount points
+ timber.d("Trying common USB mount points...")
+ val possibleMountPoints = listOf(
+ "/storage/usbotg",
+ "/storage/usb",
+ "/mnt/usb",
+ "/mnt/usbstorage",
+ "/mnt/media_rw",
+ "/storage"
+ )
+
+ for (mountPoint in possibleMountPoints) {
+ timber.d("Checking mount point: $mountPoint")
+ val dir = File(mountPoint)
+ timber.d(" exists: ${dir.exists()}, isDirectory: ${dir.isDirectory}")
+
+ if (dir.exists() && dir.isDirectory) {
+ // Check subdirectories
+ val subdirs = dir.listFiles()
+ timber.d(" subdirectories: ${subdirs?.size ?: 0}")
+
+ subdirs?.forEach { subDir ->
+ timber.d(" checking ${subDir.absolutePath}: isDir=${subDir.isDirectory}, canRead=${subDir.canRead()}, canWrite=${subDir.canWrite()}")
+
+ if (subDir.isDirectory && subDir.name != "emulated") {
+ // Try writable first
+ if (subDir.canWrite()) {
+ timber.d("✓ Found writable USB mount point: ${subDir.absolutePath}")
+ return subDir
+ }
+
+ // If not writable, try app-specific directory
+ val appDir = File(subDir, "Android/data/${context.packageName}/files")
+ if (!appDir.exists()) {
+ val created = appDir.mkdirs()
+ timber.d("Created app-specific directory: $created")
+ }
+ if (appDir.exists() && appDir.canWrite()) {
+ timber.d("✓ Found writable app-specific USB directory: ${appDir.absolutePath}")
+ return appDir
+ }
+ }
+ }
+ }
+ }
+
+ // Method 3: Use getExternalFilesDirs (secondary external storage is usually USB)
+ timber.d("Trying external storage directories via getExternalFilesDirs...")
+ context.getExternalFilesDirs(null)?.forEachIndexed { index, dir ->
+ timber.d("External dir $index: ${dir?.absolutePath}")
+ if (dir != null && index > 0) { // index > 0 means not primary storage
+ timber.d(" exists: ${dir.exists()}, canWrite: ${dir.canWrite()}")
+
+ if (dir.exists() && dir.canWrite()) {
+ // Go up to the actual USB root, not the app-specific directory
+ var usbRoot: File? = dir
+ while (usbRoot != null && usbRoot.absolutePath.contains("/Android/data")) {
+ usbRoot = usbRoot.parentFile?.parentFile?.parentFile?.parentFile
+ }
+
+ if (usbRoot != null && usbRoot.exists()) {
+ timber.d("Found USB root from external dir: ${usbRoot.absolutePath}")
+ if (usbRoot.canWrite()) {
+ timber.d("✓ Using USB root: ${usbRoot.absolutePath}")
+ return usbRoot
+ } else {
+ // Return the app-specific directory if root is not writable
+ timber.d("✓ Using app-specific directory on USB: ${dir.absolutePath}")
+ return dir
+ }
+ }
+ }
+ }
+ }
+
+ // Method 4: Last resort - app's primary external storage (for testing)
+ val appExternalDir = context.getExternalFilesDir(null)
+ if (appExternalDir != null && appExternalDir.canWrite()) {
+ timber.w("⚠ Using app's external storage as last resort fallback: ${appExternalDir.absolutePath}")
+ timber.w("⚠ This is NOT a USB drive - encryption will still work but key won't be on USB!")
+ return appExternalDir
+ }
+
+ timber.e("✗ Could not find any writable USB mount point")
+ timber.e("✗ USB encryption cannot proceed without writable storage")
+ return null
+ }
+}
diff --git a/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyState.kt b/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyState.kt
new file mode 100644
index 000000000..559f56594
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/usbkey/USBKeyState.kt
@@ -0,0 +1,30 @@
+package com.m3u.data.repository.usbkey
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+data class USBKeyState(
+ val isConnected: Boolean = false,
+ val deviceName: String? = null,
+ val isEncryptionEnabled: Boolean = false,
+ val isDatabaseUnlocked: Boolean = false,
+ val error: String? = null,
+
+ // Enhancement #2: Key Verification
+ val keyVerified: Boolean = false,
+ val lastVerificationTime: Long? = null,
+ val verificationError: String? = null,
+
+ // Enhancement #3: Auto-Lock
+ val isLocked: Boolean = false,
+ val lockReason: String? = null,
+
+ // Enhancement #6: Encryption Progress
+ val encryptionProgress: EncryptionProgress? = null,
+
+ // Enhancement #9: Status Dashboard
+ val databaseSize: Long? = null,
+ val encryptionAlgorithm: String? = null,
+ val autoLockEnabled: Boolean = true,
+ val healthStatus: HealthStatus = HealthStatus.DISABLED
+)
diff --git a/data/src/main/java/com/m3u/data/repository/webserver/WebServerRepository.kt b/data/src/main/java/com/m3u/data/repository/webserver/WebServerRepository.kt
new file mode 100644
index 000000000..40bc4efd9
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/webserver/WebServerRepository.kt
@@ -0,0 +1,11 @@
+package com.m3u.data.repository.webserver
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface WebServerRepository {
+ val state: StateFlow
+
+ suspend fun start(port: Int = 8080): Result
+ suspend fun stop(): Result
+ fun isRunning(): Boolean
+}
diff --git a/data/src/main/java/com/m3u/data/repository/webserver/WebServerRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/webserver/WebServerRepositoryImpl.kt
new file mode 100644
index 000000000..d5b74b4a8
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/webserver/WebServerRepositoryImpl.kt
@@ -0,0 +1,340 @@
+package com.m3u.data.repository.webserver
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import io.ktor.http.*
+import io.ktor.serialization.kotlinx.json.*
+import io.ktor.http.content.*
+import io.ktor.server.application.*
+import io.ktor.server.engine.*
+import io.ktor.server.cio.*
+import io.ktor.server.plugins.contentnegotiation.*
+import io.ktor.server.plugins.cors.routing.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import com.m3u.data.repository.playlist.PlaylistRepository
+import timber.log.Timber
+import java.io.File
+import java.net.InetAddress
+import java.net.NetworkInterface
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val MAX_FILE_SIZE_BYTES = 400L * 1024 * 1024 // 400 MB
+
+@Serializable
+data class UrlImportRequest(
+ val url: String,
+ val title: String
+)
+
+@Serializable
+data class XtreamImportRequest(
+ val title: String,
+ val basicUrl: String,
+ val username: String,
+ val password: String,
+ val type: String? = null
+)
+
+@Serializable
+data class UploadResponse(
+ val success: Boolean,
+ val message: String,
+ val count: Int = 0,
+ val error: String? = null
+)
+
+@Singleton
+internal class WebServerRepositoryImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val playlistRepository: PlaylistRepository
+) : WebServerRepository {
+
+ private val timber = Timber.tag("WebServerRepository")
+
+ private val _state = MutableStateFlow(WebServerState())
+ override val state: StateFlow = _state.asStateFlow()
+
+ private var server: EmbeddedServer<*, *>? = null
+
+ override suspend fun start(port: Int): Result = withContext(Dispatchers.IO) {
+ try {
+ if (server != null) {
+ return@withContext Result.failure(IllegalStateException("Server is already running"))
+ }
+
+ val ipAddress = getLocalIpAddress()
+ if (ipAddress == null) {
+ return@withContext Result.failure(Exception("Could not determine local IP address"))
+ }
+
+ val embeddedServerInstance = embeddedServer(CIO, port = port, host = "0.0.0.0") {
+ install(ContentNegotiation) {
+ json(Json {
+ prettyPrint = true
+ ignoreUnknownKeys = true
+ })
+ }
+ install(CORS) {
+ anyHost()
+ allowHeader(HttpHeaders.ContentType)
+ }
+ configureRouting()
+ }
+ embeddedServerInstance.start(wait = false)
+ server = embeddedServerInstance
+
+ _state.value = WebServerState(
+ isRunning = true,
+ ipAddress = ipAddress,
+ port = port,
+ error = null
+ )
+
+ timber.d("Web server started at http://$ipAddress:$port")
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to start web server")
+ _state.value = WebServerState(
+ isRunning = false,
+ error = e.message
+ )
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun stop(): Result = withContext(Dispatchers.IO) {
+ try {
+ server?.stop(1000, 2000)
+ server = null
+ _state.value = WebServerState(isRunning = false)
+ timber.d("Web server stopped")
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to stop web server")
+ Result.failure(e)
+ }
+ }
+
+ override fun isRunning(): Boolean = server != null
+
+ private fun Application.configureRouting() {
+ routing {
+ // Serve HTML upload page
+ get("/") {
+ val html = this::class.java.classLoader?.getResourceAsStream("upload.html")?.use { it.readBytes() }
+ ?: throw Exception("Could not load upload.html")
+ call.respondBytes(html, ContentType.Text.Html)
+ }
+
+ // Status endpoint
+ get("/status") {
+ call.respond(
+ mapOf(
+ "server" to "M3U Android",
+ "version" to "1.0.0",
+ "ip" to (_state.value.ipAddress ?: "unknown"),
+ "port" to _state.value.port,
+ "playlists" to (playlistRepository.getAll().size)
+ )
+ )
+ }
+
+ // File upload endpoint
+ post("/upload") {
+ try {
+ val multipart = call.receiveMultipart()
+ var fileBytes: ByteArray? = null
+ var filename: String? = null
+ var title: String? = null
+
+ multipart.forEachPart { part ->
+ when (part) {
+ is PartData.FormItem -> {
+ if (part.name == "title") {
+ title = part.value
+ }
+ }
+ is PartData.FileItem -> {
+ filename = part.originalFileName ?: "playlist.m3u"
+ fileBytes = part.streamProvider().readBytes()
+ }
+ else -> {}
+ }
+ part.dispose()
+ }
+
+ if (fileBytes == null) {
+ call.respond(
+ HttpStatusCode.BadRequest,
+ UploadResponse(
+ success = false,
+ message = "No file uploaded",
+ error = "Missing file"
+ )
+ )
+ return@post
+ }
+
+ // Validate file size (400 MB max)
+ if (fileBytes!!.size > MAX_FILE_SIZE_BYTES) {
+ call.respond(
+ HttpStatusCode.PayloadTooLarge,
+ UploadResponse(
+ success = false,
+ message = "File too large",
+ error = "Maximum file size is 400 MB. Your file is ${fileBytes!!.size / (1024 * 1024)} MB"
+ )
+ )
+ return@post
+ }
+
+ // Save file temporarily
+ val tempFile = File(context.cacheDir, filename ?: "upload_${System.currentTimeMillis()}.m3u")
+ tempFile.writeBytes(fileBytes!!)
+
+ // Import using existing repository method
+ val playlistTitle = title ?: filename?.replace(Regex("\\.(m3u|m3u8)$", RegexOption.IGNORE_CASE), "") ?: "Uploaded Playlist"
+ var channelCount = 0
+
+ playlistRepository.m3uOrThrow(
+ title = playlistTitle,
+ url = tempFile.toURI().toString(),
+ callback = { count -> channelCount = count }
+ )
+
+ // Clean up temp file
+ tempFile.delete()
+
+ call.respond(
+ UploadResponse(
+ success = true,
+ message = "Playlist imported successfully",
+ count = channelCount
+ )
+ )
+ timber.d("File upload successful: $playlistTitle with $channelCount channels")
+ } catch (e: Exception) {
+ timber.e(e, "File upload failed")
+ call.respond(
+ HttpStatusCode.InternalServerError,
+ UploadResponse(
+ success = false,
+ message = "Upload failed",
+ error = e.message
+ )
+ )
+ }
+ }
+
+ // URL import endpoint
+ post("/import-url") {
+ try {
+ val request = call.receive()
+ var channelCount = 0
+
+ playlistRepository.m3uOrThrow(
+ title = request.title,
+ url = request.url,
+ callback = { count -> channelCount = count }
+ )
+
+ call.respond(
+ UploadResponse(
+ success = true,
+ message = "Playlist imported from URL",
+ count = channelCount
+ )
+ )
+ timber.d("URL import successful: ${request.title} with $channelCount channels")
+ } catch (e: Exception) {
+ timber.e(e, "URL import failed")
+ call.respond(
+ HttpStatusCode.InternalServerError,
+ UploadResponse(
+ success = false,
+ message = "URL import failed",
+ error = e.message
+ )
+ )
+ }
+ }
+
+ // Xtream codes import endpoint
+ post("/import-xtream") {
+ try {
+ val request = call.receive()
+ var channelCount = 0
+
+ playlistRepository.xtreamOrThrow(
+ title = request.title,
+ basicUrl = request.basicUrl,
+ username = request.username,
+ password = request.password,
+ type = request.type,
+ callback = { count -> channelCount = count }
+ )
+
+ call.respond(
+ UploadResponse(
+ success = true,
+ message = "Xtream playlist imported successfully",
+ count = channelCount
+ )
+ )
+ timber.d("Xtream import successful: ${request.title} with $channelCount channels")
+ } catch (e: Exception) {
+ timber.e(e, "Xtream import failed")
+ call.respond(
+ HttpStatusCode.InternalServerError,
+ UploadResponse(
+ success = false,
+ message = "Xtream import failed",
+ error = e.message
+ )
+ )
+ }
+ }
+ }
+ }
+
+ private fun getLocalIpAddress(): String? {
+ try {
+ // Check if running in Android emulator
+ val isEmulator = android.os.Build.FINGERPRINT.contains("generic") ||
+ android.os.Build.FINGERPRINT.contains("emulator") ||
+ android.os.Build.MODEL.contains("Emulator") ||
+ android.os.Build.MODEL.contains("Android SDK")
+
+ if (isEmulator) {
+ // For emulator, return localhost which will work with adb port forwarding
+ // User needs to run: adb forward tcp:8080 tcp:8080
+ return "localhost"
+ }
+
+ val interfaces = NetworkInterface.getNetworkInterfaces()
+ while (interfaces.hasMoreElements()) {
+ val networkInterface = interfaces.nextElement()
+ val addresses = networkInterface.inetAddresses
+ while (addresses.hasMoreElements()) {
+ val address = addresses.nextElement()
+ if (!address.isLoopbackAddress && address is InetAddress && address.hostAddress?.contains(":") == false) {
+ return address.hostAddress
+ }
+ }
+ }
+ } catch (e: Exception) {
+ timber.e(e, "Failed to get local IP address")
+ }
+ return null
+ }
+}
diff --git a/data/src/main/java/com/m3u/data/repository/webserver/WebServerState.kt b/data/src/main/java/com/m3u/data/repository/webserver/WebServerState.kt
new file mode 100644
index 000000000..a1360cbae
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/repository/webserver/WebServerState.kt
@@ -0,0 +1,16 @@
+package com.m3u.data.repository.webserver
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+data class WebServerState(
+ val isRunning: Boolean = false,
+ val ipAddress: String? = null,
+ val port: Int = 8080,
+ val error: String? = null
+) {
+ val accessUrl: String?
+ get() = if (isRunning && ipAddress != null) {
+ "http://$ipAddress:$port"
+ } else null
+}
diff --git a/data/src/main/java/com/m3u/data/security/EncryptionLockManager.kt b/data/src/main/java/com/m3u/data/security/EncryptionLockManager.kt
new file mode 100644
index 000000000..16ac76118
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/security/EncryptionLockManager.kt
@@ -0,0 +1,150 @@
+package com.m3u.data.security
+
+import android.content.Context
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.core.architecture.preferences.set
+import com.m3u.data.database.M3UDatabase
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.first
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Enhancement #3: Auto-Lock on USB Removal
+ * Manages application locking when USB is removed
+ */
+@Singleton
+class EncryptionLockManager @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val settings: Settings,
+ private val keyVerificationManager: KeyVerificationManager
+) {
+ private val timber = Timber.tag("EncryptionLockManager")
+ private var database: M3UDatabase? = null
+
+ // Allow database injection (internal for now, not exposed publicly)
+ internal fun setDatabase(db: Any?) {
+ // Store as Any to avoid exposing internal M3UDatabase type
+ this.database = db as? M3UDatabase
+ }
+
+ /**
+ * Lock the application due to USB removal or key mismatch
+ * @param reason Human-readable reason for locking
+ */
+ suspend fun lockApplication(reason: String) {
+ try {
+ timber.d("=== LOCKING APPLICATION ===")
+ timber.d("Reason: $reason")
+
+ // Close database connections
+ closeDatabase()
+
+ // Clear sensitive data from memory
+ clearSensitiveMemory()
+
+ // Mark application as locked
+ // Note: Lock state is managed in USBKeyRepository state
+
+ timber.d("Application locked successfully")
+ } catch (e: Exception) {
+ timber.e(e, "Failed to lock application")
+ }
+ }
+
+ /**
+ * Unlock the application with provided key
+ * @param key Encryption key to verify
+ * @return Result indicating success or failure
+ */
+ suspend fun unlockApplication(key: ByteArray): Result {
+ return try {
+ timber.d("=== UNLOCKING APPLICATION ===")
+
+ // Verify key matches stored fingerprint
+ val verified = keyVerificationManager.verifyKey(key)
+ if (!verified) {
+ timber.w("Unlock failed - key verification failed")
+ return Result.failure(Exception("Key verification failed"))
+ }
+
+ timber.d("Application unlocked successfully")
+ Result.success(Unit)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to unlock application")
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Check if application is currently locked
+ * Note: Actual lock state is managed in USBKeyRepository state
+ */
+ fun isApplicationLocked(): Boolean {
+ // Lock state is tracked in USBKeyState.isLocked
+ return false // Placeholder - actual state in repository
+ }
+
+ /**
+ * Close database connections gracefully
+ */
+ private fun closeDatabase() {
+ try {
+ database?.let { db ->
+ if (db.isOpen) {
+ timber.d("Closing database...")
+ db.close()
+ timber.d("Database closed")
+ }
+ }
+ } catch (e: Exception) {
+ timber.e(e, "Failed to close database")
+ }
+ }
+
+ /**
+ * Clear sensitive data from memory
+ * Attempt to overwrite encryption key bytes and encourage GC
+ */
+ fun clearSensitiveMemory() {
+ try {
+ timber.d("Clearing sensitive memory...")
+
+ // Note: Actual key clearing happens in USBKeyRepositoryImpl
+ // where the key ByteArray is stored
+
+ // Encourage garbage collection (best effort)
+ System.gc()
+
+ timber.d("Memory cleared")
+ } catch (e: Exception) {
+ timber.e(e, "Failed to clear sensitive memory")
+ }
+ }
+
+ /**
+ * Check if auto-lock is enabled
+ */
+ suspend fun isAutoLockEnabled(): Boolean {
+ return try {
+ settings.data.first()[PreferencesKeys.USB_ENCRYPTION_AUTO_LOCK] ?: true
+ } catch (e: Exception) {
+ timber.e(e, "Failed to check auto-lock setting")
+ true // Default to enabled for security
+ }
+ }
+
+ /**
+ * Set auto-lock enabled/disabled
+ */
+ suspend fun setAutoLockEnabled(enabled: Boolean) {
+ try {
+ settings[PreferencesKeys.USB_ENCRYPTION_AUTO_LOCK] = enabled
+ timber.d("Auto-lock ${if (enabled) "enabled" else "disabled"}")
+ } catch (e: Exception) {
+ timber.e(e, "Failed to set auto-lock")
+ }
+ }
+}
diff --git a/data/src/main/java/com/m3u/data/security/EncryptionMetricsCalculator.kt b/data/src/main/java/com/m3u/data/security/EncryptionMetricsCalculator.kt
new file mode 100644
index 000000000..0f90968e6
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/security/EncryptionMetricsCalculator.kt
@@ -0,0 +1,190 @@
+package com.m3u.data.security
+
+import android.content.Context
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.data.repository.usbkey.HealthStatus
+import com.m3u.data.repository.usbkey.USBKeyState
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.first
+import timber.log.Timber
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Enhancement #9: Encryption Status Dashboard
+ * Calculates metrics and statistics for the encryption system
+ */
+@Singleton
+class EncryptionMetricsCalculator @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val settings: Settings
+) {
+ private val timber = Timber.tag("EncryptionMetricsCalculator")
+
+ companion object {
+ private const val DATABASE_NAME = "m3u-database"
+ private const val ENCRYPTION_ALGORITHM = "AES-256-CBC (SQLCipher 4.5.4)"
+ private const val VERIFICATION_WARNING_THRESHOLD_HOURS = 24
+ }
+
+ /**
+ * Calculate database file size in bytes
+ * @return Database size in bytes, or null if database doesn't exist
+ */
+ fun calculateDatabaseSize(): Long? {
+ return try {
+ val dbFile = context.getDatabasePath(DATABASE_NAME)
+ if (dbFile.exists()) {
+ val sizeBytes = dbFile.length()
+ timber.d("Database size: $sizeBytes bytes (${formatBytes(sizeBytes)})")
+ sizeBytes
+ } else {
+ timber.d("Database file does not exist yet")
+ null
+ }
+ } catch (e: Exception) {
+ timber.e(e, "Failed to calculate database size")
+ null
+ }
+ }
+
+ /**
+ * Get encryption algorithm description
+ * @return Human-readable encryption algorithm name
+ */
+ fun getEncryptionAlgorithm(): String {
+ return ENCRYPTION_ALGORITHM
+ }
+
+ /**
+ * Get relative time string for last verification
+ * @return Human-readable relative time (e.g., "5 minutes ago", "2 hours ago")
+ */
+ suspend fun getLastVerifiedRelativeTime(): String? {
+ return try {
+ val timestamp = settings.data.first()[PreferencesKeys.USB_ENCRYPTION_LAST_VERIFIED] ?: 0L
+ if (timestamp == 0L) {
+ timber.d("No verification timestamp found")
+ return null
+ }
+
+ val currentTime = System.currentTimeMillis()
+ val diffMillis = currentTime - timestamp
+
+ formatRelativeTime(diffMillis)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to get last verified time")
+ null
+ }
+ }
+
+ /**
+ * Calculate overall health status based on encryption state
+ * @param state Current USB key state
+ * @return Health status indicator
+ */
+ suspend fun calculateHealthStatus(state: USBKeyState): HealthStatus {
+ return try {
+ when {
+ // Encryption disabled
+ !state.isEncryptionEnabled -> {
+ timber.d("Health: DISABLED (encryption not enabled)")
+ HealthStatus.DISABLED
+ }
+
+ // Key verification failed
+ state.verificationError != null -> {
+ timber.d("Health: CRITICAL (verification error: ${state.verificationError})")
+ HealthStatus.CRITICAL
+ }
+
+ // USB disconnected or app locked
+ !state.isConnected || state.isLocked -> {
+ timber.d("Health: WARNING (USB disconnected or app locked)")
+ HealthStatus.WARNING
+ }
+
+ // Check verification timestamp
+ else -> {
+ val lastVerified = state.lastVerificationTime
+ if (lastVerified == null) {
+ timber.d("Health: WARNING (never verified)")
+ HealthStatus.WARNING
+ } else {
+ val hoursSinceVerification = TimeUnit.MILLISECONDS.toHours(
+ System.currentTimeMillis() - lastVerified
+ )
+ if (hoursSinceVerification > VERIFICATION_WARNING_THRESHOLD_HOURS) {
+ timber.d("Health: WARNING (last verified $hoursSinceVerification hours ago)")
+ HealthStatus.WARNING
+ } else {
+ timber.d("Health: HEALTHY (all checks passed)")
+ HealthStatus.HEALTHY
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ timber.e(e, "Failed to calculate health status")
+ HealthStatus.WARNING
+ }
+ }
+
+ /**
+ * Format bytes to human-readable string
+ * @param bytes Size in bytes
+ * @return Formatted string (e.g., "2.5 MB")
+ */
+ fun formatBytes(bytes: Long): String {
+ return when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> String.format(Locale.US, "%.2f KB", bytes / 1024.0)
+ bytes < 1024 * 1024 * 1024 -> String.format(Locale.US, "%.2f MB", bytes / (1024.0 * 1024.0))
+ else -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))
+ }
+ }
+
+ /**
+ * Format relative time from milliseconds difference
+ * @param diffMillis Time difference in milliseconds
+ * @return Human-readable relative time string
+ */
+ private fun formatRelativeTime(diffMillis: Long): String {
+ val seconds = TimeUnit.MILLISECONDS.toSeconds(diffMillis)
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diffMillis)
+ val hours = TimeUnit.MILLISECONDS.toHours(diffMillis)
+ val days = TimeUnit.MILLISECONDS.toDays(diffMillis)
+
+ return when {
+ seconds < 60 -> "Just now"
+ minutes < 2 -> "1 minute ago"
+ minutes < 60 -> "$minutes minutes ago"
+ hours < 2 -> "1 hour ago"
+ hours < 24 -> "$hours hours ago"
+ days < 2 -> "1 day ago"
+ days < 7 -> "$days days ago"
+ days < 30 -> "${days / 7} weeks ago"
+ else -> {
+ val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.US)
+ dateFormat.format(Date(System.currentTimeMillis() - diffMillis))
+ }
+ }
+ }
+
+ /**
+ * Get estimated database encryption time based on size
+ * @param databaseSizeBytes Database size in bytes
+ * @return Estimated time in seconds
+ */
+ fun estimateEncryptionTime(databaseSizeBytes: Long): Long {
+ // Rough estimate: ~1 second per MB on average Android TV hardware
+ val sizeMb = databaseSizeBytes / (1024.0 * 1024.0)
+ return maxOf(5, (sizeMb * 1.0).toLong()) // Minimum 5 seconds
+ }
+}
diff --git a/data/src/main/java/com/m3u/data/security/KeyVerificationManager.kt b/data/src/main/java/com/m3u/data/security/KeyVerificationManager.kt
new file mode 100644
index 000000000..b51228984
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/security/KeyVerificationManager.kt
@@ -0,0 +1,136 @@
+package com.m3u.data.security
+
+import android.content.Context
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.core.architecture.preferences.set
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.first
+import timber.log.Timber
+import java.security.MessageDigest
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Enhancement #2: Key Verification on App Start
+ * Manages encryption key fingerprinting and verification using HMAC-SHA256
+ */
+@Singleton
+class KeyVerificationManager @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val settings: Settings
+) {
+ private val timber = Timber.tag("KeyVerificationManager")
+
+ companion object {
+ // App-specific salt for HMAC
+ private const val APP_SALT = "com.m3u.tv.encryption.v1"
+ private const val HMAC_ALGORITHM = "HmacSHA256"
+ }
+
+ /**
+ * Generate HMAC-SHA256 fingerprint of encryption key
+ * @param key The encryption key to fingerprint
+ * @return Hex-encoded fingerprint string
+ */
+ fun generateFingerprint(key: ByteArray): String {
+ return try {
+ val mac = Mac.getInstance(HMAC_ALGORITHM)
+ val secretKey = SecretKeySpec(APP_SALT.toByteArray(), HMAC_ALGORITHM)
+ mac.init(secretKey)
+ val hmac = mac.doFinal(key)
+ hmac.joinToString("") { "%02x".format(it) }
+ } catch (e: Exception) {
+ timber.e(e, "Failed to generate fingerprint")
+ ""
+ }
+ }
+
+ /**
+ * Store key fingerprint securely in preferences
+ * @param fingerprint The fingerprint to store
+ */
+ suspend fun storeFingerprint(fingerprint: String) {
+ try {
+ settings[PreferencesKeys.USB_ENCRYPTION_KEY_FINGERPRINT] = fingerprint
+ settings[PreferencesKeys.USB_ENCRYPTION_LAST_VERIFIED] = System.currentTimeMillis()
+ timber.d("Stored key fingerprint: ${fingerprint.takeLast(8)}")
+ } catch (e: Exception) {
+ timber.e(e, "Failed to store fingerprint")
+ }
+ }
+
+ /**
+ * Verify USB key matches stored fingerprint
+ * @param key The key to verify
+ * @return true if key matches stored fingerprint, false otherwise
+ */
+ suspend fun verifyKey(key: ByteArray): Boolean {
+ return try {
+ val storedFingerprint = settings.data.first()[PreferencesKeys.USB_ENCRYPTION_KEY_FINGERPRINT]
+ if (storedFingerprint.isNullOrEmpty()) {
+ timber.w("No stored fingerprint found")
+ return false
+ }
+
+ val currentFingerprint = generateFingerprint(key)
+ val matches = currentFingerprint == storedFingerprint
+
+ if (matches) {
+ // Update last verified timestamp
+ settings[PreferencesKeys.USB_ENCRYPTION_LAST_VERIFIED] = System.currentTimeMillis()
+ timber.d("Key verification successful")
+ } else {
+ timber.w("Key verification failed - fingerprint mismatch")
+ }
+
+ matches
+ } catch (e: Exception) {
+ timber.e(e, "Key verification error")
+ false
+ }
+ }
+
+ /**
+ * Get last verification timestamp
+ * @return Timestamp in milliseconds, or null if never verified
+ */
+ suspend fun getLastVerificationTime(): Long? {
+ return try {
+ val timestamp = settings.data.first()[PreferencesKeys.USB_ENCRYPTION_LAST_VERIFIED] ?: 0L
+ if (timestamp > 0) timestamp else null
+ } catch (e: Exception) {
+ timber.e(e, "Failed to get last verification time")
+ null
+ }
+ }
+
+ /**
+ * Clear stored fingerprint (when disabling encryption)
+ */
+ suspend fun clearFingerprint() {
+ try {
+ settings[PreferencesKeys.USB_ENCRYPTION_KEY_FINGERPRINT] = ""
+ settings[PreferencesKeys.USB_ENCRYPTION_LAST_VERIFIED] = 0L
+ timber.d("Cleared key fingerprint")
+ } catch (e: Exception) {
+ timber.e(e, "Failed to clear fingerprint")
+ }
+ }
+
+ /**
+ * Check if fingerprint exists (encryption initialized)
+ * @return true if fingerprint is stored
+ */
+ suspend fun hasFingerprint(): Boolean {
+ return try {
+ val fingerprint = settings.data.first()[PreferencesKeys.USB_ENCRYPTION_KEY_FINGERPRINT]
+ !fingerprint.isNullOrEmpty()
+ } catch (e: Exception) {
+ timber.e(e, "Failed to check fingerprint existence")
+ false
+ }
+ }
+}
diff --git a/data/src/main/java/com/m3u/data/security/PINKeyManager.kt b/data/src/main/java/com/m3u/data/security/PINKeyManager.kt
new file mode 100644
index 000000000..a09e66d06
--- /dev/null
+++ b/data/src/main/java/com/m3u/data/security/PINKeyManager.kt
@@ -0,0 +1,343 @@
+package com.m3u.data.security
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import androidx.datastore.preferences.core.edit
+import com.m3u.core.architecture.preferences.PreferencesKeys
+import com.m3u.core.architecture.preferences.Settings
+import com.m3u.core.architecture.preferences.get
+import dagger.hilt.android.qualifiers.ApplicationContext
+import timber.log.Timber
+import java.security.KeyStore
+import java.security.SecureRandom
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.SecretKeyFactory
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.PBEKeySpec
+import javax.crypto.spec.SecretKeySpec
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Manages PIN-based encryption using Android Keystore and PBKDF2 key derivation.
+ *
+ * Security features:
+ * - 6-digit PIN requirement
+ * - PBKDF2 with 100,000 iterations for key derivation
+ * - Salt stored securely in encrypted preferences
+ * - Derived key encrypted using Android Keystore master key
+ * - AES-256-GCM encryption
+ * - Hardware-backed security when available (TEE/Secure Element)
+ */
+@Singleton
+class PINKeyManager @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val settings: Settings
+) {
+ private val timber = Timber.tag("PINKeyManager")
+
+ companion object {
+ private const val KEYSTORE_ALIAS = "m3u_pin_master_key"
+ private const val PIN_LENGTH = 6
+ private const val PBKDF2_ITERATIONS = 100_000
+ private const val KEY_SIZE_BITS = 256
+ private const val GCM_TAG_LENGTH = 128
+ }
+
+ private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply {
+ load(null)
+ }
+
+ // ========================================
+ // SESSION-BASED UNLOCK STATE MANAGEMENT
+ // ========================================
+
+ /**
+ * Represents the current unlock state of the database.
+ * This is session-based and cleared when the app process terminates.
+ */
+ sealed class UnlockState {
+ /** Database is locked - PIN required */
+ object Locked : UnlockState()
+
+ /** Database is unlocked - key available in memory */
+ data class Unlocked(val timestamp: Long) : UnlockState()
+ }
+
+ /** Current unlock state (session-based, in-memory only) */
+ private var unlockState: UnlockState = UnlockState.Locked
+
+ /** Cached encryption key (cleared when locked) */
+ private var cachedKey: ByteArray? = null
+
+ /**
+ * Validates that PIN is exactly 6 digits
+ */
+ fun isValidPIN(pin: String): Boolean {
+ return pin.length == PIN_LENGTH && pin.all { it.isDigit() }
+ }
+
+ /**
+ * Initializes encryption with a new PIN
+ * Returns the 256-bit encryption key for SQLCipher
+ */
+ suspend fun initializeWithPIN(pin: String): Result {
+ return try {
+ timber.d("=== Initializing encryption with PIN ===")
+
+ if (!isValidPIN(pin)) {
+ return Result.failure(IllegalArgumentException("PIN must be exactly $PIN_LENGTH digits"))
+ }
+
+ // Generate random salt for PBKDF2
+ val salt = ByteArray(32)
+ SecureRandom().nextBytes(salt)
+ timber.d("Generated random salt")
+
+ // Derive 256-bit key from PIN using PBKDF2
+ val derivedKey = deriveKeyFromPIN(pin, salt)
+ timber.d("Derived 256-bit key from PIN using PBKDF2")
+
+ // Get or create Android Keystore master key
+ val masterKey = getOrCreateMasterKey()
+ timber.d("Retrieved Android Keystore master key")
+
+ // Encrypt the derived key with master key
+ val (encryptedKey, iv) = encryptWithMasterKey(derivedKey, masterKey)
+ timber.d("Encrypted derived key with master key")
+
+ // Store encrypted key, IV, and salt in preferences
+ settings.edit { prefs ->
+ prefs[PreferencesKeys.ENCRYPTED_DATABASE_KEY] = encryptedKey.toBase64()
+ prefs[PreferencesKeys.ENCRYPTION_KEY_IV] = iv.toBase64()
+ prefs[PreferencesKeys.ENCRYPTION_SALT] = salt.toBase64()
+ prefs[PreferencesKeys.PIN_ENCRYPTION_ENABLED] = true
+ }
+ timber.d("Stored encrypted key material in preferences")
+
+ timber.d("✓ PIN initialization complete")
+ Result.success(derivedKey)
+ } catch (e: Exception) {
+ timber.e(e, "Failed to initialize PIN encryption")
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Unlocks encryption by deriving key from PIN
+ * Returns the 256-bit encryption key for SQLCipher
+ */
+ suspend fun unlockWithPIN(pin: String): Result {
+ return try {
+ timber.d("=== Attempting to unlock with PIN ===")
+
+ if (!isValidPIN(pin)) {
+ return Result.failure(IllegalArgumentException("Invalid PIN format"))
+ }
+
+ // Retrieve stored salt
+ val saltBase64 = settings.get(PreferencesKeys.ENCRYPTION_SALT) ?: ""
+ if (saltBase64.isEmpty()) {
+ return Result.failure(IllegalStateException("No encryption salt found"))
+ }
+ val salt = saltBase64.fromBase64()
+ timber.d("Retrieved salt from preferences")
+
+ // Derive key from entered PIN
+ val derivedKey = deriveKeyFromPIN(pin, salt)
+ timber.d("Derived key from entered PIN")
+
+ // Retrieve encrypted key and IV
+ val encryptedKeyBase64 = settings.get(PreferencesKeys.ENCRYPTED_DATABASE_KEY) ?: ""
+ val ivBase64 = settings.get(PreferencesKeys.ENCRYPTION_KEY_IV) ?: ""
+
+ if (encryptedKeyBase64.isEmpty() || ivBase64.isEmpty()) {
+ return Result.failure(IllegalStateException("No encrypted key found"))
+ }
+
+ val encryptedKey = encryptedKeyBase64.fromBase64()
+ val iv = ivBase64.fromBase64()
+ timber.d("Retrieved encrypted key material")
+
+ // Get master key from Keystore
+ val masterKey = getOrCreateMasterKey()
+
+ // Decrypt the stored key with master key
+ val storedKey = decryptWithMasterKey(encryptedKey, iv, masterKey)
+ timber.d("Decrypted stored key with master key")
+
+ // Verify that derived key matches stored key
+ if (derivedKey.contentEquals(storedKey)) {
+ // Cache the key in memory for this session
+ cachedKey = derivedKey.copyOf()
+ unlockState = UnlockState.Unlocked(System.currentTimeMillis())
+
+ timber.d("✓ PIN verification successful - database unlocked")
+ timber.d("Key cached in memory for session")
+ Result.success(derivedKey)
+ } else {
+ timber.w("✗ PIN verification failed - keys do not match")
+ Result.failure(SecurityException("Incorrect PIN"))
+ }
+ } catch (e: Exception) {
+ timber.e(e, "Failed to unlock with PIN")
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Derives 256-bit key from PIN using PBKDF2-HMAC-SHA256
+ */
+ private fun deriveKeyFromPIN(pin: String, salt: ByteArray): ByteArray {
+ val spec = PBEKeySpec(pin.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_SIZE_BITS)
+ val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
+ return factory.generateSecret(spec).encoded
+ }
+
+ /**
+ * Gets or creates the Android Keystore master key
+ * This key is hardware-backed and cannot be extracted
+ */
+ private fun getOrCreateMasterKey(): SecretKey {
+ return if (keyStore.containsAlias(KEYSTORE_ALIAS)) {
+ timber.d("Using existing master key from Keystore")
+ (keyStore.getEntry(KEYSTORE_ALIAS, null) as KeyStore.SecretKeyEntry).secretKey
+ } else {
+ timber.d("Creating new master key in Keystore")
+ val keyGenerator = KeyGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_AES,
+ "AndroidKeyStore"
+ )
+
+ val spec = KeyGenParameterSpec.Builder(
+ KEYSTORE_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(KEY_SIZE_BITS)
+ .setUserAuthenticationRequired(false) // No biometric/lock screen required
+ .build()
+
+ keyGenerator.init(spec)
+ keyGenerator.generateKey()
+ }
+ }
+
+ /**
+ * Encrypts data using the Android Keystore master key
+ * Returns (encrypted data, IV)
+ */
+ private fun encryptWithMasterKey(data: ByteArray, masterKey: SecretKey): Pair {
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ cipher.init(Cipher.ENCRYPT_MODE, masterKey)
+ val iv = cipher.iv
+ val encrypted = cipher.doFinal(data)
+ return Pair(encrypted, iv)
+ }
+
+ /**
+ * Decrypts data using the Android Keystore master key
+ */
+ private fun decryptWithMasterKey(encryptedData: ByteArray, iv: ByteArray, masterKey: SecretKey): ByteArray {
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
+ cipher.init(Cipher.DECRYPT_MODE, masterKey, spec)
+ return cipher.doFinal(encryptedData)
+ }
+
+ /**
+ * Clears all PIN encryption data
+ */
+ suspend fun clearPINEncryption() {
+ try {
+ timber.d("Clearing PIN encryption data")
+
+ // Remove from preferences
+ settings.edit { prefs ->
+ prefs.remove(PreferencesKeys.ENCRYPTED_DATABASE_KEY)
+ prefs.remove(PreferencesKeys.ENCRYPTION_KEY_IV)
+ prefs.remove(PreferencesKeys.ENCRYPTION_SALT)
+ prefs[PreferencesKeys.PIN_ENCRYPTION_ENABLED] = false
+ }
+
+ // Remove master key from Keystore
+ if (keyStore.containsAlias(KEYSTORE_ALIAS)) {
+ keyStore.deleteEntry(KEYSTORE_ALIAS)
+ timber.d("Deleted master key from Keystore")
+ }
+
+ timber.d("✓ PIN encryption data cleared")
+ } catch (e: Exception) {
+ timber.e(e, "Failed to clear PIN encryption")
+ }
+ }
+
+ /**
+ * Checks if PIN encryption is enabled
+ */
+ suspend fun isPINEncryptionEnabled(): Boolean {
+ return try {
+ settings.get(PreferencesKeys.PIN_ENCRYPTION_ENABLED) ?: false
+ } catch (e: Exception) {
+ timber.e(e, "Failed to check PIN encryption status")
+ false
+ }
+ }
+
+ // ========================================
+ // SESSION STATE ACCESSORS
+ // ========================================
+
+ /**
+ * Gets the encryption key if the database is currently unlocked.
+ * Returns null if locked (PIN not entered yet).
+ *
+ * This is the key method that DatabaseModule uses to determine
+ * if the database can be opened.
+ */
+ fun getEncryptionKeyIfUnlocked(): ByteArray? {
+ return when (unlockState) {
+ is UnlockState.Unlocked -> {
+ timber.d("Returning cached encryption key (unlocked)")
+ cachedKey?.copyOf()
+ }
+ is UnlockState.Locked -> {
+ timber.d("Database is locked - no key available")
+ null
+ }
+ }
+ }
+
+ /**
+ * Locks the database by clearing the cached key.
+ * User will need to enter PIN again to unlock.
+ */
+ fun lockDatabase() {
+ timber.d("Locking database - clearing cached key")
+
+ // Securely clear the key from memory
+ cachedKey?.fill(0)
+ cachedKey = null
+
+ unlockState = UnlockState.Locked
+ timber.d("✓ Database locked")
+ }
+
+ /**
+ * Checks if the database is currently unlocked.
+ */
+ fun isUnlocked(): Boolean {
+ return unlockState is UnlockState.Unlocked
+ }
+
+ // Base64 encoding/decoding helpers
+ private fun ByteArray.toBase64(): String =
+ android.util.Base64.encodeToString(this, android.util.Base64.NO_WRAP)
+
+ private fun String.fromBase64(): ByteArray =
+ android.util.Base64.decode(this, android.util.Base64.NO_WRAP)
+}
diff --git a/data/src/main/resources/upload.html b/data/src/main/resources/upload.html
new file mode 100644
index 000000000..45c06a710
--- /dev/null
+++ b/data/src/main/resources/upload.html
@@ -0,0 +1,460 @@
+
+
+
+
+
+ M3U Playlist Upload
+
+
+
+
+
📺 M3U Playlist Upload
+
Import your IPTV playlists easily
+
+
+
+
+
+
+
+
+
+
+
📤
+
Drag & drop M3U file here
+
or click to browse (max 400 MB)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Uploading...
+
+
+
+
+
+
+
+
diff --git a/i18n/src/main/res/values-de-rDE/feat_playlist.xml b/i18n/src/main/res/values-de-rDE/feat_playlist.xml
index e45aa8ecc..76522ed8b 100644
--- a/i18n/src/main/res/values-de-rDE/feat_playlist.xml
+++ b/i18n/src/main/res/values-de-rDE/feat_playlist.xml
@@ -13,4 +13,7 @@
Stream existiert nichtspeichere in (%s)hochscrollen
-
\ No newline at end of file
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
+
diff --git a/i18n/src/main/res/values-de-rDE/feat_setting.xml b/i18n/src/main/res/values-de-rDE/feat_setting.xml
index 2b1416cf5..baa0dc14b 100644
--- a/i18n/src/main/res/values-de-rDE/feat_setting.xml
+++ b/i18n/src/main/res/values-de-rDE/feat_setting.xml
@@ -94,6 +94,18 @@
XtreamEmbyDropbox
+ WebDrop
+
+ Server Running
+ Server Stopped
+ Error: %s
+ Access URL
+ Copy URL
+ Start Web Server
+ Stop Web Server
+ Open the URL above in your browser to upload playlists from any device on your local network
+ URL copied to clipboard
+ Use the web interface to add playlists via WebDropAdresseBenutzer
@@ -127,4 +139,33 @@
Anschließend kannst Du die Playlist mit dem EPG verknüpfenZufällige Wiedergabe von Favoriten
-
\ No newline at end of file
+
+
+ Security
+ USB Encryption
+ Error: %s
+ Database Unlocked
+ Database Locked - Insert USB Key
+ Encryption Disabled
+ USB Device Connected
+ Enable USB Encryption
+ Connect USB Stick
+ Disable Encryption
+ ⚠️ WARNING: All playlists, channels, and VOD will only be accessible with this specific USB stick. If you lose the USB stick, you will permanently lose access to all content. There is no recovery option.
+ Enable USB Encryption?
+ This will encrypt your entire database with military-grade encryption. Only this USB stick will unlock your content. Without the USB stick, all data will be permanently inaccessible.\n\nThis action will:\n• Encrypt all playlists and channels\n• Require USB stick for app access\n• Cannot be recovered if USB is lost\n\nAre you sure?
+ Enable Encryption
+ Disable Encryption?
+ This will remove USB encryption and make your database accessible without the USB stick. Your existing data will remain, but will no longer be encrypted.
+ Disable
+ Cancel
+ Database Locked
+ Your database is encrypted and requires the USB encryption key to access.
+ Please insert your USB encryption key
+ USB encryption enabled successfully
+ USB encryption disabled
+ Encryption error: %s
+ No USB device connected. Please connect your USB stick first.
+ Waiting for: %s
+ No access to content without USB key
+
diff --git a/i18n/src/main/res/values-es-rES/feat_playlist.xml b/i18n/src/main/res/values-es-rES/feat_playlist.xml
index 41314a883..d69cb6425 100644
--- a/i18n/src/main/res/values-es-rES/feat_playlist.xml
+++ b/i18n/src/main/res/values-es-rES/feat_playlist.xml
@@ -13,4 +13,7 @@
la transmisión no existese ha guardado a (%s)subir
-
\ No newline at end of file
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
+
diff --git a/i18n/src/main/res/values-es-rES/feat_setting.xml b/i18n/src/main/res/values-es-rES/feat_setting.xml
index 3edac04a1..ea4181d75 100644
--- a/i18n/src/main/res/values-es-rES/feat_setting.xml
+++ b/i18n/src/main/res/values-es-rES/feat_setting.xml
@@ -122,4 +122,33 @@
puede asociar la lista con su(s) EPGreproducir aleatoriamente sólo desde favoritos
-
\ No newline at end of file
+
+
+ Security
+ USB Encryption
+ Error: %s
+ Database Unlocked
+ Database Locked - Insert USB Key
+ Encryption Disabled
+ USB Device Connected
+ Enable USB Encryption
+ Connect USB Stick
+ Disable Encryption
+ ⚠️ WARNING: All playlists, channels, and VOD will only be accessible with this specific USB stick. If you lose the USB stick, you will permanently lose access to all content. There is no recovery option.
+ Enable USB Encryption?
+ This will encrypt your entire database with military-grade encryption. Only this USB stick will unlock your content. Without the USB stick, all data will be permanently inaccessible.\n\nThis action will:\n• Encrypt all playlists and channels\n• Require USB stick for app access\n• Cannot be recovered if USB is lost\n\nAre you sure?
+ Enable Encryption
+ Disable Encryption?
+ This will remove USB encryption and make your database accessible without the USB stick. Your existing data will remain, but will no longer be encrypted.
+ Disable
+ Cancel
+ Database Locked
+ Your database is encrypted and requires the USB encryption key to access.
+ Please insert your USB encryption key
+ USB encryption enabled successfully
+ USB encryption disabled
+ Encryption error: %s
+ No USB device connected. Please connect your USB stick first.
+ Waiting for: %s
+ No access to content without USB key
+
diff --git a/i18n/src/main/res/values-es-rMX/feat_playlist.xml b/i18n/src/main/res/values-es-rMX/feat_playlist.xml
index 85d4b7036..e43e1fb5b 100644
--- a/i18n/src/main/res/values-es-rMX/feat_playlist.xml
+++ b/i18n/src/main/res/values-es-rMX/feat_playlist.xml
@@ -13,4 +13,7 @@
la emisión no existeguardada a (%s)subir
-
\ No newline at end of file
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
+
diff --git a/i18n/src/main/res/values-es-rMX/feat_setting.xml b/i18n/src/main/res/values-es-rMX/feat_setting.xml
index 71513b9da..137272591 100644
--- a/i18n/src/main/res/values-es-rMX/feat_setting.xml
+++ b/i18n/src/main/res/values-es-rMX/feat_setting.xml
@@ -122,4 +122,33 @@
puedes vincular la playlist con su(s) EPGreproducir al azar sólo desde favoritos
-
\ No newline at end of file
+
+
+ Security
+ USB Encryption
+ Error: %s
+ Database Unlocked
+ Database Locked - Insert USB Key
+ Encryption Disabled
+ USB Device Connected
+ Enable USB Encryption
+ Connect USB Stick
+ Disable Encryption
+ ⚠️ WARNING: All playlists, channels, and VOD will only be accessible with this specific USB stick. If you lose the USB stick, you will permanently lose access to all content. There is no recovery option.
+ Enable USB Encryption?
+ This will encrypt your entire database with military-grade encryption. Only this USB stick will unlock your content. Without the USB stick, all data will be permanently inaccessible.\n\nThis action will:\n• Encrypt all playlists and channels\n• Require USB stick for app access\n• Cannot be recovered if USB is lost\n\nAre you sure?
+ Enable Encryption
+ Disable Encryption?
+ This will remove USB encryption and make your database accessible without the USB stick. Your existing data will remain, but will no longer be encrypted.
+ Disable
+ Cancel
+ Database Locked
+ Your database is encrypted and requires the USB encryption key to access.
+ Please insert your USB encryption key
+ USB encryption enabled successfully
+ USB encryption disabled
+ Encryption error: %s
+ No USB device connected. Please connect your USB stick first.
+ Waiting for: %s
+ No access to content without USB key
+
diff --git a/i18n/src/main/res/values-fr-rFR/app.xml b/i18n/src/main/res/values-fr-rFR/app.xml
deleted file mode 100644
index 0c6733dc8..000000000
--- a/i18n/src/main/res/values-fr-rFR/app.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- L'application plante
- Récemment
- Chaîne récemment jouée
- indisponible
- Oups ! L'application a planté
- Les traces ont été collectées, vous pouvez nous les partager plus tard !
- Protégé
-
diff --git a/i18n/src/main/res/values-fr-rFR/data.xml b/i18n/src/main/res/values-fr-rFR/data.xml
deleted file mode 100644
index dec3050fe..000000000
--- a/i18n/src/main/res/values-fr-rFR/data.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
- fichier introuvable
- le nom de la playlist est vide
- Service de téléchargement de flux
- Description du téléchargement de flux
-
- Annuler
- Réessayer
- Terminé (+%d)
- %d chaînes ont été téléchargées
- %d programmes ont été téléchargés
-
diff --git a/i18n/src/main/res/values-fr-rFR/feat_about.xml b/i18n/src/main/res/values-fr-rFR/feat_about.xml
deleted file mode 100644
index 55dad4eff..000000000
--- a/i18n/src/main/res/values-fr-rFR/feat_about.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- à propos du projet
-
diff --git a/i18n/src/main/res/values-fr-rFR/feat_console.xml b/i18n/src/main/res/values-fr-rFR/feat_console.xml
deleted file mode 100644
index 7582a3eb2..000000000
--- a/i18n/src/main/res/values-fr-rFR/feat_console.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- Éditeur de console
-
diff --git a/i18n/src/main/res/values-fr-rFR/feat_favourite.xml b/i18n/src/main/res/values-fr-rFR/feat_favourite.xml
deleted file mode 100644
index 4acd488d3..000000000
--- a/i18n/src/main/res/values-fr-rFR/feat_favourite.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
- inconnu
- lecture aléatoire
-
diff --git a/i18n/src/main/res/values-fr-rFR/feat_foryou.xml b/i18n/src/main/res/values-fr-rFR/feat_foryou.xml
deleted file mode 100644
index 635f37a9a..000000000
--- a/i18n/src/main/res/values-fr-rFR/feat_foryou.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
- chaînes masquées
- se désabonner
- copier l'URL
- renommer
- importé
- ajouter une playlist
- favori que vous reverrez
- plus de %d jours
- %d jours
- %d heures
- continuer à regarder
- nouvelle sortie
- entrez le code depuis la TV
- Assurez-vous d'être connecté au même Wi-Fi
-
diff --git a/i18n/src/main/res/values-fr-rFR/feat_playlist.xml b/i18n/src/main/res/values-fr-rFR/feat_playlist.xml
deleted file mode 100644
index f3e1841d6..000000000
--- a/i18n/src/main/res/values-fr-rFR/feat_playlist.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
- inconnu
- j'aime
- je n'aime plus
- masquer
- enregistrer dans la galerie
- créer un raccourci
- la playlist n'existe pas (%s)
- entrez un mot-clé
- l'URL de la playlist n'existe pas
- la couverture n'existe pas
- la chaîne n'existe pas
- enregistré dans (%s)
- défiler vers le haut
-
diff --git a/i18n/src/main/res/values-fr-rFR/feat_playlist_configuration.xml b/i18n/src/main/res/values-fr-rFR/feat_playlist_configuration.xml
deleted file mode 100644
index 447a03863..000000000
--- a/i18n/src/main/res/values-fr-rFR/feat_playlist_configuration.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
- titre
- user agent
- EPGs activés
- synchroniser les programmes
- annuler la synchronisation des programmes
- Expire: %s
- Les programmes mis en cache sont obsolètes
- Actualiser automatiquement les programmes
- Au démarrage de l'application
-
diff --git a/i18n/src/main/res/values-fr-rFR/feat_setting.xml b/i18n/src/main/res/values-fr-rFR/feat_setting.xml
deleted file mode 100644
index b0ba0f96f..000000000
--- a/i18n/src/main/res/values-fr-rFR/feat_setting.xml
+++ /dev/null
@@ -1,133 +0,0 @@
-
-
- version de l'application
- s'abonner
- abonnement réussi
- URL malformée (%s)
- le nom de la playlist est vide
- nom de la playlist
- lien de la playlist
- abonnement en cours
- tout
- garder
- mode de synchronisation
- gestion des playlists
- délai de connexion
- god mode
- ajuster les mises en page avec les boutons de volume
- mode expérimental
- les fonctionnalités instables peuvent provoquer des erreurs fatales
- Liste EPG
- chaînes masquées
- catégories de playlists masquées
- ajouter une playlist
- parser le presse-papiers
- sélectionner un fichier
- parser un fichier
- mode clip vidéo
- adaptatif
- clip
- étiré
- actualiser automatiquement les chaînes
- actualisé automatiquement à la lecture de la chaîne
-
- informations complètes du lecteur
- afficher plus d'informations dans le lecteur
- curseur
- mode sans image
- peut améliorer les performances
- paramètres système
- importer Javascript
- une tâche d'abonnement a été ajoutée à la file d'attente
- dropbox
- à propos du projet
- stockage local
- aucun fichier sélectionné
- URL vide
- couleurs dynamiques
- disponible sur Android 12 et supérieur
- fond coloré
- restaurer les schémas
- cette fonctionnalité n'est pas disponible pour la version actuelle
- mode zapping
- aperçu rapide avant la lecture de la chaîne
- geste de luminosité
- geste de volume
- diffusion d'écran
- rotation d'écran
- disponible lorsque la rotation système est déverrouillée
- fonctionnalités optionnelles
- recommander les chaînes favorites non-vues depuis longtemps
- reconnexion automatique
- jamais
- uniquement en cas d'échec
- toujours
-
- apparence
- appui long pour modifier la couleur
-
- mode tunnel
- améliore la lecture du contenu 4K/HDR
- sauvegarder
- restaurer
-
- sombre
- clair
- appliquer
- réinitialiser
-
- mode horloge 12h du programme
-
- télécommande
- active la possibilité de contrôler à distance la TV
-
- télécommande
- autoriser le téléphone à contrôler la TV
-
- pour la TV
-
- sauvegarde de toutes les playlists et chaînes
- restauration de toutes les playlists et chaînes
-
- suivre le thème système
-
- M3U
- EPG
- Xtream
- Emby
- Dropbox
-
- adresse
- nom d'utilisateur
- mot de passe
-
- cela peut prendre beaucoup plus de temps
-
- retour à l'accueil
-
- toujours afficher le bouton replay
-
- panneau du lecteur
- glissez vers le haut le lecteur pour l'agrandir en mode portrait
-
- mettre en cache pendant la lecture
- cette option peut empêcher la lecture normale
- vider le cache
-
- pagination des chaînes
-
- utile pour un grand nombre de chaînes,
- mais le regroupement sera désactivé en même temps
-
- code source
-
- dimension compacte
-
- nom de l'epg
- le nom de l'epg est vide
- le lien de l'epg est vide
- lien de l'epg
- vous pouvez ensuite associer la playlist à l'EPG
-
- lecture aléatoire uniquement depuis les favoris
-
diff --git a/i18n/src/main/res/values-fr-rFR/feat_stream.xml b/i18n/src/main/res/values-fr-rFR/feat_stream.xml
deleted file mode 100644
index 5ffd2e2ba..000000000
--- a/i18n/src/main/res/values-fr-rFR/feat_stream.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
- inactif
- mise en mémoire tampon
- prêt
- terminé
-
- retour
- couper le son
- activer le son
- favori
- retirer des favoris
- télécharger
- arrêter le téléchargement
- diffuser l'écran
- ouvrir le panneau
- mode PIP
- rotation d'écran
- choisir le format
-
- Appareils DLNA
- Choisir les pistes
-
- ouvrir dans une application externe
-
- Dernière lecture à %s
- Reculer
-
diff --git a/i18n/src/main/res/values-fr-rFR/ui.xml b/i18n/src/main/res/values-fr-rFR/ui.xml
deleted file mode 100644
index 4d1ddccc1..000000000
--- a/i18n/src/main/res/values-fr-rFR/ui.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
- M3U
-+ Favoris
- Paramètres
-
- Pour vous
- Favoris
- Extension
- Paramètres
- Playlist
-
- erreur inconnue
- retour
-
- Le saviez-vous ?
- Nous enregistrerons votre **progression de visionnage** et la reprendrons la prochaine fois que vous jouerez
-
- Trier
- A-Z
- Z-A
- Récemment
- Mélangé
- jamais joué
- Non spécifié
-
- Se connecter
-
diff --git a/i18n/src/main/res/values-id-rID/feat_playlist.xml b/i18n/src/main/res/values-id-rID/feat_playlist.xml
index 9ff428c4c..15c7d3698 100644
--- a/i18n/src/main/res/values-id-rID/feat_playlist.xml
+++ b/i18n/src/main/res/values-id-rID/feat_playlist.xml
@@ -13,4 +13,7 @@
saluran tidak ditemukantersimpan di (%s)gulir ke atas
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
diff --git a/i18n/src/main/res/values-id-rID/feat_setting.xml b/i18n/src/main/res/values-id-rID/feat_setting.xml
index 2fa6f0eaa..a83d3c014 100644
--- a/i18n/src/main/res/values-id-rID/feat_setting.xml
+++ b/i18n/src/main/res/values-id-rID/feat_setting.xml
@@ -97,6 +97,18 @@
XtreamEmbyDropbox
+ WebDrop
+
+ Server Running
+ Server Stopped
+ Error: %s
+ Access URL
+ Copy URL
+ Start Web Server
+ Stop Web Server
+ Open the URL above in your browser to upload playlists from any device on your local network
+ URL copied to clipboard
+ Use the web interface to add playlists via WebDropAlamat URLusername
@@ -131,4 +143,33 @@
Anda bisa menghubungkan playlist dengan EPGPutar acak hanya dari daftar favorit
+
+
+ Security
+ USB Encryption
+ Error: %s
+ Database Unlocked
+ Database Locked - Insert USB Key
+ Encryption Disabled
+ USB Device Connected
+ Enable USB Encryption
+ Connect USB Stick
+ Disable Encryption
+ ⚠️ WARNING: All playlists, channels, and VOD will only be accessible with this specific USB stick. If you lose the USB stick, you will permanently lose access to all content. There is no recovery option.
+ Enable USB Encryption?
+ This will encrypt your entire database with military-grade encryption. Only this USB stick will unlock your content. Without the USB stick, all data will be permanently inaccessible.\n\nThis action will:\n• Encrypt all playlists and channels\n• Require USB stick for app access\n• Cannot be recovered if USB is lost\n\nAre you sure?
+ Enable Encryption
+ Disable Encryption?
+ This will remove USB encryption and make your database accessible without the USB stick. Your existing data will remain, but will no longer be encrypted.
+ Disable
+ Cancel
+ Database Locked
+ Your database is encrypted and requires the USB encryption key to access.
+ Please insert your USB encryption key
+ USB encryption enabled successfully
+ USB encryption disabled
+ Encryption error: %s
+ No USB device connected. Please connect your USB stick first.
+ Waiting for: %s
+ No access to content without USB key
diff --git a/i18n/src/main/res/values-it-rIT/feat_playlist.xml b/i18n/src/main/res/values-it-rIT/feat_playlist.xml
index 3f05c17c9..628642569 100644
--- a/i18n/src/main/res/values-it-rIT/feat_playlist.xml
+++ b/i18n/src/main/res/values-it-rIT/feat_playlist.xml
@@ -13,4 +13,7 @@
il canale non esistesalvato in (%s)scorri in alto
-
\ No newline at end of file
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
+
diff --git a/i18n/src/main/res/values-it-rIT/feat_setting.xml b/i18n/src/main/res/values-it-rIT/feat_setting.xml
index 33ccb8a47..16f4a73b0 100644
--- a/i18n/src/main/res/values-it-rIT/feat_setting.xml
+++ b/i18n/src/main/res/values-it-rIT/feat_setting.xml
@@ -96,6 +96,18 @@
XtreamEmbyDropbox
+ WebDrop
+
+ Server Running
+ Server Stopped
+ Error: %s
+ Access URL
+ Copy URL
+ Start Web Server
+ Stop Web Server
+ Open the URL above in your browser to upload playlists from any device on your local network
+ URL copied to clipboard
+ Use the web interface to add playlists via WebDropindirizzousername
@@ -130,4 +142,33 @@
puoi associare la playlist all\'EPGriproduzione casuale solo dai preferiti
-
\ No newline at end of file
+
+
+ Security
+ USB Encryption
+ Error: %s
+ Database Unlocked
+ Database Locked - Insert USB Key
+ Encryption Disabled
+ USB Device Connected
+ Enable USB Encryption
+ Connect USB Stick
+ Disable Encryption
+ ⚠️ WARNING: All playlists, channels, and VOD will only be accessible with this specific USB stick. If you lose the USB stick, you will permanently lose access to all content. There is no recovery option.
+ Enable USB Encryption?
+ This will encrypt your entire database with military-grade encryption. Only this USB stick will unlock your content. Without the USB stick, all data will be permanently inaccessible.\n\nThis action will:\n• Encrypt all playlists and channels\n• Require USB stick for app access\n• Cannot be recovered if USB is lost\n\nAre you sure?
+ Enable Encryption
+ Disable Encryption?
+ This will remove USB encryption and make your database accessible without the USB stick. Your existing data will remain, but will no longer be encrypted.
+ Disable
+ Cancel
+ Database Locked
+ Your database is encrypted and requires the USB encryption key to access.
+ Please insert your USB encryption key
+ USB encryption enabled successfully
+ USB encryption disabled
+ Encryption error: %s
+ No USB device connected. Please connect your USB stick first.
+ Waiting for: %s
+ No access to content without USB key
+
diff --git a/i18n/src/main/res/values-pt-rBR/feat_playlist.xml b/i18n/src/main/res/values-pt-rBR/feat_playlist.xml
index 24b2ca58b..95b4cfa3d 100644
--- a/i18n/src/main/res/values-pt-rBR/feat_playlist.xml
+++ b/i18n/src/main/res/values-pt-rBR/feat_playlist.xml
@@ -13,4 +13,7 @@
A stream não existeSalvo em (%s)Role para cima
-
\ No newline at end of file
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
+
diff --git a/i18n/src/main/res/values-pt-rBR/feat_setting.xml b/i18n/src/main/res/values-pt-rBR/feat_setting.xml
index 4fb6c62b7..2b4944e1a 100644
--- a/i18n/src/main/res/values-pt-rBR/feat_setting.xml
+++ b/i18n/src/main/res/values-pt-rBR/feat_setting.xml
@@ -92,6 +92,18 @@
XtreamEmbyDropbox
+ WebDrop
+
+ Server Running
+ Server Stopped
+ Error: %s
+ Access URL
+ Copy URL
+ Start Web Server
+ Stop Web Server
+ Open the URL above in your browser to upload playlists from any device on your local network
+ URL copied to clipboard
+ Use the web interface to add playlists via WebDropURLUsuário
@@ -120,4 +132,33 @@
O link do EPG está vazioLink do EPGVocê pode associar uma playlist ao EPG
-
\ No newline at end of file
+
+
+ Security
+ USB Encryption
+ Error: %s
+ Database Unlocked
+ Database Locked - Insert USB Key
+ Encryption Disabled
+ USB Device Connected
+ Enable USB Encryption
+ Connect USB Stick
+ Disable Encryption
+ ⚠️ WARNING: All playlists, channels, and VOD will only be accessible with this specific USB stick. If you lose the USB stick, you will permanently lose access to all content. There is no recovery option.
+ Enable USB Encryption?
+ This will encrypt your entire database with military-grade encryption. Only this USB stick will unlock your content. Without the USB stick, all data will be permanently inaccessible.\n\nThis action will:\n• Encrypt all playlists and channels\n• Require USB stick for app access\n• Cannot be recovered if USB is lost\n\nAre you sure?
+ Enable Encryption
+ Disable Encryption?
+ This will remove USB encryption and make your database accessible without the USB stick. Your existing data will remain, but will no longer be encrypted.
+ Disable
+ Cancel
+ Database Locked
+ Your database is encrypted and requires the USB encryption key to access.
+ Please insert your USB encryption key
+ USB encryption enabled successfully
+ USB encryption disabled
+ Encryption error: %s
+ No USB device connected. Please connect your USB stick first.
+ Waiting for: %s
+ No access to content without USB key
+
diff --git a/i18n/src/main/res/values-ro-rRO/feat_playlist.xml b/i18n/src/main/res/values-ro-rRO/feat_playlist.xml
index 0e6d58072..19c97e51a 100644
--- a/i18n/src/main/res/values-ro-rRO/feat_playlist.xml
+++ b/i18n/src/main/res/values-ro-rRO/feat_playlist.xml
@@ -13,4 +13,7 @@
canalul nu existasalvat in (%s)deruleazA sus
-
\ No newline at end of file
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
+
diff --git a/i18n/src/main/res/values-ro-rRO/feat_setting.xml b/i18n/src/main/res/values-ro-rRO/feat_setting.xml
index 5c363aee6..10fd97509 100644
--- a/i18n/src/main/res/values-ro-rRO/feat_setting.xml
+++ b/i18n/src/main/res/values-ro-rRO/feat_setting.xml
@@ -84,4 +84,33 @@
restaurare toate listele si canalelela fel ca tema telefonului
-
\ No newline at end of file
+
+
+ Security
+ USB Encryption
+ Error: %s
+ Database Unlocked
+ Database Locked - Insert USB Key
+ Encryption Disabled
+ USB Device Connected
+ Enable USB Encryption
+ Connect USB Stick
+ Disable Encryption
+ ⚠️ WARNING: All playlists, channels, and VOD will only be accessible with this specific USB stick. If you lose the USB stick, you will permanently lose access to all content. There is no recovery option.
+ Enable USB Encryption?
+ This will encrypt your entire database with military-grade encryption. Only this USB stick will unlock your content. Without the USB stick, all data will be permanently inaccessible.\n\nThis action will:\n• Encrypt all playlists and channels\n• Require USB stick for app access\n• Cannot be recovered if USB is lost\n\nAre you sure?
+ Enable Encryption
+ Disable Encryption?
+ This will remove USB encryption and make your database accessible without the USB stick. Your existing data will remain, but will no longer be encrypted.
+ Disable
+ Cancel
+ Database Locked
+ Your database is encrypted and requires the USB encryption key to access.
+ Please insert your USB encryption key
+ USB encryption enabled successfully
+ USB encryption disabled
+ Encryption error: %s
+ No USB device connected. Please connect your USB stick first.
+ Waiting for: %s
+ No access to content without USB key
+
diff --git a/i18n/src/main/res/values-sv-rSE/feat_playlist.xml b/i18n/src/main/res/values-sv-rSE/feat_playlist.xml
index 3d92f7bf2..4407359e8 100644
--- a/i18n/src/main/res/values-sv-rSE/feat_playlist.xml
+++ b/i18n/src/main/res/values-sv-rSE/feat_playlist.xml
@@ -13,4 +13,7 @@
kanalen finns intesparad i (%s)scrolla upp
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
diff --git a/i18n/src/main/res/values-sv-rSE/feat_setting.xml b/i18n/src/main/res/values-sv-rSE/feat_setting.xml
index a385a30aa..19c4b54ef 100644
--- a/i18n/src/main/res/values-sv-rSE/feat_setting.xml
+++ b/i18n/src/main/res/values-sv-rSE/feat_setting.xml
@@ -96,6 +96,18 @@
XtreamEmbyDropbox
+ WebDrop
+
+ Server Running
+ Server Stopped
+ Error: %s
+ Access URL
+ Copy URL
+ Start Web Server
+ Stop Web Server
+ Open the URL above in your browser to upload playlists from any device on your local network
+ URL copied to clipboard
+ Use the web interface to add playlists via WebDropadressanvändarnamn
@@ -130,4 +142,33 @@
du kan sedan associera spellistan med EPGspela slumpmässigt endast från favoriter
+
+
+ Säkerhet
+ USB-kryptering
+ Fel: %s
+ Databas upplåst
+ Databas låst - Sätt in USB-nyckel
+ Kryptering inaktiverad
+ USB-enhet ansluten
+ Aktivera USB-kryptering
+ Anslut USB-sticka
+ Inaktivera kryptering
+ ⚠️ VARNING: Alla spellistor, kanaler och VOD kommer endast att vara tillgängliga med just denna USB-sticka. Om du förlorar USB-stickan kommer du att permanent förlora åtkomst till allt innehåll. Det finns inget återställningsalternativ.
+ Aktivera USB-kryptering?
+ Detta kommer att kryptera hela din databas med militär-grade kryptering. Endast denna USB-sticka kommer att låsa upp ditt innehåll. Utan USB-stickan kommer all data att vara permanent otillgänglig.\n\nDenna åtgärd kommer att:\n• Kryptera alla spellistor och kanaler\n• Kräva USB-sticka för appåtkomst\n• Kan inte återställas om USB förloras\n\nÄr du säker?
+ Aktivera kryptering
+ Inaktivera kryptering?
+ Detta tar bort USB-krypteringen och gör din databas tillgänglig utan USB-stickan. Din befintliga data kommer att vara kvar, men kommer inte längre att vara krypterad.
+ Inaktivera
+ Avbryt
+ Databas låst
+ Din databas är krypterad och kräver USB-krypteringsnyckeln för åtkomst.
+ Vänligen sätt in din USB-krypteringsnyckel
+ USB-kryptering aktiverad framgångsrikt
+ USB-kryptering inaktiverad
+ Krypteringsfel: %s
+ Ingen USB-enhet ansluten. Vänligen anslut din USB-sticka först.
+ Väntar på: %s
+ Ingen åtkomst till innehåll utan USB-nyckel
diff --git a/i18n/src/main/res/values-tr-rTR/feat_playlist.xml b/i18n/src/main/res/values-tr-rTR/feat_playlist.xml
index 96da3bc53..9b9756c1e 100644
--- a/i18n/src/main/res/values-tr-rTR/feat_playlist.xml
+++ b/i18n/src/main/res/values-tr-rTR/feat_playlist.xml
@@ -13,4 +13,7 @@
Kanal bulunamadışuraya kaydedildi: (%%s)yukarı kaydır
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
diff --git a/i18n/src/main/res/values-tr-rTR/feat_setting.xml b/i18n/src/main/res/values-tr-rTR/feat_setting.xml
index e4cff0abb..57441789e 100644
--- a/i18n/src/main/res/values-tr-rTR/feat_setting.xml
+++ b/i18n/src/main/res/values-tr-rTR/feat_setting.xml
@@ -96,6 +96,18 @@
XtreamEmbyDropbox
+ WebDrop
+
+ Server Running
+ Server Stopped
+ Error: %s
+ Access URL
+ Copy URL
+ Start Web Server
+ Stop Web Server
+ Open the URL above in your browser to upload playlists from any device on your local network
+ URL copied to clipboard
+ Use the web interface to add playlists via WebDropadreskullanıcı adı
@@ -128,4 +140,33 @@
sonrasında oynatma listesi ile eşleştirebilirsinizyalnızca favorilerden rastgele oynat
+
+
+ Security
+ USB Encryption
+ Error: %s
+ Database Unlocked
+ Database Locked - Insert USB Key
+ Encryption Disabled
+ USB Device Connected
+ Enable USB Encryption
+ Connect USB Stick
+ Disable Encryption
+ ⚠️ WARNING: All playlists, channels, and VOD will only be accessible with this specific USB stick. If you lose the USB stick, you will permanently lose access to all content. There is no recovery option.
+ Enable USB Encryption?
+ This will encrypt your entire database with military-grade encryption. Only this USB stick will unlock your content. Without the USB stick, all data will be permanently inaccessible.\n\nThis action will:\n• Encrypt all playlists and channels\n• Require USB stick for app access\n• Cannot be recovered if USB is lost\n\nAre you sure?
+ Enable Encryption
+ Disable Encryption?
+ This will remove USB encryption and make your database accessible without the USB stick. Your existing data will remain, but will no longer be encrypted.
+ Disable
+ Cancel
+ Database Locked
+ Your database is encrypted and requires the USB encryption key to access.
+ Please insert your USB encryption key
+ USB encryption enabled successfully
+ USB encryption disabled
+ Encryption error: %s
+ No USB device connected. Please connect your USB stick first.
+ Waiting for: %s
+ No access to content without USB key
diff --git a/i18n/src/main/res/values-zh-rCN/feat_playlist.xml b/i18n/src/main/res/values-zh-rCN/feat_playlist.xml
index 4ddc472b0..cd336d328 100644
--- a/i18n/src/main/res/values-zh-rCN/feat_playlist.xml
+++ b/i18n/src/main/res/values-zh-rCN/feat_playlist.xml
@@ -15,4 +15,7 @@
频道不存在保存到了(%s)回到顶部
-
\ No newline at end of file
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
+
diff --git a/i18n/src/main/res/values-zh-rCN/feat_setting.xml b/i18n/src/main/res/values-zh-rCN/feat_setting.xml
index 0e8f9bdb7..8b43a5dc7 100644
--- a/i18n/src/main/res/values-zh-rCN/feat_setting.xml
+++ b/i18n/src/main/res/values-zh-rCN/feat_setting.xml
@@ -126,4 +126,33 @@
EPG链接为空随机播放只播放已收藏的频道
-
\ No newline at end of file
+
+
+ Security
+ USB Encryption
+ Error: %s
+ Database Unlocked
+ Database Locked - Insert USB Key
+ Encryption Disabled
+ USB Device Connected
+ Enable USB Encryption
+ Connect USB Stick
+ Disable Encryption
+ ⚠️ WARNING: All playlists, channels, and VOD will only be accessible with this specific USB stick. If you lose the USB stick, you will permanently lose access to all content. There is no recovery option.
+ Enable USB Encryption?
+ This will encrypt your entire database with military-grade encryption. Only this USB stick will unlock your content. Without the USB stick, all data will be permanently inaccessible.\n\nThis action will:\n• Encrypt all playlists and channels\n• Require USB stick for app access\n• Cannot be recovered if USB is lost\n\nAre you sure?
+ Enable Encryption
+ Disable Encryption?
+ This will remove USB encryption and make your database accessible without the USB stick. Your existing data will remain, but will no longer be encrypted.
+ Disable
+ Cancel
+ Database Locked
+ Your database is encrypted and requires the USB encryption key to access.
+ Please insert your USB encryption key
+ USB encryption enabled successfully
+ USB encryption disabled
+ Encryption error: %s
+ No USB device connected. Please connect your USB stick first.
+ Waiting for: %s
+ No access to content without USB key
+
diff --git a/i18n/src/main/res/values/feat_playlist.xml b/i18n/src/main/res/values/feat_playlist.xml
index 3e734e23a..88099074d 100644
--- a/i18n/src/main/res/values/feat_playlist.xml
+++ b/i18n/src/main/res/values/feat_playlist.xml
@@ -13,4 +13,7 @@
channel is not existedsaved to (%s)scroll up
+ Web server started - Open browser to upload playlists
+ Web server stopped
+ Web server error: %s
\ No newline at end of file
diff --git a/i18n/src/main/res/values/feat_setting.xml b/i18n/src/main/res/values/feat_setting.xml
index 1a14a9bdf..fbc61da56 100644
--- a/i18n/src/main/res/values/feat_setting.xml
+++ b/i18n/src/main/res/values/feat_setting.xml
@@ -96,6 +96,18 @@
XtreamEmbyDropbox
+ WebDrop
+
+ Server Running
+ Server Stopped
+ Error: %s
+ Access URL
+ Copy URL
+ Start Web Server
+ Stop Web Server
+ Open the URL above in your browser to upload playlists from any device on your local network
+ URL copied to clipboard
+ Use the web interface to add playlists via WebDropaddressusername
@@ -130,4 +142,60 @@
you can then associate the playlist with the EPGplay randomly only from favourite
+
+
+ Security
+ USB Encryption
+ Error: %s
+ Database Unlocked
+ Database Locked - Insert USB Key
+ Encryption Disabled
+ USB Device Connected
+ Enable USB Encryption
+ Connect USB Stick
+ Disable Encryption
+ ⚠️ WARNING: All playlists, channels, and VOD will only be accessible with this specific USB stick. If you lose the USB stick, you will permanently lose access to all content. There is no recovery option.
+ Enable USB Encryption?
+ This will encrypt your entire database with military-grade encryption. Only this USB stick will unlock your content. Without the USB stick, all data will be permanently inaccessible.\n\nThis action will:\n• Encrypt all playlists and channels\n• Require USB stick for app access\n• Cannot be recovered if USB is lost\n\nAre you sure?
+ Enable Encryption
+ Disable Encryption?
+ This will remove USB encryption and make your database accessible without the USB stick. Your existing data will remain, but will no longer be encrypted.
+ Disable
+ Cancel
+ Database Locked
+ Your database is encrypted and requires the USB encryption key to access.
+ Please insert your USB encryption key
+ USB encryption enabled successfully
+ USB encryption disabled
+ Encryption error: %s
+ No USB device connected. Please connect your USB stick first.
+ Waiting for: %s
+ No access to content without USB key
+ USB Device
+ No device
+ Status
+ Request Permission
+ ⚠️ WARNING: Without the USB stick, you will permanently lose all data!
+
+
+ PIN Encryption
+ Enable PIN Encryption
+ Disable Encryption
+ Change PIN
+ Encryption Enabled
+ Encryption Disabled
+ Enter 6-Digit PIN
+ Confirm PIN
+ Set Up PIN Encryption
+ Create a 6-digit PIN to encrypt your database
+ Enter PIN to Unlock
+ PIN encryption enabled successfully
+ PIN encryption disabled successfully
+ Encryption error: %s
+ PIN must be exactly 6 digits
+ Incorrect PIN. Please try again.
+ Database unlocked successfully
+ ⚠️ Your database will be encrypted with AES-256. Only the correct 6-digit PIN can unlock your content. If you forget your PIN, you must uninstall the app to reset (all data will be lost).
+ ⚠️ WARNING: Forgetting your PIN means permanent data loss!
+ PINs do not match. Please try again.
\ No newline at end of file