Android compatibility WIP 2

- Shared file chooser and saver api
- Categories window/screen support
This commit is contained in:
Syer10
2022-02-26 22:35:19 -05:00
parent a4ec64c67d
commit c7dd11ae23
13 changed files with 306 additions and 40 deletions

View File

@@ -13,6 +13,9 @@ accompanist = "0.20.1"
kamel = "0.3.0" kamel = "0.3.0"
materialDialogs = "0.6.4" materialDialogs = "0.6.4"
# Android
activityCompose = "1.3.1"
# Swing # Swing
darklaf = "2.7.3" darklaf = "2.7.3"
@@ -61,6 +64,9 @@ accompanistFlowLayout = { module = "ca.gosyer:accompanist-flowlayout", version.r
kamel = { module = "com.alialbaali.kamel:kamel-image", version.ref = "kamel" } kamel = { module = "com.alialbaali.kamel:kamel-image", version.ref = "kamel" }
materialDialogsCore = { module = "ca.gosyer:compose-material-dialogs-core", version.ref = "materialDialogs" } materialDialogsCore = { module = "ca.gosyer:compose-material-dialogs-core", version.ref = "materialDialogs" }
# Android
activityCompose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
# Swing # Swing
darklaf = { module = "com.github.weisj:darklaf-core", version.ref = "darklaf" } darklaf = { module = "com.github.weisj:darklaf-core", version.ref = "darklaf" }
@@ -99,4 +105,4 @@ desugarJdkLibs = { module = "com.android.tools:desugar_jdk_libs", version.ref =
# Localization # Localization
mokoCore = { module = "dev.icerock.moko:resources", version.ref = "moko" } mokoCore = { module = "dev.icerock.moko:resources", version.ref = "moko" }
mokoCompose= { module = "dev.icerock.moko:resources-compose", version.ref = "moko" } mokoCompose = { module = "dev.icerock.moko:resources-compose", version.ref = "moko" }

View File

@@ -82,6 +82,7 @@ kotlin {
kotlin.srcDir("build/generated/ksp/androidRelease/kotlin") kotlin.srcDir("build/generated/ksp/androidRelease/kotlin")
dependencies { dependencies {
api(kotlin("stdlib-jdk8")) api(kotlin("stdlib-jdk8"))
api(libs.activityCompose)
} }
} }
val androidTest by getting { val androidTest by getting {

View File

@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.ui.base.file
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.core.net.toFile
import okio.Path
import okio.Path.Companion.toOkioPath
actual class FileChooser(private val resultLauncher: ManagedActivityResultLauncher<String, Uri?>) {
actual fun launch(extension: String) {
resultLauncher.launch(MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension))
}
}
@Composable
actual fun rememberFileChooser(onFileFound: (Path) -> Unit): FileChooser {
val result = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
it?.toFile()?.toOkioPath()?.let(onFileFound)
}
return remember { FileChooser(result) }
}

View File

@@ -0,0 +1,42 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.ui.base.file
import android.net.Uri
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.core.net.toFile
import okio.Path
import okio.Path.Companion.toOkioPath
actual class FileSaver(
private val resultLauncher: ManagedActivityResultLauncher<String, Uri?>,
) {
actual fun save(name: String) {
resultLauncher.launch(name)
}
}
@Composable
actual fun rememberFileSaver(
onFileSelected: (Path) -> Unit,
onCancel: () -> Unit,
onError: () -> Unit,
): FileSaver {
val result = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument()) {
if (it != null) {
it.toFile().toOkioPath().let(onFileSelected)
} else {
onCancel()
}
}
return remember { FileSaver(result) }
}

View File

@@ -0,0 +1,13 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.ui.categories
import cafe.adriel.voyager.navigator.Navigator
actual fun openCategoriesMenu(notifyFinished: () -> Unit, navigator: Navigator) {
navigator push CategoriesScreen(notifyFinished)
}

View File

@@ -0,0 +1,41 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.ui.base.file
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import ca.gosyer.core.lang.launchDefault
import kotlinx.coroutines.CoroutineScope
import okio.Path
import okio.Path.Companion.toOkioPath
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
actual class FileChooser(private val onFileFound: (Path) -> Unit, private val scope: CoroutineScope) {
private val fileChooser = JFileChooser()
.apply {
val details = actionMap.get("viewTypeDetails")
details?.actionPerformed(null)
}
actual fun launch(extension: String) {
scope.launchDefault {
fileChooser.fileFilter = FileNameExtensionFilter("$extension file", extension)
when (fileChooser.showOpenDialog(null)) {
JFileChooser.APPROVE_OPTION -> onFileFound(fileChooser.selectedFile.toOkioPath())
}
}
}
}
@Composable
actual fun rememberFileChooser(onFileFound: (Path) -> Unit): FileChooser {
val coroutineScope = rememberCoroutineScope()
return remember { FileChooser(onFileFound, coroutineScope) }
}

