Skip to content

Commit 52cc3bb

Browse files
optiixclaude
andcommitted
Add WebDrop file upload feature and PIN-based database encryption with enterprise-level improvements
This commit introduces two major features along with critical bug fixes for production stability: ## 1. WebDrop Feature - Embedded Web Server for Playlist Management - Implemented Ktor-based embedded web server for remote playlist uploads - Web interface accessible at http://[device-ip]:8080 for easy file uploads - Three import methods supported: * File upload (M3U/M3U8 files up to 400MB) * URL import (remote M3U playlist URLs) * Xtream Codes API import (username/password authentication) - Enhanced upload.html with modern responsive UI - Real-time playlist status endpoint at /status - Automatic playlist parsing and channel import on upload ## 2. PIN-Based Database Encryption with SQLCipher - Full Room database encryption using SQLCipher 4.5.x - 6-digit PIN authentication system with unlock screen - Secure key derivation using Android Keystore (AES-256-GCM) - PBKDF2 with SHA-256 for PIN-to-key conversion (100,000 iterations) - UnlockManager for app-wide authentication state management - PIN unlock screen blocks app access until authentication succeeds - Encryption status dashboard showing health metrics - Database migration support for upgrading unencrypted to encrypted DBs - USB key backup/restore for encrypted databases with integrity verification ## 3. Enterprise-Level Network Timeout Fixes - Fixed SocketTimeoutException for large M3U playlist downloads (40MB+) - Configured OkHttp timeouts for slow M3U servers: * connectTimeout: 30 seconds (slow networks) * readTimeout: 90 seconds (critical for slow servers) * writeTimeout: 30 seconds (sufficient for GET requests) * callTimeout: 5 minutes (total max time for large downloads) - Comprehensive error handling with proper HTTP status codes (408, 503, 500) - Detailed error logging with timing metrics for debugging - Streaming-aware timeout configuration (readTimeout resets per data chunk) ## 4. Android TV Compatibility Fixes - Fixed ActivityNotFoundException crash on TV startup - Wrapped MANAGE_ALL_FILES_ACCESS_PERMISSION request in try-catch - TV devices skip this permission gracefully (not supported on Android TV) - App can still access cache/data directories without this permission ## 5. File Upload Path Fix - Fixed WebServerRepositoryImpl.kt file path handling - Changed from absolute path to proper file:// URI scheme - Ensures uploaded files are correctly recognized by playlist parser ## 6. Enhanced Logging and Debugging - Added comprehensive logging to M3UParserImpl for stream debugging - Enhanced PlaylistRepositoryImpl logging for input stream tracking - OkHttp interceptor logs request/response timing and payload sizes - Timber-based structured logging with component-specific tags ## Files Changed: - app/tv/MainActivity.kt: Added PIN unlock authentication gate - data/api/ApiModule.kt: Enterprise-level timeout configuration - data/database/DatabaseModule.kt: SQLCipher integration - data/repository/webserver/WebServerRepositoryImpl.kt: File URI fix - business/setting/UnlockManager.kt: Authentication state manager - data/security/PINKeyManager.kt: Secure key management with Android Keystore - app/tv/screens/security/PINUnlockScreen.kt: 6-digit PIN input UI - app/tv/screens/security/EncryptionStatusDashboard.kt: Health monitoring UI - data/repository/usbkey/USBKeyRepositoryImpl.kt: USB backup/restore ## Testing: - Verified URL import works with 44MB M3U playlists from slow servers - Verified file upload via WebDrop web interface - Verified TV app launches without permission crashes - Verified PIN encryption and unlock flow - Tested on Android TV emulator (API 33) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 22d8070 commit 52cc3bb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+4970
-290
lines changed

app/tv/src/main/AndroidManifest.xml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,30 @@
1212
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1313
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
1414

