Android: add general document import with multi-file support

Add three new FFI exports to synchronicity_lib: import_image_document,
import_music_track_document, and get_all_document_mime_types. The
MIME-type list comes from the same documents::import constants the GUI
uses, so Android and GUI share the same source of truth for which files
are supported.

On Android: add DocumentFilePicker (OpenMultipleDocuments) and an
import button to DocumentsScreen. File type is detected via
contentResolver.getType and routed to the correct importer. Add
IMPORT_ICON = Icons.Default.FileOpen as an app-wide constant; update
PdfFilePicker to use it and rename its button text to 'Import PDF'.
This commit is contained in:
Greg Shuflin
2026-05-03 23:13:26 -07:00
parent d024d82256
commit e40ed91cbf
9 changed files with 280 additions and 21 deletions
@@ -168,6 +168,14 @@ class NativeLib {
fun importPdfDocument(fileName: String, pdfBytes: ByteArray): String =
uniffi.synchronicity.importPdfDocument(fileName, pdfBytes)
fun importImageDocument(fileName: String, fileExt: String, imageBytes: ByteArray): String =
uniffi.synchronicity.importImageDocument(fileName, fileExt, imageBytes)
fun importMusicTrackDocument(fileName: String, musicBytes: ByteArray): String =
uniffi.synchronicity.importMusicTrackDocument(fileName, musicBytes)
fun getAllDocumentMimeTypes(): List<String> = uniffi.synchronicity.getAllDocumentMimeTypes()
fun getDocumentBlobBytes(documentId: String): ByteArray =
uniffi.synchronicity.getDocumentBlobBytes(documentId)
@@ -0,0 +1,32 @@
package com.gregshuflin.synchronicity.documents.ui
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import com.gregshuflin.synchronicity.ui.IMPORT_ICON
private const val TAG = "DocumentFilePicker"
@Composable
fun DocumentFilePicker(
mimeTypes: Array<String>,
onFilesSelected: (List<Uri>) -> Unit,
) {
val filePickerLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris: List<Uri> ->
Log.d(TAG, "Files selected: $uris")
if (uris.isNotEmpty()) onFilesSelected(uris)
}
TextButton(onClick = { filePickerLauncher.launch(mimeTypes) }) {
Icon(IMPORT_ICON, contentDescription = null)
Text("Import")
}
}
@@ -1,6 +1,9 @@
package com.gregshuflin.synchronicity.documents.ui
import android.net.Uri
import android.util.Log
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@@ -11,31 +14,42 @@ import androidx.compose.material3.MenuAnchorType
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.gregshuflin.synchronicity.NativeLib
import com.gregshuflin.synchronicity.getFileNameFromUri
import com.gregshuflin.synchronicity.ui.SubAppScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class DocumentFilter(val type: String, val displayName: String)
data class DateFilter(val key: String, val displayName: String)
private const val TAG = "DocumentsScreen"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentsScreen(onBack: () -> Unit, onDocumentClick: ((String) -> Unit)? = null) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val nativeLib = remember { NativeLib() }
var searchQuery by remember { mutableStateOf("") }
var selectedDocumentTypes by remember { mutableStateOf(setOf<String>()) }
var selectedDateFilter by remember { mutableStateOf<String?>(null) }
var documentTypeExpanded by remember { mutableStateOf(false) }
var dateFilterExpanded by remember { mutableStateOf(false) }
var refreshTrigger by remember { mutableIntStateOf(0) }
// Load document types from native library
// Load document types and supported MIME types from native library
val availableDocumentTypes = remember { mutableStateOf<List<DocumentFilter>>(emptyList()) }
val supportedMimeTypes = remember { mutableStateOf<Array<String>>(emptyArray()) }
LaunchedEffect(Unit) {
try {
val nativeLib = NativeLib()
val documentTypes = nativeLib.getAllDocumentTypes()
val documentFilters =
documentTypes.map { type ->
val displayName =
@@ -48,14 +62,14 @@ fun DocumentsScreen(onBack: () -> Unit, onDocumentClick: ((String) -> Unit)? = n
}
DocumentFilter(type, displayName)
}
Log.d(
"DocumentsScreen",
"Loaded ${documentFilters.size} document types: ${documentFilters.map { it.type }}",
)
Log.d(TAG, "Loaded ${documentFilters.size} document types: ${documentFilters.map { it.type }}")
availableDocumentTypes.value = documentFilters
val mimeTypes = nativeLib.getAllDocumentMimeTypes()
Log.d(TAG, "Supported MIME types: $mimeTypes")
supportedMimeTypes.value = mimeTypes.toTypedArray()
} catch (e: Exception) {
Log.e("DocumentsScreen", "Failed to load document types from native library", e)
Log.e(TAG, "Failed to load document types from native library", e)
availableDocumentTypes.value = emptyList()
}
}
@@ -72,9 +86,8 @@ fun DocumentsScreen(onBack: () -> Unit, onDocumentClick: ((String) -> Unit)? = n
}
val documents by
remember(searchQuery, selectedDocumentTypes, selectedDateFilter) {
remember(searchQuery, selectedDocumentTypes, selectedDateFilter, refreshTrigger) {
derivedStateOf {
val nativeLib = NativeLib()
searchAndParseDocuments(
nativeLib,
query = searchQuery,
@@ -88,7 +101,24 @@ fun DocumentsScreen(onBack: () -> Unit, onDocumentClick: ((String) -> Unit)? = n
val hasActiveFilters =
searchQuery.isNotEmpty() || selectedDocumentTypes.isNotEmpty() || selectedDateFilter != null
SubAppScreen(title = "Documents", onBack = onBack) {
SubAppScreen(
title = "Documents",
onBack = onBack,
actions = {
if (supportedMimeTypes.value.isNotEmpty()) {
DocumentFilePicker(mimeTypes = supportedMimeTypes.value) { uris: List<Uri> ->
scope.launch {
uris.forEach { uri ->
importDocument(context, nativeLib, uri) { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
refreshTrigger++
}
}
}
},
) {
// Search bar
OutlinedTextField(
value = searchQuery,
@@ -286,6 +316,47 @@ fun DocumentsScreen(onBack: () -> Unit, onDocumentClick: ((String) -> Unit)? = n
}
}
private suspend fun importDocument(
context: android.content.Context,
nativeLib: NativeLib,
uri: Uri,
onResult: (String) -> Unit,
) {
val mimeType = context.contentResolver.getType(uri)
val displayName = getFileNameFromUri(context, uri) ?: "Unknown"
val stem = displayName.substringBeforeLast(".", displayName)
try {
val bytes = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalStateException("Could not read file")
}
val docId = when {
mimeType == "application/pdf" -> {
Log.i(TAG, "Importing PDF: $displayName")
nativeLib.importPdfDocument(stem, bytes)
}
mimeType?.startsWith("image/") == true -> {
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: "jpg"
Log.i(TAG, "Importing image: $displayName ($mimeType)")
nativeLib.importImageDocument(stem, ext, bytes)
}
mimeType == "audio/mpeg" -> {
Log.i(TAG, "Importing music track: $displayName")
nativeLib.importMusicTrackDocument(stem, bytes)
}
else -> throw IllegalArgumentException("Unsupported file type: $mimeType")
}
Log.i(TAG, "Imported $displayName as document $docId")
withContext(Dispatchers.Main) { onResult("Imported: $displayName") }
} catch (e: Exception) {
Log.e(TAG, "Failed to import $displayName", e)
withContext(Dispatchers.Main) { onResult("Import failed: ${e.message}") }
}
}
@Composable
private fun DocumentOverflowMenu(onSettingsClick: () -> Unit) {
var expanded by remember { mutableStateOf(false) }
@@ -5,14 +5,13 @@ import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.vector.ImageVector
import com.gregshuflin.synchronicity.ui.IconButton
import com.gregshuflin.synchronicity.ui.IMPORT_ICON
@Composable
fun PdfFilePicker(
onFileSelected: (Uri) -> Unit,
icon: ImageVector,
text: String = "Select PDF File",
text: String = "Import PDF",
) {
val filePickerLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) {
@@ -23,7 +22,7 @@ fun PdfFilePicker(
IconButton(
onClick = { filePickerLauncher.launch("application/pdf") },
icon = icon,
icon = IMPORT_ICON,
text = text,
iconContentDescription = text,
)
@@ -4,8 +4,6 @@ import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -73,8 +71,6 @@ fun PdfLibraryScreen(
}
}
},
icon = Icons.Default.SaveAlt,
text = "Import PDF to Library",
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
@@ -0,0 +1,6 @@
package com.gregshuflin.synchronicity.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileOpen
val IMPORT_ICON = Icons.Default.FileOpen
@@ -647,6 +647,8 @@ internal object IntegrityCheckingUniffiLib {
): Short
external fun uniffi_synchronicity_checksum_func_get_agents(
): Short
external fun uniffi_synchronicity_checksum_func_get_all_document_mime_types(
): Short
external fun uniffi_synchronicity_checksum_func_get_all_document_types(
): Short
external fun uniffi_synchronicity_checksum_func_get_document(
@@ -669,6 +671,10 @@ internal object IntegrityCheckingUniffiLib {
): Short
external fun uniffi_synchronicity_checksum_func_get_theme_mode(
): Short
external fun uniffi_synchronicity_checksum_func_import_image_document(
): Short
external fun uniffi_synchronicity_checksum_func_import_music_track_document(
): Short
external fun uniffi_synchronicity_checksum_func_import_pdf_document(
): Short
external fun uniffi_synchronicity_checksum_func_init_android_logging(
@@ -754,6 +760,8 @@ external fun uniffi_synchronicity_fn_func_generate_ensemble_key(uniffi_out_err:
): RustBuffer.ByValue
external fun uniffi_synchronicity_fn_func_get_agents(uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
external fun uniffi_synchronicity_fn_func_get_all_document_mime_types(uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
external fun uniffi_synchronicity_fn_func_get_all_document_types(uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
external fun uniffi_synchronicity_fn_func_get_document(`documentId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
@@ -776,6 +784,10 @@ external fun uniffi_synchronicity_fn_func_get_seen_nodes(uniffi_out_err: UniffiR
): RustBuffer.ByValue
external fun uniffi_synchronicity_fn_func_get_theme_mode(uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
external fun uniffi_synchronicity_fn_func_import_image_document(`fileName`: RustBuffer.ByValue,`fileExt`: RustBuffer.ByValue,`imageBytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
external fun uniffi_synchronicity_fn_func_import_music_track_document(`fileName`: RustBuffer.ByValue,`musicBytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
external fun uniffi_synchronicity_fn_func_import_pdf_document(`fileName`: RustBuffer.ByValue,`pdfBytes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
external fun uniffi_synchronicity_fn_func_init_android_logging(uniffi_out_err: UniffiRustCallStatus,
@@ -973,6 +985,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) {
if (lib.uniffi_synchronicity_checksum_func_get_agents() != 10120.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_synchronicity_checksum_func_get_all_document_mime_types() != 33749.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_synchronicity_checksum_func_get_all_document_types() != 5653.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
@@ -1006,6 +1021,12 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) {
if (lib.uniffi_synchronicity_checksum_func_get_theme_mode() != 45849.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_synchronicity_checksum_func_import_image_document() != 55567.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_synchronicity_checksum_func_import_music_track_document() != 43507.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_synchronicity_checksum_func_import_pdf_document() != 42164.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
@@ -2796,6 +2817,16 @@ public typealias FfiConverterTypeTimestamp = FfiConverterString
)
}
fun `getAllDocumentMimeTypes`(): List<kotlin.String> {
return FfiConverterSequenceString.lift(
uniffiRustCall() { _status ->
UniffiLib.uniffi_synchronicity_fn_func_get_all_document_mime_types(
_status)
}
)
}
fun `getAllDocumentTypes`(): List<kotlin.String> {
return FfiConverterSequenceString.lift(
uniffiRustCall() { _status ->
@@ -2921,6 +2952,28 @@ public typealias FfiConverterTypeTimestamp = FfiConverterString
}
@Throws(SyncException::class) fun `importImageDocument`(`fileName`: kotlin.String, `fileExt`: kotlin.String, `imageBytes`: kotlin.ByteArray): kotlin.String {
return FfiConverterString.lift(
uniffiRustCallWithError(SyncException) { _status ->
UniffiLib.uniffi_synchronicity_fn_func_import_image_document(
FfiConverterString.lower(`fileName`),FfiConverterString.lower(`fileExt`),FfiConverterByteArray.lower(`imageBytes`),_status)
}
)
}
@Throws(SyncException::class) fun `importMusicTrackDocument`(`fileName`: kotlin.String, `musicBytes`: kotlin.ByteArray): kotlin.String {
return FfiConverterString.lift(
uniffiRustCallWithError(SyncException) { _status ->
UniffiLib.uniffi_synchronicity_fn_func_import_music_track_document(
FfiConverterString.lower(`fileName`),FfiConverterByteArray.lower(`musicBytes`),_status)
}
)
}
@Throws(SyncException::class) fun `importPdfDocument`(`fileName`: kotlin.String, `pdfBytes`: kotlin.ByteArray): kotlin.String {
return FfiConverterString.lift(
uniffiRustCallWithError(SyncException) { _status ->
+1 -1
View File
@@ -4,7 +4,7 @@ _default:
TEST_ENSEMBLE_KEY := "malformed-datebook doctrine-bovine shrug-omit rigid-playback"
TEST_ENSEMBLE_KEY_TWO := "obituary-silencer handcuff-saxophone unaligned-sanitary fancy-lifter"
TEST_ENSEMBLE_KEY_TWO := "remnant-bullhorn ripcord-print immunity-caucus islamist-pusher"
# Build the Rust library for Android
[group: "build"]
+94
View File
@@ -1140,6 +1140,100 @@ pub fn import_pdf_document(file_name: String, pdf_bytes: Vec<u8>) -> Result<Stri
})
}
#[uniffi::export]
pub fn import_image_document(
file_name: String,
file_ext: String,
image_bytes: Vec<u8>,
) -> Result<String, SyncError> {
let origin_node = local_node_pubkey_required()?;
let stored = with_blob_store(|bs| {
bs.store(&image_bytes, crate::vault::DEFAULT_VAULT_ID)
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })
})?;
with_document_store(|store| {
let doc = Document::new(DocumentType::Image, None, origin_node);
let doc_id = doc.id.clone();
store.add_document(doc)?;
store.upsert_property(&Property::new(
doc_id.clone(),
PropertyKind::ImageTitle,
PropertyValue::Text(file_name),
origin_node,
))?;
store.upsert_property(&Property::new(
doc_id.clone(),
PropertyKind::ImageFileType,
PropertyValue::Text(file_ext),
origin_node,
))?;
store.add_blob_ref(&BlobRef::new(
doc_id.clone(),
stored.stored_hash,
stored.plaintext_hash,
Role::PrimaryContent,
crate::vault::DEFAULT_VAULT_ID,
image_bytes.len() as u64,
))?;
Ok(doc_id.to_string())
})
}
#[uniffi::export]
pub fn import_music_track_document(
file_name: String,
music_bytes: Vec<u8>,
) -> Result<String, SyncError> {
let origin_node = local_node_pubkey_required()?;
let stored = with_blob_store(|bs| {
bs.store(&music_bytes, crate::vault::DEFAULT_VAULT_ID)
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })
})?;
with_document_store(|store| {
let doc = Document::new(DocumentType::MusicTrack, None, origin_node);
let doc_id = doc.id.clone();
store.add_document(doc)?;
store.upsert_property(&Property::new(
doc_id.clone(),
PropertyKind::MusicTrackTitle,
PropertyValue::Text(file_name),
origin_node,
))?;
store.upsert_property(&Property::new(
doc_id.clone(),
PropertyKind::MusicTrackFileType,
PropertyValue::Text("mp3".to_string()),
origin_node,
))?;
store.add_blob_ref(&BlobRef::new(
doc_id.clone(),
stored.stored_hash,
stored.plaintext_hash,
Role::PrimaryContent,
crate::vault::DEFAULT_VAULT_ID,
music_bytes.len() as u64,
))?;
Ok(doc_id.to_string())
})
}
#[uniffi::export]
pub fn get_all_document_mime_types() -> Vec<String> {
crate::documents::import::all_document_mime_types().iter().map(|s| s.to_string()).collect()
}
#[uniffi::export]
pub fn get_document_blob_bytes(document_id: String) -> Result<Vec<u8>, SyncError> {
let blob_ref = with_document_store(|store| {