View File

@@ -0,0 +1,50 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.ui.base.file
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import ca.gosyer.core.lang.launchDefault
import kotlinx.coroutines.CoroutineScope
import okio.Path
import okio.Path.Companion.toOkioPath
import javax.swing.JFileChooser
actual class FileSaver(
private val onFileSelected: (Path) -> Unit,
private val onCancel: () -> Unit,
private val onError: () -> Unit,
private val scope: CoroutineScope
) {
private val fileChooser = JFileChooser()
.apply {
val details = actionMap.get("viewTypeDetails")
details?.actionPerformed(null)
}
actual fun save(name: String) {
scope.launchDefault {
fileChooser.selectedFile = fileChooser.currentDirectory.resolve(name)
when (fileChooser.showSaveDialog(null)) {
JFileChooser.APPROVE_OPTION -> onFileSelected(fileChooser.selectedFile.toOkioPath())
JFileChooser.CANCEL_OPTION -> onCancel()
JFileChooser.ERROR_OPTION -> onError()
}
}
}
}
@Composable
actual fun rememberFileSaver(
onFileSelected: (Path) -> Unit,
onCancel: () -> Unit,
onError: () -> Unit,
): FileSaver {
val coroutineScope = rememberCoroutineScope()
return remember { FileSaver(onFileSelected, onCancel, onError, coroutineScope) }
}

View File