15+
<!-- USB Storage permissions for encryption and log export -->
16+
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
17+
android:maxSdkVersion="32" />
18+
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
19+
android:maxSdkVersion="32" />
20+
<uses-permission android:name="android.permission.READ_LOGS"
21+
tools:ignore="ProtectedPermissions" />
22+
23+
<!-- For Android 11+ (API 30+) manage all files access -->
24+
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
25+
tools:ignore="ScopedStorage" />
26+
1527
<uses-feature
1628
android:name="android.software.leanback"
1729
android:required="true" />
1830
<uses-feature
1931
android:name="android.hardware.touchscreen"
2032
android:required="false" />
2133

34+
<!-- Enable mouse/pointer support for emulator development -->
35+
<uses-feature
36+
android:name="android.hardware.type.pc"
37+
android:required="false" />
38+
2239
<!-- USB Host permissions for encryption -->
2340
<uses-feature
2441
android:name="android.hardware.usb.host"

app/tv/src/main/java/com/m3u/tv/M3UApplication.kt

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,33 @@ package com.m3u.tv
22

33
import android.app.Application
44
import androidx.hilt.work.HiltWorkerFactory
5+
import androidx.lifecycle.ProcessLifecycleOwner
6+
import androidx.lifecycle.lifecycleScope
57
import androidx.work.Configuration
8+
import com.m3u.core.architecture.preferences.PreferencesKeys
9+
import com.m3u.core.architecture.preferences.get
10+
import com.m3u.core.architecture.preferences.set
11+
import com.m3u.core.architecture.preferences.settings
12+
import com.m3u.data.repository.usbkey.USBKeyRepository
13+
import com.m3u.data.security.KeyVerificationManager
614
import dagger.hilt.android.HiltAndroidApp
15+
import kotlinx.coroutines.launch
16+
import timber.log.Timber
717
import javax.inject.Inject
818

