fix(resources): grant explicit URI permissions to all resolved activities before startActivity

Resolves 'Keine App gefunden' for ePub and PDF opening failures (e.g. Firefox).
FLAG_GRANT_READ_URI_PERMISSION alone is not sufficient when Android shows
the Chooser – each potential app must receive grantUriPermission() before
startActivity() is called.

- Extract openResourceFile() into FileOpenHelper.kt (eliminates DRY violation)
- Use PackageManager.ResolveInfoFlags (API 33+) with DEPRECATION suppress fallback
- Remove duplicate inline intent code from ResourceDetailScreen

Closes #129
This commit is contained in:
Jens Reinemann 2026-05-19 00:24:58 +02:00
parent 96375cb9ea
commit a84d130495
3 changed files with 58 additions and 35 deletions

View file

@ -0,0 +1,55 @@
package de.bollwerk.app.ui.resources
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.compose.material3.SnackbarHostState
import androidx.core.content.FileProvider
import java.io.File
/**
* Öffnet eine Ressourcen-Datei in einer externen App via FileProvider.
*
* Erteilt vor dem `startActivity`-Aufruf explizit URI-Berechtigungen an alle Apps,
* die den Intent auflösen können notwendig damit der Android-Chooser die Berechtigung
* korrekt weitergibt (z.B. Firefox für PDFs).
*/
internal suspend fun openResourceFile(
context: Context,
file: File,
mimeType: String,
snackbarHostState: SnackbarHostState
) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// Explizit URI-Berechtigung an alle Apps vergeben, die diesen Intent auflösen können,
// damit der Android-Chooser die Berechtigung korrekt weitergibt (z.B. Firefox für PDFs).
val resolvedActivities = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())
)
} else {
@Suppress("DEPRECATION")
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
}
resolvedActivities.forEach { resolveInfo ->
context.grantUriPermission(
resolveInfo.activityInfo.packageName,
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
snackbarHostState.showSnackbar("Keine App zum Öffnen dieser Datei gefunden")
}
}

View file

@ -1,7 +1,5 @@
package de.bollwerk.app.ui.resources package de.bollwerk.app.ui.resources
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
@ -42,7 +40,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.bollwerk.shared.model.ResourceDto import de.bollwerk.shared.model.ResourceDto
@ -67,16 +64,7 @@ internal fun ResourceDetailScreen(
// Handle download success // Handle download success
if (downloadState is DownloadState.Success) { if (downloadState is DownloadState.Success) {
LaunchedEffect(downloadState) { LaunchedEffect(downloadState) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", downloadState.file) openResourceFile(context, downloadState.file, downloadState.mimeType, snackbarHostState)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, downloadState.mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
snackbarHostState.showSnackbar("Keine App zum Öffnen dieser Datei gefunden")
}
viewModel.clearDownloadState(guid) viewModel.clearDownloadState(guid)
} }
} }

View file

@ -1,8 +1,5 @@
package de.bollwerk.app.ui.resources package de.bollwerk.app.ui.resources
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -61,7 +58,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.bollwerk.shared.model.ResourceDto import de.bollwerk.shared.model.ResourceDto
@ -112,7 +108,7 @@ internal fun ResourceListScreen(
when (state) { when (state) {
is DownloadState.Success -> { is DownloadState.Success -> {
LaunchedEffect(guid, state) { LaunchedEffect(guid, state) {
openFile(context, state.file, state.mimeType, snackbarHostState) openResourceFile(context, state.file, state.mimeType, snackbarHostState)
viewModel.clearDownloadState(guid) viewModel.clearDownloadState(guid)
} }
} }
@ -519,20 +515,4 @@ private fun formatFileSize(bytes: Long): String {
} }
} }
private suspend fun openFile(
context: Context,
file: File,
mimeType: String,
snackbarHostState: SnackbarHostState
) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
snackbarHostState.showSnackbar("Keine App zum Öffnen dieser Datei gefunden")
}
}