feat(settings): add JSON/Markdown export via Share Intent

Implement export functionality in the Settings screen allowing users to
share their inventory data as JSON (via FileProvider + ACTION_SEND with
EXTRA_STREAM) or Markdown (via ACTION_SEND with EXTRA_TEXT).

Key changes:
- ShareContent sealed interface for export events (Json with URI,
  Markdown with text)
- SettingsViewModel: exportJson() writes to cache file and creates
  FileProvider URI; exportMarkdown() provides text directly
- SettingsUiState: isExporting, shareContent, exportError fields
- SettingsScreen: LaunchedEffect consumes share events and opens
  Android Share Sheet via Intent.createChooser
- FileProvider registered in AndroidManifest with cache-path config
- MockK added as test dependency for FileProvider static mocking
- 8 new unit tests covering export success, failure, and state cleanup

Closes #37
This commit is contained in:
Jens Reinemann 2026-05-14 03:26:15 +02:00
parent 12f787a406
commit 8193445939
9 changed files with 295 additions and 20 deletions

View file

@ -73,6 +73,7 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidx.espresso.core)

View file

@ -19,6 +19,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -1,5 +1,6 @@
package de.krisenvorrat.app.ui.settings
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -20,8 +21,8 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
@ -37,6 +38,34 @@ internal fun SettingsScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(uiState.shareContent) {
val content = uiState.shareContent ?: return@LaunchedEffect
val shareIntent = when (content) {
is ShareContent.Json -> {
Intent(Intent.ACTION_SEND).apply {
type = "application/json"
putExtra(Intent.EXTRA_STREAM, content.fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
is ShareContent.Markdown -> {
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, content.text)
putExtra(Intent.EXTRA_SUBJECT, "Krisenvorrat Inventar")
}
}
}
context.startActivity(Intent.createChooser(shareIntent, "Exportieren via…"))
viewModel.onShareHandled()
}
LaunchedEffect(uiState.exportError) {
val error = uiState.exportError ?: return@LaunchedEffect
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
viewModel.onExportErrorDismissed()
}
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(title = { Text("Einstellungen") })
@ -112,33 +141,31 @@ internal fun SettingsScreen(
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = {
Toast.makeText(
context,
"Export wird in einem späteren Update verfügbar.",
Toast.LENGTH_SHORT
).show()
},
onClick = viewModel::exportJson,
enabled = !uiState.isExporting,
modifier = Modifier.fillMaxWidth()
) {
Text("Daten exportieren")
Text("Daten exportieren (JSON)")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = {
Toast.makeText(
context,
"Import wird in einem späteren Update verfügbar.",
Toast.LENGTH_SHORT
).show()
},
onClick = viewModel::exportMarkdown,
enabled = !uiState.isExporting,
modifier = Modifier.fillMaxWidth()
) {
Text("Daten importieren")
Text("Als Markdown teilen")
}
if (uiState.isExporting) {
Spacer(modifier = Modifier.height(8.dp))
CircularProgressIndicator(
modifier = Modifier.padding(8.dp)
)
}
}
}
}
}

View file

@ -4,5 +4,8 @@ internal data class SettingsUiState(
val householdSize: String = "",
val dailyKcalPerPerson: String = "",
val isLoading: Boolean = true,
val isSaved: Boolean = false
val isSaved: Boolean = false,
val isExporting: Boolean = false,
val shareContent: ShareContent? = null,
val exportError: String? = null
)

View file