@@ -16,7 +16,7 @@ import cafe.adriel.voyager.navigator.Navigator
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
fun openCategoriesMenu(notifyFinished: (() -> Unit)? = null) { actual fun openCategoriesMenu(notifyFinished: () -> Unit, navigator: Navigator) {
launchApplication { launchApplication {
CompositionLocalProvider(*remember { AppComponent.getInstance().uiComponent.getHooks() }) { CompositionLocalProvider(*remember { AppComponent.getInstance().uiComponent.getHooks() }) {
ThemedWindow(::exitApplication, title = "${BuildKonfig.NAME} - Categories") { ThemedWindow(::exitApplication, title = "${BuildKonfig.NAME} - Categories") {

View File

@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.ui.base.file
import androidx.compose.runtime.Composable
import okio.Path
expect class FileChooser {
fun launch(extension: String)
}
@Composable
expect fun rememberFileChooser(onFileFound: (Path) -> Unit): FileChooser

View File

@@ -0,0 +1,21 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.ui.base.file
import androidx.compose.runtime.Composable
import okio.Path
expect class FileSaver {
fun save(name: String)
}
@Composable
expect fun rememberFileSaver(
onFileSelected: (Path) -> Unit,
onCancel: () -> Unit = {},
onError: () -> Unit = {},
): FileSaver

View File

@@ -13,6 +13,9 @@ import ca.gosyer.uicore.vm.viewModel
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.ScreenKey
import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey
import cafe.adriel.voyager.navigator.Navigator
expect fun openCategoriesMenu(notifyFinished: () -> Unit, navigator: Navigator)
class CategoriesScreen( class CategoriesScreen(
@Transient @Transient

View File

@@ -41,10 +41,10 @@ import ca.gosyer.i18n.MR
import ca.gosyer.ui.base.components.VerticalScrollbar import ca.gosyer.ui.base.components.VerticalScrollbar
import ca.gosyer.ui.base.components.rememberScrollbarAdapter import ca.gosyer.ui.base.components.rememberScrollbarAdapter
import ca.gosyer.ui.base.dialog.getMaterialDialogProperties import ca.gosyer.ui.base.dialog.getMaterialDialogProperties
import ca.gosyer.ui.base.file.rememberFileChooser
import ca.gosyer.ui.base.file.rememberFileSaver
import ca.gosyer.ui.base.navigation.Toolbar import ca.gosyer.ui.base.navigation.Toolbar
import ca.gosyer.ui.base.prefs.PreferenceRow import ca.gosyer.ui.base.prefs.PreferenceRow
import ca.gosyer.ui.util.system.filePicker
import ca.gosyer.ui.util.system.fileSaver
import ca.gosyer.uicore.resources.stringResource import ca.gosyer.uicore.resources.stringResource
import ca.gosyer.uicore.vm.ContextWrapper import ca.gosyer.uicore.vm.ContextWrapper
import ca.gosyer.uicore.vm.ViewModel import ca.gosyer.uicore.vm.ViewModel
@@ -66,6 +66,8 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
import okio.FileSystem import okio.FileSystem
import okio.Path import okio.Path
@@ -90,7 +92,8 @@ class SettingsBackupScreen : Screen {
restoreFile = vm::restoreFile, restoreFile = vm::restoreFile,
restoreBackup = vm::restoreBackup, restoreBackup = vm::restoreBackup,
stopRestore = vm::stopRestore, stopRestore = vm::stopRestore,
exportBackup = vm::exportBackup exportBackup = vm::exportBackup,
exportBackupFileFound = vm::exportBackupFileFound
) )
} }
} }
@@ -114,13 +117,13 @@ class SettingsBackupViewModel @Inject constructor(
val creatingProgress = _creatingProgress.asStateFlow() val creatingProgress = _creatingProgress.asStateFlow()
private val _creatingStatus = MutableStateFlow<Status>(Status.Nothing) private val _creatingStatus = MutableStateFlow<Status>(Status.Nothing)
internal val creatingStatus = _creatingStatus.asStateFlow() internal val creatingStatus = _creatingStatus.asStateFlow()
private val _createFlow = MutableSharedFlow<Pair<String, (Path) -> Unit>>() private val _createFlow = MutableSharedFlow<String>()
val createFlow = _createFlow.asSharedFlow() val createFlow = _createFlow.asSharedFlow()
fun restoreFile(file: Path?) { fun restoreFile(file: Path) {
scope.launch { scope.launch {
if (file == null || !FileSystem.SYSTEM.exists(file)) { if (!FileSystem.SYSTEM.exists(file)) {
info { "Invalid file ${file?.toString()}" } info { "Invalid file ${file.toString()}" }
_restoreStatus.value = Status.Error _restoreStatus.value = Status.Error
_restoring.value = false _restoring.value = false
} else { } else {
@@ -167,6 +170,9 @@ class SettingsBackupViewModel @Inject constructor(
_restoring.value = false _restoring.value = false
} }
private val tempFile = MutableStateFlow<Path?>(null)
private val mutex = Mutex()
fun exportBackup() { fun exportBackup() {
scope.launch { scope.launch {
_creatingStatus.value = Status.Nothing _creatingStatus.value = Status.Nothing
@@ -181,31 +187,56 @@ class SettingsBackupViewModel @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
info(e) { "Error exporting backup" } info(e) { "Error exporting backup" }
_creatingStatus.value = Status.Error _creatingStatus.value = Status.Error
_creating.value = false
e.throwIfCancellation() e.throwIfCancellation()
null null
} }
_creatingProgress.value = 1.0F _creatingProgress.value = 1.0F
if (backup != null && backup.status.isSuccess()) { if (backup != null && backup.status.isSuccess()) {
_createFlow.emit( val filename = backup.headers["content-disposition"]?.substringAfter("filename=")?.trim('"') ?: "backup"
(backup.headers["content-disposition"]?.substringAfter("filename=")?.trim('"') ?: "backup") to { tempFile.value = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.resolve(filename).also {
scope.launch { launch {
try { try {
backup.content.toInputStream() backup.content.toInputStream()
.source() .source()
.copyTo( .copyTo(
FileSystem.SYSTEM.sink(it).buffer() FileSystem.SYSTEM.sink(it).buffer()
) )
_creatingStatus.value = Status.Success } catch (e: Exception) {
} catch (e: Exception) { e.throwIfCancellation()
e.throwIfCancellation() error(e) { "Error creating backup" }
error(e) { "Error creating backup" } _creatingStatus.value = Status.Error
_creatingStatus.value = Status.Error _creating.value = false
} finally { } finally {
_creating.value = false mutex.unlock()
}
} }
} }
) }
mutex.tryLock()
_createFlow.emit(filename)
}
}
}
fun exportBackupFileFound(backupPath: Path) {
scope.launch {
mutex.withLock {
val tempFile = tempFile.value
if (_creating.value && tempFile != null) {
try {
FileSystem.SYSTEM.atomicMove(tempFile, backupPath)
_creatingStatus.value = Status.Success
} catch (e: Exception) {
e.throwIfCancellation()
error(e) { "Error moving created backup" }
_creatingStatus.value = Status.Error
} finally {
_creating.value = false
}
} else {
_creatingStatus.value = Status.Error
_creating.value = false
}
} }
} }
} }
@@ -228,15 +259,18 @@ private fun SettingsBackupScreenContent(
creatingProgress: Float?, creatingProgress: Float?,
creatingStatus: SettingsBackupViewModel.Status, creatingStatus: SettingsBackupViewModel.Status,
missingSourceFlow: SharedFlow<Pair<Path, List<String>>>, missingSourceFlow: SharedFlow<Pair<Path, List<String>>>,
createFlow: SharedFlow<Pair<String, (Path) -> Unit>>, createFlow: SharedFlow<String>,
restoreFile: (Path?) -> Unit, restoreFile: (Path) -> Unit,
restoreBackup: (Path) -> Unit, restoreBackup: (Path) -> Unit,
stopRestore: () -> Unit, stopRestore: () -> Unit,
exportBackup: () -> Unit exportBackup: () -> Unit,
exportBackupFileFound: (Path) -> Unit
) { ) {
var backupFile by remember { mutableStateOf<Path?>(null) } var backupFile by remember { mutableStateOf<Path?>(null) }
var missingSources by remember { mutableStateOf(emptyList<String>()) } var missingSources by remember { mutableStateOf(emptyList<String>()) }
val dialogState = rememberMaterialDialogState() val dialogState = rememberMaterialDialogState()
val fileSaver = rememberFileSaver(exportBackupFileFound)
val fileChooser = rememberFileChooser(restoreFile)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { launch {
missingSourceFlow.collect { (backup, sources) -> missingSourceFlow.collect { (backup, sources) ->
@@ -246,12 +280,14 @@ private fun SettingsBackupScreenContent(
} }
} }
launch { launch {
createFlow.collect { (filename, function) -> createFlow.collect { filename ->
fileSaver(filename, "proto.gz", onApprove = function) fileSaver.save(filename)
} }
} }
} }
Scaffold( Scaffold(
topBar = { topBar = {
Toolbar(stringResource(MR.strings.settings_backup_screen)) Toolbar(stringResource(MR.strings.settings_backup_screen))
@@ -268,7 +304,7 @@ private fun SettingsBackupScreenContent(
restoringProgress, restoringProgress,
restoreStatus restoreStatus
) { ) {
filePicker("gz", onApprove = restoreFile) fileChooser.launch("gz")
} }
PreferenceFile( PreferenceFile(
stringResource(MR.strings.backup_create), stringResource(MR.strings.backup_create),

View File

@@ -6,14 +6,12 @@
package ca.gosyer.ui.settings package ca.gosyer.ui.settings
import ca.gosyer.ui.base.components.VerticalScrollbar
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import ca.gosyer.ui.base.components.rememberScrollbarAdapter
import androidx.compose.material.Scaffold import androidx.compose.material.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -23,6 +21,8 @@ import androidx.compose.ui.unit.dp
import ca.gosyer.data.library.LibraryPreferences import ca.gosyer.data.library.LibraryPreferences
import ca.gosyer.data.server.interactions.CategoryInteractionHandler import ca.gosyer.data.server.interactions.CategoryInteractionHandler
import ca.gosyer.i18n.MR import ca.gosyer.i18n.MR
import ca.gosyer.ui.base.components.VerticalScrollbar
import ca.gosyer.ui.base.components.rememberScrollbarAdapter
import ca.gosyer.ui.base.navigation.Toolbar import ca.gosyer.ui.base.navigation.Toolbar
import ca.gosyer.ui.base.prefs.PreferenceRow import ca.gosyer.ui.base.prefs.PreferenceRow
import ca.gosyer.ui.base.prefs.SwitchPreference import ca.gosyer.ui.base.prefs.SwitchPreference
@@ -35,6 +35,8 @@ import ca.gosyer.uicore.vm.viewModel
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.ScreenKey
import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -46,10 +48,11 @@ class SettingsLibraryScreen : Screen {
@Composable @Composable
override fun Content() { override fun Content() {
val vm = viewModel<SettingsLibraryViewModel>() val vm = viewModel<SettingsLibraryViewModel>()
val navigator = LocalNavigator.currentOrThrow
SettingsLibraryScreenContent( SettingsLibraryScreenContent(
showAllCategory = vm.showAllCategory, showAllCategory = vm.showAllCategory,
refreshCategoryCount = vm::refreshCategoryCount, categoriesSize = vm.categories.collectAsState().value,
categoriesSize = vm.categories.collectAsState().value openCategoriesScreen = { openCategoriesMenu(vm::refreshCategoryCount, navigator) }
) )
} }
} }
@@ -78,8 +81,8 @@ class SettingsLibraryViewModel @Inject constructor(
@Composable @Composable
fun SettingsLibraryScreenContent( fun SettingsLibraryScreenContent(
showAllCategory: PreferenceMutableStateFlow<Boolean>, showAllCategory: PreferenceMutableStateFlow<Boolean>,
refreshCategoryCount: () -> Unit, categoriesSize: Int,
categoriesSize: Int openCategoriesScreen: () -> Unit
) { ) {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -98,7 +101,7 @@ fun SettingsLibraryScreenContent(
item { item {
PreferenceRow( PreferenceRow(
stringResource(MR.strings.location_categories), stringResource(MR.strings.location_categories),
onClick = { openCategoriesMenu(refreshCategoryCount) }, onClick = { openCategoriesScreen() },
subtitle = categoriesSize.toString() subtitle = categoriesSize.toString()
) )
} }