919
@HiltAndroidApp
1020
class M3UApplication : Application(), Configuration.Provider {
1121
@Inject
1222
lateinit var workerFactory: HiltWorkerFactory
1323

24+
@Inject
25+
lateinit var usbKeyRepository: USBKeyRepository
26+
27+
@Inject
28+
lateinit var keyVerificationManager: KeyVerificationManager
29+
30+
private val timber = Timber.tag("M3UApplication")
31+
1432
override val workManagerConfiguration: Configuration by lazy {
1533
Configuration.Builder()
1634
.setWorkerFactory(workerFactory)
@@ -19,5 +37,84 @@ class M3UApplication : Application(), Configuration.Provider {
1937

2038
override fun onCreate() {
2139
super.onCreate()
40+
41+
// TODO: REMOVE BEFORE PRODUCTION - Debug logging enabled
42+
if (BuildConfig.DEBUG) {
43+
Timber.plant(Timber.DebugTree())
44+
Timber.d("============================================")
45+
Timber.d("M3U TV APPLICATION STARTING (DEBUG MODE)")
46+
Timber.d("============================================")
47+
}
48+
49+
// Launch startup verification asynchronously
50+
// Note: performStartupVerification() will check for pending encryption
51+
// and perform it BEFORE any database access if needed
52+
ProcessLifecycleOwner.get().lifecycleScope.launch {
53+
performStartupVerification()
54+
}
55+
}
56+
57+
private suspend fun performStartupVerification() {
58+
try {
59+
timber.d("=== STARTUP VERIFICATION ===")
60+
61+
// Check if encryption is pending (app was killed to close database)
62+
val inProgress = settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS]
63+
val lastOperation = settings[PreferencesKeys.USB_ENCRYPTION_LAST_OPERATION] ?: ""
64+
65+
if (inProgress == true && lastOperation == "ENCRYPTION_PENDING") {
66+
timber.d("!!! ENCRYPTION PENDING - Performing encryption with database closed !!!")
67+
68+
// Now the database is CLOSED because the app was killed
69+
// We can safely perform the encryption
70+
val result = usbKeyRepository.performPendingEncryption()
71+
72+
if (result.isSuccess) {
73+
timber.d("✓ Encryption completed successfully!")
74+
// Clear the pending flag
75+
settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS] = false
76+
settings[PreferencesKeys.USB_ENCRYPTION_LAST_OPERATION] = "ENCRYPTION_COMPLETED"
77+
settings[PreferencesKeys.USB_ENCRYPTION_ENABLED] = true
78+
79+
timber.d("Restarting app to open encrypted database...")
80+
// Restart ONE MORE TIME to open the encrypted database
81+
android.os.Process.killProcess(android.os.Process.myPid())
82+
} else {
83+
timber.e("✗ Encryption failed: ${result.exceptionOrNull()?.message}")
84+
// Clear the pending flag so we don't retry
85+
settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS] = false
86+
settings[PreferencesKeys.USB_ENCRYPTION_LAST_OPERATION] = "ENCRYPTION_FAILED"
87+
}
88+
return // Don't continue with normal startup
89+
} else if (inProgress == true) {
90+
// Some other operation was in progress - just clear the flag
91+
timber.d("Clearing in-progress flag from previous operation: $lastOperation")
92+
settings[PreferencesKeys.USB_ENCRYPTION_IN_PROGRESS] = false
93+
}
94+
95+
// Verify USB key on startup if encryption is enabled
96+
if (usbKeyRepository.isEncryptionEnabled()) {
97+
timber.d("Encryption is enabled - verifying USB key on startup...")
98+
99+
usbKeyRepository.getEncryptionKey()?.let { key ->
100+
timber.d("Found encryption key - verifying fingerprint...")
101+
val verified = keyVerificationManager.verifyKey(key)
102+
103+
if (verified) {
104+
timber.d("USB key verified successfully on startup")
105+
} else {
106+
timber.w("USB key verification failed on startup")
107+
}
108+
} ?: run {
109+
timber.w("No encryption key found on startup - USB may be disconnected")
110+
}
111+
} else {
112+
timber.d("Encryption is not enabled - skipping startup verification")
113+
}
114+
115+
timber.d("=== STARTUP VERIFICATION COMPLETE ===")
116+
} catch (e: Exception) {
117+
timber.e(e, "Error during startup verification")
118+
}
22119
}
23120
}

app/tv/src/main/java/com/m3u/tv/MainActivity.kt

Lines changed: 209 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,241 @@
11
package com.m3u.tv
22

3+
import android.Manifest
4+
import android.content.Intent
5+
import android.content.pm.PackageManager
6+
import android.net.Uri
7+
import android.os.Build
38
import android.os.Bundle
9+
import android.os.Environment
10+
import android.provider.Settings
11+
import android.view.KeyEvent
412
import androidx.activity.ComponentActivity
513
import androidx.activity.compose.setContent
14+
import androidx.activity.result.contract.ActivityResultContracts
615
import androidx.compose.foundation.background
716
import androidx.compose.foundation.layout.Box
817
import androidx.compose.runtime.CompositionLocalProvider
18+
import androidx.compose.runtime.getValue
19+
import androidx.compose.runtime.mutableStateOf
20+
import androidx.compose.runtime.remember
21+
import androidx.compose.runtime.setValue
922
import androidx.compose.ui.Modifier
23+
import androidx.core.content.ContextCompat
24+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
25+
import androidx.lifecycle.lifecycleScope
1026
import androidx.tv.material3.LocalContentColor
1127
import androidx.tv.material3.MaterialTheme
1228
import androidx.tv.material3.darkColorScheme
29+
import com.m3u.business.setting.UnlockManager
30+
import com.m3u.tv.screens.common.ErrorScreen
31+
import com.m3u.tv.screens.common.LoadingScreen
32+
import com.m3u.tv.screens.security.PINUnlockScreen
1333
import com.m3u.tv.utils.Helper
1434
import com.m3u.tv.utils.LocalHelper
1535
import dagger.hilt.android.AndroidEntryPoint
36+
import kotlinx.coroutines.launch
37+
import timber.log.Timber
38+
import javax.inject.Inject
1639

