Generate qrCode using DeepLink

This commit is contained in:
hasinarak3@gmail.com 2026-04-20 11:49:13 +03:00
parent 1d35b9227d
commit cf4046e202
12 changed files with 248 additions and 28 deletions

View file

@ -21,6 +21,12 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="feufaro" android:host="song" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"

View file

@ -46,7 +46,14 @@ class AndroidFileRepository(private val context: Context) : FileRepository {
}
else -> {
File(filePath).readLines()
val file = File(filePath)
println("AndroidRead: Tentative sur ${file.absolutePath}")
println("AndroidRead: Existe=${file.exists()} | Lisible=${file.canRead()}")
if (!file.exists()) {
throw IOException("Le fichier n'existe pas : $filePath")
}
file.bufferedReader(Charsets.UTF_8).useLines { it.toList() }
}
}
} catch (e: IOException) {
@ -77,32 +84,37 @@ class AndroidFileRepository(private val context: Context) : FileRepository {
if (!file.exists()) throw IOException("File not found: ${file.absolutePath}")
file.readBytes()
}*/
override suspend fun saveLocalFile(filePath: String, data: ByteArray) {
val cleanPath = if (filePath.contains("/storage/emulated/0/")) {
filePath.substring(filePath.indexOf("/storage/emulated/0/"))
override suspend fun saveLocalFile(filePath: String, data: ByteArray) = withContext(Dispatchers.IO) {
val file = if (filePath.contains("/") ) {
File(filePath)
} else {
filePath
File(getAppPublicFolder(), filePath)
}
val file = File(cleanPath)
try {
file.parentFile?.mkdirs()
file.writeBytes(data)
println("Fichier sauvegardé avec succès dans : ${file.absolutePath}")
println("Fichier sauvegardé dans : ${file.absolutePath}")
if (file.extension.lowercase() == "pdf") {
openPdfFile(context = context, filePath = file.absolutePath)
}
} catch (e: Exception) {
e.printStackTrace()
throw e
}
}
// Internal files
/*override fun getFileName(shortName: String): String {
return File(context.filesDir, shortName).absolutePath
}*/
private fun getAppPublicFolder(): File {
val publicDocsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val appDir = File(publicDocsDir, "Feufaro")
if (!appDir.exists()) {
appDir.mkdirs()
}
return appDir
}
override fun pickSavePath(defaultName: String): String? {
val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
return File(downloadDir, defaultName).absolutePath
return File(getAppPublicFolder(), defaultName).absolutePath
}
suspend fun openPdfFile(context: Context, filePath: String) {
val file = File(filePath)
@ -115,10 +127,9 @@ class AndroidFileRepository(private val context: Context) : FileRepository {
file
)
// uvrir un PDF
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/pdf")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // Important !
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}

View file

@ -1,7 +1,13 @@
package mg.dot.feufaro
import android.Manifest
import android.app.ComponentCaller
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.util.Base64
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@ -13,6 +19,8 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import org.koin.androidx.compose.KoinAndroidContext
import java.io.File
import java.util.zip.GZIPInputStream
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -26,6 +34,7 @@ class MainActivity : ComponentActivity() {
setFilePickerActivity(this)
WindowCompat.setDecorFitsSystemWindows(window, false)
hideSystemBar()
intent?.let { handleDeepLinkIntent(it) }
setContent {
@ -34,6 +43,93 @@ class MainActivity : ComponentActivity() {
}
}
}
@Suppress("OVERRIDE_DEPRECATION")
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleDeepLinkIntent(intent)
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
super.onNewIntent(intent, caller)
setIntent(intent)
handleDeepLinkIntent(intent)
}
private fun decodeAndDecompress(base64Data: String): String {
return try {
val compressedBytes = Base64.decode(base64Data, Base64.URL_SAFE)
java.util.zip.GZIPInputStream(compressedBytes.inputStream()).bufferedReader(Charsets.UTF_8).use {
it.readText()
}
} catch (e: Exception) {
println("Decompression Error: ${e.message}")
base64Data
}
}
private fun handleDeepLinkIntent(intent: Intent) {
println("DeepLink: intent reçu = ${intent.data}")
val data: Uri = intent.data ?: run {
println("DeepLink: data est null")
return
}
println("DeepLink: scheme=${data.scheme} host=${data.host}")
if (data.scheme != "feufaro" || data.host != "song") {
println("DeepLink: scheme/host incorrect")
return
}
val encodedData = data.getQueryParameter("file") ?: run {
println("DeepLink: paramètre 'file' introuvable")
return
}
val rawContent = decodeAndDecompress(encodedData)
println("DeepLink: rawContent = $rawContent")
val fileName = rawContent
.split("|")
.firstOrNull { it.startsWith("t:") }
?.removePrefix("t:")
?.trim()
println("DeepLink: fileName extrait = $fileName")
if (fileName.isNullOrBlank()) {
println("DeepLink: fileName vide ou null")
return
}
val docsDir = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOCUMENTS
)
println("DeepLink: docsDir = ${docsDir.absolutePath}, exists=${docsDir.exists()}")
val appDir = File(docsDir, "Feufaro")
appDir.mkdirs()
// docsDir.mkdirs()
val file = File(appDir, "$fileName.txt")
try {
file.writeText(rawContent)
println("DeepLink: fichier créé → ${file.absolutePath}")
DeepLinkHandler.hasConsumedDeepLink = true
DeepLinkHandler.handleSongContent(rawContent, file.absolutePath)
} catch (e: Exception) {
println("DeepLink: ERREUR écriture fichier → ${e.message}")
}
}
private fun extractFileName(content: String): String? {
return content
.split("|")
.firstOrNull { it.startsWith("H:") }
?.removePrefix("H:")
?.trim()
}
private fun hideSystemBar() {
// Pour les versions d'Android plus récentes (API 30+)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="external_files" path="." />
<files-path name="internal_files" path="." />
<external-path name="external_files" path="Documents/Feufaro" />
</paths>

View file

@ -43,6 +43,10 @@ fun App() {
LaunchedEffect(currentDisplayConfig) {
if (currentDisplayConfig.playlist.isNotEmpty()) {
if (DeepLinkHandler.hasConsumedDeepLink) {
sharedScreenModel.setPlaylist(currentDisplayConfig.playlist)
} else {
if(sharedScreenModel.activeFilePath.value.isEmpty()) {
try {
sharedScreenModel.setPlaylist(currentDisplayConfig.playlist)
solfaScreenModel.reload()
@ -51,6 +55,8 @@ fun App() {
}
}
}
}
}
val musicXML: MusicXML = koinInject()

View file

@ -22,6 +22,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -45,6 +47,7 @@ import mg.dot.feufaro.ui.MainScreenWithDrawer
import kotlin.math.roundToInt
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.ui.MyVerticalScrollbar
@ -56,7 +59,6 @@ object ScreenSolfa : Screen {
override fun Content() {
val solfaScreenModel = koinScreenModel<SolfaScreenModel>()
var menuPosition by remember { mutableStateOf(Offset.Zero) }
var showContextualMenu by remember { mutableStateOf(false) }
var gridWidthPx by rememberSaveable { mutableStateOf(0) }
val sharedScreenModel = koinScreenModel<SharedScreenModel>()
val tuoList by sharedScreenModel.tuoList.collectAsState()
@ -69,7 +71,20 @@ object ScreenSolfa : Screen {
var isScanning by remember { mutableStateOf(false) }
var qrCodeResult by remember { mutableStateOf("Aucun code scanné") }
LaunchedEffect(Unit) {
DeepLinkHandler.onSongReceived = { content, filePath ->
println("2DeepLink: LaunchedEffect reçoit → $filePath")
sharedScreenModel.reset()
solfaScreenModel.loadFromFile(filePath)
}
// DeepLinkHandler.consumePending("")
}
DisposableEffect(Unit) {
onDispose {
DeepLinkHandler.onSongReceived = null
}
}
MainScreenWithDrawer(
solfaScreenModel,

View file

@ -0,0 +1,34 @@
package mg.dot.feufaro
object DeepLinkHandler {
var hasConsumedDeepLink = false
private var pendingData: Pair<String, String>? = null
var onSongReceived: ((fileContent: String, filePath: String) -> Unit)? = null
set(value) {
field = value
if (value != null) {
consumePending()
}
}
fun handleSongContent(fileContent: String, filePath: String) {
var hasConsumedDeepLink = true
val callback = onSongReceived
if (callback != null) {
println("DeepLink: callback dispo → appel immédiat")
callback(fileContent, filePath)
} else {
println("DeepLink: callback null → mise en attente de $filePath")
pendingData = Pair(fileContent, filePath)
}
}
private fun consumePending() {
pendingData?.let { (content, path) ->
println("DeepLink: consommation du contenu en attente pour $path")
onSongReceived?.invoke(content, path)
pendingData = null
}
}
}

View file

@ -175,7 +175,11 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
}
}
fun resetShared() {
sharedScreenModel.reset()
}
fun parse(sourceFile: String) {
println("37: lFL bien reçu $sourceFile")
currentFile = sourceFile
val parseScope = CoroutineScope(Dispatchers.Default)
parseScope.launch {
@ -224,7 +228,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
val midiWriter = MidiWriterKotlin(fileRepository)
midiWriter.process(pitches)
midiWriter.save("whawyd3.mid")
sharedScreenModel.setStanza(1)
}
}

View file

@ -93,9 +93,12 @@ LaunchedEffect(isPlay, isPos) {
focusRequester.requestFocus()
}
}
val fileContent = sharedScreenModel.fileContent.value ?: ""
LaunchedEffect(Unit) {
if(fileContent == "") {
sharedScreenModel.loadNewSong("$midiFile")
}
}
ModalNavigationDrawer(drawerState = drawerState, drawerContent = {
SimpleDrawerContent(
items,
@ -134,6 +137,7 @@ LaunchedEffect(isPlay, isPos) {
if (showPrintSettings) {
PrintSettingsDialog(
initialSettings = defaultPrintSettings(),
// nbStanza = nbStanzas,
onDismiss = { showPrintSettings = false },
onConfirm = { settings ->
showPrintSettings = false

View file

@ -15,15 +15,31 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.dp
import java.io.ByteArrayOutputStream
import java.net.URLEncoder
import java.util.zip.GZIPOutputStream
import kotlin.io.encoding.Base64
@Composable
fun QRDisplay(sharedScreenModel: SharedScreenModel) {
val content by sharedScreenModel.fileContent
fun compressAndEncode(content: String?): String {
val bos = ByteArrayOutputStream()
GZIPOutputStream(bos).use { it.write(content?.toByteArray(Charsets.UTF_8)) }
val compressedBytes = bos.toByteArray()
return Base64.UrlSafe.encode(compressedBytes).trim('=')
}
val compressedData = compressAndEncode(content)
val uri = "feufaro://song?file=$compressedData"
// val encodedContent = URLEncoder.encode(content, "UTF-8")
// val uri = "feufaro://song?file=$encodedContent"
val qrCodeImage = remember(content) {
if (content != null) {
generateQRCode(content!!, size = 800)
generateQRCode(uri, size = 800)
} else {
null
}
@ -45,7 +61,7 @@ fun QRDisplay(sharedScreenModel: SharedScreenModel) {
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(24.dp)
) {
Text("Scanner ceci: ")
Text("Scanner pour ouvrir ce partition")
if (qrCodeImage != null) {
Image(
bitmap = qrCodeImage,

View file

@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import mg.dot.feufaro.DeepLinkHandler
import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.data.DrawerItem
import mg.dot.feufaro.data.GridTUOData
@ -94,6 +95,14 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
fun toggleQRCodeVisibility() {
_isQRCodeVisible.value = !_isQRCodeVisible.value
}
val qrCodeContent: State<String?>
get() = mutableStateOf(
_activeFilePath.value.takeIf { it.isNotBlank() }?.let { path ->
val fileName = path.substringAfterLast("/").substringAfterLast("\\")
"feufaro://song?file=${fileName}"
}
)
fun updateSearchTxt(searchValue: String) {
_searchTitle.value = searchValue
}
@ -341,7 +350,6 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
fun loadNewSong(newMidiFile: String) {
_mediaPlayer?.stop()
_mediaPlayer?.release()
_stanza.value = 1
_mediaPlayer = null
_isPos.value = true
_isPlay.value = false

View file

@ -1,14 +1,28 @@
package mg.dot.feufaro.viewmodel
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import mg.dot.feufaro.DeepLinkHandler
import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.solfa.Solfa
class SolfaScreenModel(
val fileRepository: FileRepository,
private val solfa: Solfa
val solfa: Solfa
) : ScreenModel {
init {}
init {
DeepLinkHandler.onSongReceived = { fileContent, filePath ->
println("QR Scan: Chemin reçu, lancement du chargement...")
loadExternalFile(filePath)
}
}
fun loadExternalFile(path: String) {
solfa.resetShared()
solfa.parse(path)
println("QR Scan: Chargement terminé pour $path")
}
fun loadNextInPlaylist() {
solfa.loadNextInPlaylist()