Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ horologist = "0.7.15"
coreSplashscreen = "1.0.1"
wear-input = "1.2.0"
androidxDataStore = "1.1.7"
documentfile = "1.0.1"
#material-icons-extended = "1.7.8"

[libraries]
Expand Down Expand Up @@ -63,6 +64,7 @@ wear-input = { group = "androidx.wear", name = "wear-input", version.ref = "wear
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-dataStore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Expand Down
1 change: 1 addition & 0 deletions mobile/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.androidx.dataStore.preferences)
implementation(libs.androidx.work.ktx)
implementation(libs.androidx.documentfile)

implementation(libs.androidx.compose.material.iconsExtended)
}
31 changes: 31 additions & 0 deletions mobile/src/main/java/dev/tberghuis/voicememos/MobileScreen.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package dev.tberghuis.voicememos

import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
Expand All @@ -24,12 +28,14 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.tberghuis.voicememos.common.calcDuration
import dev.tberghuis.voicememos.common.formatTimestampFromFilename
Expand All @@ -40,6 +46,28 @@ import java.io.File
fun MobileScreen(
vm: MobileViewModel = viewModel()
) {
val context = LocalContext.current

val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri: Uri? ->
uri?.let {
vm.exportToFolder(it)
}
}

LaunchedEffect(Unit) {
vm.exportRecordingsTrigger.collect {
exportLauncher.launch(null)
}
}

LaunchedEffect(Unit) {
vm.snackbarMessage.collect {
vm.snackbarHostState.showSnackbar(it)
}
}

Scaffold(topBar = {
TopAppBar(title = { Text(stringResource(R.string.app_name)) })
}, bottomBar = {
Expand All @@ -55,6 +83,9 @@ fun MobileScreen(
}) {
Text("Delete All Watch")
}
Button(modifier = Modifier.padding(start = 10.dp), onClick = { vm.exportRecordings() }) {
Text("Export")
}
}
}
}, snackbarHost = {
Expand Down
56 changes: 55 additions & 1 deletion mobile/src/main/java/dev/tberghuis/voicememos/MobileViewModel.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package dev.tberghuis.voicememos

import android.app.Application
import android.net.Uri
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.wearable.CapabilityClient
Expand All @@ -14,9 +16,12 @@ import dev.tberghuis.voicememos.common.AudioController
import dev.tberghuis.voicememos.common.deleteFileCommon
import dev.tberghuis.voicememos.common.logd
import java.io.File
import java.io.FileInputStream
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await

Expand Down Expand Up @@ -47,6 +52,12 @@ class MobileViewModel(private val application: Application) : AndroidViewModel(a
}
}

private val _exportRecordingsTrigger = MutableSharedFlow<Unit>()
val exportRecordingsTrigger = _exportRecordingsTrigger.asSharedFlow()

private val _snackbarMessage = MutableSharedFlow<String>()
val snackbarMessage = _snackbarMessage.asSharedFlow()

init {
logd("MobileViewModel init")
refreshRecordingFiles()
Expand Down Expand Up @@ -129,4 +140,47 @@ class MobileViewModel(private val application: Application) : AndroidViewModel(a

}
}
}

fun exportRecordings() {
viewModelScope.launch {
_exportRecordingsTrigger.emit(Unit)
}
}

fun exportToFolder(treeUri: Uri) {
viewModelScope.launch {
var copiedCount = 0
try {
application.contentResolver.takePersistableUriPermission(
treeUri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)

val folder = DocumentFile.fromTreeUri(application, treeUri)
?: throw Exception("Could not get DocumentFile from URI")

recordingFilesStateFlow.value.forEach { file ->
val newFile = folder.createFile("audio/pcm", file.name)
?: throw Exception("Could not create file ${file.name} in selected folder")

application.contentResolver.openOutputStream(newFile.uri)?.use { outputStream ->
FileInputStream(file).use { inputStream ->
inputStream.copyTo(outputStream)
}
}
copiedCount++
}
_snackbarMessage.emit("Exported $copiedCount recordings to selected folder")
} catch (e: Exception) {
logd("Error exporting recordings: $e")
_snackbarMessage.emit("Error exporting recordings: ${e.localizedMessage}")
} finally {
// It's generally good practice to release permissions when no longer needed,
// but for a chosen folder, the user might expect it to persist.
// For simplicity and to match common patterns, we will not release it here.
// If a more complex permission management is needed, this would be the place.
}
}
}
}