1740
@AndroidEntryPoint
1841
class MainActivity : ComponentActivity() {
42+
@Inject
43+
lateinit var unlockManager: UnlockManager
44+
1945
private val helper = Helper(this)
46+
47+
private val timber = Timber.tag("MainActivity")
48+
49+
// Storage permission request launcher
50+
private val storagePermissionLauncher = registerForActivityResult(
51+
ActivityResultContracts.RequestMultiplePermissions()
52+
) { permissions ->
53+
timber.d("Storage permissions result: $permissions")
54+
val allGranted = permissions.values.all { it }
55+
if (allGranted) {
56+
timber.d("✓ All storage permissions granted")
57+
} else {
58+
timber.w("⚠ Some storage permissions denied: ${permissions.filter { !it.value }}")
59+
}
60+
}
61+
62+
// Manage all files permission launcher for Android 11+
63+
private val manageStorageLauncher = registerForActivityResult(
64+
ActivityResultContracts.StartActivityForResult()
65+
) { result ->
66+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
67+
if (Environment.isExternalStorageManager()) {
68+
timber.d("✓ All files access granted")
69+
} else {
70+
timber.w("⚠ All files access denied")
71+
}
72+
}
73+
}
74+
2075
override fun onCreate(savedInstanceState: Bundle?) {
2176
super.onCreate(savedInstanceState)
77+
78+
timber.d("=== MAIN ACTIVITY ONCREATE ===")
79+
80+
// Request storage permissions on first launch
81+
requestStoragePermissions()
82+
83+
// Initialize unlock manager BEFORE setContent
84+
// This checks if PIN encryption is enabled and sets initial lock state
85+
lifecycleScope.launch {
86+
timber.d("Initializing unlock manager...")
87+
unlockManager.initialize()
88+
timber.d("Unlock manager initialized")
89+
}
90+
2291
setContent {
2392
MaterialTheme(
2493
colorScheme = darkColorScheme()
2594
) {
2695
Box(Modifier.background(MaterialTheme.colorScheme.background)) {
27-
CompositionLocalProvider(
28-
LocalHelper provides helper,
29-
LocalContentColor provides MaterialTheme.colorScheme.onBackground
30-
) {
31-
App {
32-
onBackPressedDispatcher.onBackPressed()
96+
// ========================================
97+
// AUTHENTICATION GATE
98+
// ========================================
99+
// Observe the lock state and show different screens based on it
100+
val lockState by unlockManager.lockState.collectAsStateWithLifecycle()
101+
102+
timber.d("Current lock state: $lockState")
103+
104+
when (lockState) {
105+
is UnlockManager.LockState.Initializing -> {
106+
// Show loading while checking encryption status
107+
timber.d("Showing loading screen")
108+
LoadingScreen(message = "Initializing...")
109+
}
110+
111+
is UnlockManager.LockState.Locked -> {
112+
// Database is encrypted - show PIN unlock screen
113+
// This BLOCKS access to the main app
114+
timber.d("Showing PIN unlock screen")
115+
116+
var errorMessage by remember { mutableStateOf<String?>(null) }
117+
118+
PINUnlockScreen(
119+
onPINEntered = { pin ->
120+
timber.d("PIN entered in unlock screen, attempting unlock...")
121+
lifecycleScope.launch {
122+
val result = unlockManager.attemptUnlock(pin)
123+
if (result.isFailure) {
124+
timber.w("Unlock failed: ${result.exceptionOrNull()?.message}")
125+
errorMessage = "Incorrect PIN. Please try again."
126+
} else {
127+
timber.d("✓ Unlock successful!")
128+
errorMessage = null
129+
// State will automatically change to Unlocked
130+
}
131+
}
132+
},
133+
errorMessage = errorMessage
134+
)
135+
}
136+
137+
is UnlockManager.LockState.NoEncryption,
138+
is UnlockManager.LockState.Unlocked -> {
139+
// No encryption OR successfully unlocked - proceed to main app
140+
timber.d("Proceeding to main app (unlocked or no encryption)")
141+
142+
CompositionLocalProvider(
143+
LocalHelper provides helper,
144+
LocalContentColor provides MaterialTheme.colorScheme.onBackground
145+
) {
146+
App {
147+
onBackPressedDispatcher.onBackPressed()
148+
}
149+
}
33150
}
151+
152+
is UnlockManager.LockState.Error -> {
153+
// Error during initialization
154+
val error = lockState as UnlockManager.LockState.Error
155+
timber.e("Error state: ${error.message}")
156+
157+
ErrorScreen(
158+
message = error.message,
159+
onRetry = {
160+
lifecycleScope.launch {
161+
unlockManager.initialize()
162+
}
163+
}
164+
)
165+
}
166+
}
167+
}
168+
}
169+
}
170+
171+
timber.d("=== MAIN ACTIVITY SETUP COMPLETE ===")
172+
}
173+
174+
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
175+
// Handle DELETE and PAGE_DOWN as back navigation
176+
return when (keyCode) {
177+
KeyEvent.KEYCODE_DEL,
178+
KeyEvent.KEYCODE_PAGE_DOWN -> {
179+
timber.d("Back navigation triggered by key: $keyCode")
180+
onBackPressedDispatcher.onBackPressed()
181+
true
182+
}
183+
else -> super.onKeyDown(keyCode, event)
184+
}
185+
}
186+
187+
private fun requestStoragePermissions() {
188+
timber.d("=== STORAGE PERMISSION CHECK ===")
189+
timber.d("Android SDK Version: ${Build.VERSION.SDK_INT}")
190+
191+
when {
192+
// Android 11+ (API 30+) requires special "All files access" permission
193+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
194+
timber.d("Android 11+ detected - checking All Files Access")
195+
if (!Environment.isExternalStorageManager()) {
196+
timber.d("All Files Access not granted - attempting to open settings")
197+
try {
198+
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
199+
data = Uri.parse("package:$packageName")
200+
}
201+
manageStorageLauncher.launch(intent)
202+
timber.d("✓ Launched settings for All Files Access")
203+
} catch (e: Exception) {
204+
timber.w(e, "Unable to request All Files Access on this device (TV doesn't support this)")
205+
// On Android TV, this permission doesn't exist - just skip it
206+
// The app can still access its own cache/data directories without this permission
34207
}
208+
} else {
209+
timber.d("✓ All Files Access already granted")
210+
}
211+
}
212+
// Android 6-10 (API 23-29) requires runtime permissions
213+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
214+
timber.d("Android 6-10 detected - checking storage permissions")
215+
val permissionsToRequest = mutableListOf<String>()
216+
217+
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
218+
!= PackageManager.PERMISSION_GRANTED) {
219+
permissionsToRequest.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
220+
timber.d("WRITE_EXTERNAL_STORAGE not granted")
221+
}
222+
223+
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
224+
!= PackageManager.PERMISSION_GRANTED) {
225+
permissionsToRequest.add(Manifest.permission.READ_EXTERNAL_STORAGE)
226+
timber.d("READ_EXTERNAL_STORAGE not granted")
35227
}
228+
229+
if (permissionsToRequest.isNotEmpty()) {
230+
timber.d("Requesting ${permissionsToRequest.size} storage permissions")
231+
storagePermissionLauncher.launch(permissionsToRequest.toTypedArray())
232+
} else {
233+
timber.d("✓ All storage permissions already granted")
234+
}
235+
}
236+
// Android 5 and below (API <23) - permissions granted at install time
237+
else -> {
238+
timber.d("Android 5 or below - permissions granted at install time")
36239
}
37240
}
38241
}

0 commit comments

Comments
 (0)