@ -1,8 +1,12 @@
package de.krisenvorrat.app.ui.settings
import android.content.Context
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import kotlinx.coroutines.flow.MutableStateFlow
@ -10,11 +14,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
@HiltViewModel
internal class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository,
@ApplicationContext private val context: Context
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@ -79,6 +86,47 @@ internal class SettingsViewModel @Inject constructor(
}
}
fun exportJson() {
viewModelScope.launch {
_uiState.update { it.copy(isExporting = true, exportError = null) }
try {
val json = importExportRepository.exportToJson()
val cacheDir = File(context.cacheDir, "exports")
cacheDir.mkdirs()
val file = File(cacheDir, "krisenvorrat_export.json")
file.writeText(json)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
_uiState.update { it.copy(isExporting = false, shareContent = ShareContent.Json(uri)) }
} catch (e: Exception) {
_uiState.update { it.copy(isExporting = false, exportError = "Export fehlgeschlagen") }
}
}
}
fun exportMarkdown() {
viewModelScope.launch {
_uiState.update { it.copy(isExporting = true, exportError = null) }
try {
val markdown = importExportRepository.exportToMarkdown()
_uiState.update { it.copy(isExporting = false, shareContent = ShareContent.Markdown(markdown)) }
} catch (e: Exception) {
_uiState.update { it.copy(isExporting = false, exportError = "Export fehlgeschlagen") }
}
}
}
fun onShareHandled() {
_uiState.update { it.copy(shareContent = null) }
}
fun onExportErrorDismissed() {
_uiState.update { it.copy(exportError = null) }
}
companion object {
const val KEY_HOUSEHOLD_SIZE = "household_size"
const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"

View file

@ -0,0 +1,8 @@
package de.krisenvorrat.app.ui.settings
import android.net.Uri
internal sealed interface ShareContent {
data class Json(val fileUri: Uri) : ShareContent
data class Markdown(val text: String) : ShareContent
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="exports"
path="exports/" />
</paths>

View file

@ -1,6 +1,9 @@
package de.krisenvorrat.app.ui.settings
import android.content.Context
import androidx.core.content.FileProvider
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import kotlinx.coroutines.Dispatchers
@ -12,33 +15,53 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeSettingsRepository: FakeSettingsRepository
private lateinit var fakeImportExportRepository: FakeImportExportRepository
private lateinit var mockContext: Context
private lateinit var tempDir: File
private lateinit var viewModel: SettingsViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
fakeSettingsRepository = FakeSettingsRepository()
fakeImportExportRepository = FakeImportExportRepository()
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
tempDir.mkdirs()
mockContext = mockk(relaxed = true) {
every { cacheDir } returns tempDir
every { packageName } returns "de.krisenvorrat.app"
}
}
@After
fun tearDown() {
Dispatchers.resetMain()
tempDir.deleteRecursively()
}
private fun createViewModel() = SettingsViewModel(
settingsRepository = fakeSettingsRepository
settingsRepository = fakeSettingsRepository,
importExportRepository = fakeImportExportRepository,
context = mockContext
)
@Test
@ -156,6 +179,132 @@ class SettingsViewModelTest {
// Then zero is invalid, not persisted
assertFalse(fakeSettingsRepository.store.containsKey(SettingsViewModel.KEY_HOUSEHOLD_SIZE))
}
@Test
fun test_exportMarkdown_success_emitsShareContent() = runTest(testDispatcher) {
// Given
fakeImportExportRepository.markdownResult = "# Krisenvorrat Inventar\n"
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.exportMarkdown()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isExporting)
assertNotNull(state.shareContent)
assertTrue(state.shareContent is ShareContent.Markdown)
assertEquals("# Krisenvorrat Inventar\n", (state.shareContent as ShareContent.Markdown).text)
}
@Test
fun test_exportMarkdown_emptyDatabase_emitsMarkdownWithHint() = runTest(testDispatcher) {
// Given
fakeImportExportRepository.markdownResult = "# Krisenvorrat Inventar\n\nKeine Artikel vorhanden\n"
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.exportMarkdown()
advanceUntilIdle()
// Then
val content = viewModel.uiState.value.shareContent as ShareContent.Markdown
assertTrue(content.text.contains("Keine Artikel vorhanden"))
}
@Test
fun test_exportJson_success_emitsShareContentWithUri() = runTest(testDispatcher) {
// Given
fakeImportExportRepository.jsonResult = """{"categories":[],"locations":[],"items":[],"settings":[]}"""
mockkStatic(FileProvider::class)
val mockUri = mockk<android.net.Uri>()
every { FileProvider.getUriForFile(any(), any(), any()) } returns mockUri
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.exportJson()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isExporting)
assertNotNull(state.shareContent)
assertTrue(state.shareContent is ShareContent.Json)
assertEquals(mockUri, (state.shareContent as ShareContent.Json).fileUri)
unmockkStatic(FileProvider::class)
}
@Test
fun test_exportJson_failure_setsExportError() = runTest(testDispatcher) {
// Given
fakeImportExportRepository.shouldThrow = true
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.exportJson()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isExporting)
assertNull(state.shareContent)
assertEquals("Export fehlgeschlagen", state.exportError)
}
@Test
fun test_exportMarkdown_failure_setsExportError() = runTest(testDispatcher) {
// Given
fakeImportExportRepository.shouldThrow = true
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.exportMarkdown()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isExporting)
assertNull(state.shareContent)
assertEquals("Export fehlgeschlagen", state.exportError)
}
@Test
fun test_onShareHandled_clearsShareContent() = runTest(testDispatcher) {
// Given
fakeImportExportRepository.markdownResult = "# Test"
viewModel = createViewModel()
advanceUntilIdle()
viewModel.exportMarkdown()
advanceUntilIdle()
// When
viewModel.onShareHandled()
// Then
assertNull(viewModel.uiState.value.shareContent)
}
@Test
fun test_onExportErrorDismissed_clearsError() = runTest(testDispatcher) {
// Given
fakeImportExportRepository.shouldThrow = true
viewModel = createViewModel()
advanceUntilIdle()
viewModel.exportJson()
advanceUntilIdle()
// When
viewModel.onExportErrorDismissed()
// Then
assertNull(viewModel.uiState.value.exportError)
}
}
// region Test Helpers
@ -177,4 +326,25 @@ private class FakeSettingsRepository : SettingsRepository {
override fun getAll(): Flow<List<SettingsEntity>> = allFlow
}
private class FakeImportExportRepository : ImportExportRepository {
var jsonResult = """{"version":1,"categories":[],"locations":[],"items":[],"settings":[]}"""
var markdownResult = "# Krisenvorrat Inventar\n"
var shouldThrow = false
override suspend fun exportToJson(): String {
if (shouldThrow) throw RuntimeException("Test error")
return jsonResult
}
override suspend fun importFromJson(json: String): Result<Unit> {
if (shouldThrow) return Result.failure(RuntimeException("Test error"))
return Result.success(Unit)
}
override suspend fun exportToMarkdown(): String {
if (shouldThrow) throw RuntimeException("Test error")
return markdownResult
}
}
// endregion

View file

@ -15,6 +15,7 @@ room = "2.6.1"
navigationCompose = "2.8.5"
kotlinxSerialization = "1.7.3"
kotlinxCoroutines = "1.9.0"
mockk = "1.13.13"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -43,6 +44,7 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }