Sharrit/app/src/main/java/com/shaarit/MainActivity.kt
Bruno Charest 2b8134f5b7 feat: Add audio playback system with Media3 and comprehensive audio content classification
- 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
2026-02-11 11:23:22 -05:00

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
}
}
}