- Add Media3 dependencies (ExoPlayer, HLS, MediaSession, UI) for audio streaming - Implement AudioPlayerService as MediaSessionService with foreground playback support - Create AudioHandler for playback control and AudioMedia domain model with AudioContentType enum - Add MiniPlayerBar and FullPlayerSheet UI components with play/pause, seek, and navigation controls - Implement AudioClassifier with strict priority
288 lines
12 KiB
Kotlin
288 lines
12 KiB
Kotlin
package com.shaarit
|
|
|
|
import android.net.Uri
|
|
import android.os.Bundle
|
|
import androidx.activity.compose.setContent
|
|
import androidx.activity.enableEdgeToEdge
|
|
import androidx.fragment.app.FragmentActivity
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|
import androidx.core.view.WindowCompat
|
|
import com.shaarit.core.storage.BiometricAuthManager
|
|
import com.shaarit.core.storage.SecurityPreferences
|
|
import com.shaarit.presentation.auth.LockScreen
|
|
import com.shaarit.domain.model.AudioContentType
|
|
import com.shaarit.domain.model.AudioMedia
|
|
import com.shaarit.presentation.audio.AudioHandler
|
|
import com.shaarit.presentation.audio.FullPlayerSheet
|
|
import com.shaarit.presentation.audio.MiniPlayerBar
|
|
import com.shaarit.presentation.nav.AppNavGraph
|
|
import com.shaarit.ui.theme.ShaarItTheme
|
|
import com.shaarit.ui.theme.ThemePreferences
|
|
import dagger.hilt.android.AndroidEntryPoint
|
|
import java.io.BufferedReader
|
|
import java.io.InputStreamReader
|
|
import javax.inject.Inject
|
|
|
|
@AndroidEntryPoint
|
|
class MainActivity : FragmentActivity() {
|
|
|
|
@Inject lateinit var themePreferences: ThemePreferences
|
|
@Inject lateinit var tokenManager: com.shaarit.core.storage.TokenManager
|
|
@Inject lateinit var securityPreferences: SecurityPreferences
|
|
@Inject lateinit var biometricAuthManager: BiometricAuthManager
|
|
@Inject lateinit var audioHandler: AudioHandler
|
|
|
|
// Start as authenticated — lock only triggers after app goes to background
|
|
private var isAuthenticated by mutableStateOf(true)
|
|
private var lastBackgroundTime: Long = 0L
|
|
private var hasBeenBackgrounded = false
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
// Install splash screen before super.onCreate
|
|
installSplashScreen()
|
|
|
|
super.onCreate(savedInstanceState)
|
|
|
|
// Enable edge-to-edge mode for proper keyboard (IME) insets detection
|
|
enableEdgeToEdge()
|
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
|
|
// On cold start, require auth if biometric + requireOnStartup are enabled
|
|
if (securityPreferences.isBiometricEnabled.value && securityPreferences.requireOnStartup.value) {
|
|
isAuthenticated = false
|
|
}
|
|
|
|
// Parse intent ONCE, before composition (avoids re-execution on recomposition)
|
|
val shareData = parseShareIntent(intent)
|
|
|
|
// Check if user is already logged in to skip login screen for share intents
|
|
val hasValidToken = tokenManager.getToken() != null && tokenManager.getBaseUrl() != null
|
|
val startDestination = if (hasValidToken) {
|
|
com.shaarit.presentation.nav.Screen.Feed.createRoute()
|
|
} else {
|
|
com.shaarit.presentation.nav.Screen.Login.route
|
|
}
|
|
|
|
setContent {
|
|
val currentTheme by themePreferences.currentTheme.collectAsState()
|
|
val currentThemeMode by themePreferences.themeMode.collectAsState()
|
|
val biometricEnabled by securityPreferences.isBiometricEnabled.collectAsState()
|
|
|
|
val needsAuth = biometricEnabled && !isAuthenticated
|
|
|
|
ShaarItTheme(appTheme = currentTheme, themeMode = currentThemeMode) {
|
|
Surface(
|
|
modifier = Modifier.fillMaxSize(),
|
|
color = MaterialTheme.colorScheme.background
|
|
) {
|
|
if (needsAuth) {
|
|
LockScreen(
|
|
activity = this@MainActivity,
|
|
biometricAuthManager = biometricAuthManager,
|
|
onAuthenticated = { isAuthenticated = true }
|
|
)
|
|
} else {
|
|
val playerState by audioHandler.playerState.collectAsState()
|
|
var showFullPlayer by androidx.compose.runtime.remember { mutableStateOf(false) }
|
|
|
|
Box(modifier = Modifier.fillMaxSize()) {
|
|
AppNavGraph(
|
|
startDestination = startDestination,
|
|
shareUrl = shareData.url,
|
|
shareTitle = shareData.title,
|
|
shareDescription = shareData.description,
|
|
shareTags = shareData.tags,
|
|
isFileShare = shareData.isFileShare,
|
|
initialDeepLink = shareData.deepLink,
|
|
onPlayAudio = { link ->
|
|
val contentType = when {
|
|
link.tags.any { it.equals("radio", true) } -> AudioContentType.RADIO
|
|
link.tags.any { it.equals("podcast", true) } -> AudioContentType.PODCAST
|
|
else -> AudioContentType.MUSIC
|
|
}
|
|
audioHandler.play(
|
|
AudioMedia(
|
|
id = link.id,
|
|
title = link.title,
|
|
url = link.url,
|
|
artist = link.siteName,
|
|
thumbnailUrl = link.thumbnailUrl,
|
|
contentType = contentType
|
|
)
|
|
)
|
|
}
|
|
)
|
|
|
|
MiniPlayerBar(
|
|
playerState = playerState,
|
|
onTogglePlayPause = { audioHandler.togglePlayPause() },
|
|
onClose = { audioHandler.stop() },
|
|
onClick = { showFullPlayer = true },
|
|
modifier = Modifier
|
|
.align(Alignment.BottomCenter)
|
|
.navigationBarsPadding()
|
|
)
|
|
}
|
|
|
|
if (showFullPlayer && playerState.isActive) {
|
|
FullPlayerSheet(
|
|
playerState = playerState,
|
|
onDismiss = { showFullPlayer = false },
|
|
onTogglePlayPause = { audioHandler.togglePlayPause() },
|
|
onSeek = { fraction -> audioHandler.seekToFraction(fraction) },
|
|
onNext = { audioHandler.next() },
|
|
onPrevious = { audioHandler.previous() },
|
|
onClose = {
|
|
audioHandler.stop()
|
|
showFullPlayer = false
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onStart() {
|
|
super.onStart()
|
|
audioHandler.connect()
|
|
}
|
|
|
|
override fun onStop() {
|
|
super.onStop()
|
|
audioHandler.disconnect()
|
|
lastBackgroundTime = System.currentTimeMillis()
|
|
hasBeenBackgrounded = true
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
if (!hasBeenBackgrounded) return
|
|
if (securityPreferences.isBiometricEnabled.value) {
|
|
val elapsed = System.currentTimeMillis() - lastBackgroundTime
|
|
val timeout = securityPreferences.lockTimeout.value.delayMs
|
|
val shouldLock = if (securityPreferences.requireOnResume.value) {
|
|
lastBackgroundTime > 0 && elapsed > timeout
|
|
} else {
|
|
false
|
|
}
|
|
if (shouldLock) {
|
|
isAuthenticated = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private data class ShareData(
|
|
val url: String? = null,
|
|
val title: String? = null,
|
|
val description: String? = null,
|
|
val tags: List<String>? = null,
|
|
val isFileShare: Boolean = false,
|
|
val deepLink: String? = null
|
|
)
|
|
|
|
private fun parseShareIntent(intent: android.content.Intent?): ShareData {
|
|
if (intent == null) return ShareData()
|
|
|
|
var shareUrl: String? = null
|
|
var shareTitle: String? = null
|
|
var shareDescription: String? = null
|
|
var shareTags: List<String>? = null
|
|
var isFileShare = false
|
|
var deepLink: String? = null
|
|
|
|
// Handle share intent
|
|
if (intent.action == android.content.Intent.ACTION_SEND) {
|
|
val mimeType = intent.type ?: ""
|
|
|
|
// Check if this is a file share (markdown or text file)
|
|
val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM)
|
|
|
|
if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {
|
|
isFileShare = true
|
|
val fileInfo = readFileContent(fileUri)
|
|
shareTitle = fileInfo.first
|
|
shareDescription = fileInfo.second
|
|
shareTags = listOf("note", "fichier")
|
|
shareUrl = null
|
|
} else if (mimeType == "text/plain") {
|
|
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
|
|
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
|
|
}
|
|
}
|
|
|
|
// Handle deep links from App Shortcuts
|
|
intent.data?.let { uri ->
|
|
if (uri.scheme == "shaarit") {
|
|
deepLink = uri.toString()
|
|
}
|
|
}
|
|
|
|
return ShareData(shareUrl, shareTitle, shareDescription, shareTags, isFileShare, deepLink)
|
|
}
|
|
|
|
/**
|
|
* Checks if the shared content is a text or markdown file
|
|
*/
|
|
private fun isTextOrMarkdownFile(mimeType: String, uri: Uri): Boolean {
|
|
val isTextMime = mimeType.startsWith("text/") || mimeType == "application/octet-stream"
|
|
val filename = getFileName(uri)?.lowercase() ?: ""
|
|
val isMarkdownOrText = filename.endsWith(".md") ||
|
|
filename.endsWith(".markdown") ||
|
|
filename.endsWith(".txt") ||
|
|
filename.endsWith(".text")
|
|
return isTextMime && (isMarkdownOrText || mimeType.contains("markdown"))
|
|
}
|
|
|
|
/**
|
|
* Reads the content of a file and returns (filename without extension, content)
|
|
*/
|
|
private fun readFileContent(uri: Uri): Pair<String, String> {
|
|
val filename = getFileName(uri) ?: "Note"
|
|
val filenameWithoutExtension = filename.substringBeforeLast(".")
|
|
|
|
val content = try {
|
|
contentResolver.openInputStream(uri)?.use { inputStream ->
|
|
BufferedReader(InputStreamReader(inputStream)).use { reader ->
|
|
reader.readText()
|
|
}
|
|
} ?: ""
|
|
} catch (e: Exception) {
|
|
""
|
|
}
|
|
|
|
return Pair(filenameWithoutExtension, content)
|
|
}
|
|
|
|
/**
|
|
* Gets the filename from a Uri
|
|
*/
|
|
private fun getFileName(uri: Uri): String? {
|
|
return try {
|
|
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
|
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
|
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
|
cursor.getString(nameIndex)
|
|
} else {
|
|
uri.lastPathSegment
|
|
}
|
|
} ?: uri.lastPathSegment
|
|
} catch (e: Exception) {
|
|
uri.lastPathSegment
|
|
}
|
|
}
|
|
}
|