ViewModleでの書き方
Androidで他デバイスと共有するなら、Storage Access Framework(SAF)経由でユーザーが選択したファイルへ書き出し/読み込みするのがよいです。
TableDataStore に追加するなら以下のような形になります。
import android.content.Context
import android.net.Uri
エクスポート
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
/** * エクスポート * SAF(CreateDocument)で取得した Uri に保存する。 */ suspend fun exportToUri( uri: Uri, tableData: List<List<String>>, password: String? ): Boolean = withContext(Dispatchers.IO) { clearError() try { val json = encodeToJson(tableData) val bytes = if (!password.isNullOrEmpty()) { CryptoManager.encrypt(json, password) } else { json } getApplication<Application>() .contentResolver .openOutputStream(uri) ?.use { output -> output.write(bytes) output.flush() } ?: throw IOException("出力ストリームを開けません。") true } catch (e: CryptoManager.EncryptionException.EmptyPassword) { setError("パスワードが入力されていません。") false } catch (e: CryptoManager.EncryptionException.EmptyData) { setError("保存するデータが空です。") false } catch (e: CryptoManager.EncryptionException.Failed) { setError("暗号化に失敗しました。") false } catch (e: Exception) { setError("エクスポートに失敗しました。(${e.message})") false } } |
インポート
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
/** * インポート * SAF(OpenDocument)で取得した Uri から読み込む。 */ suspend fun importFromUri( uri: Uri, password: String? ): List<List<String>>? = withContext(Dispatchers.IO) { clearError() try { val raw = getApplication<Application>() .contentResolver .openInputStream(uri) ?.use { it.readBytes() } ?: throw IOException("入力ストリームを開けません。") val json = if (!password.isNullOrEmpty()) { CryptoManager.decrypt(raw, password) } else { raw } decodeFromJson(json) } catch (e: CryptoManager.DecryptionException.EmptyPassword) { setError("パスワードが入力されていません。") null } catch (e: CryptoManager.DecryptionException.DataTooShort) { setError("データが破損しています。") null } catch (e: CryptoManager.DecryptionException.WrongPassword) { setError("パスワードが正しくありません。") null } catch (e: CryptoManager.DecryptionException.CorruptedData) { setError("データが改ざんされているか破損しています。") null } catch (e: JSONException) { setError("データ形式が不正です。") null } catch (e: Exception) { setError("インポートに失敗しました。(${e.message})") null } } |
Compose側での変数宣言
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
val exportLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("application/octet-stream") ) { uri -> uri?.let { scope.launch { dataStore.exportToUri( uri = it, tableData = viewModel.tableData, password = password ) } } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
val importLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument() ) { uri -> uri?.let { scope.launch { dataStore.importFromUri( uri = it, password = password )?.let { data -> viewModel.replaceAll(data) } } } } |
で呼び出せます。
さらにiOS版との互換性を重視するなら、エクスポート時の拡張子を .enc にして、
CreateDocument("application/octet-stream")
の代わりに
CreateDocument("application/x-tablememo")
のような独自MIMEタイプを定義しておくと、ユーザーが誤って編集しにくくなります。現在の save() / load()と同じ暗号化フォーマットを使うため、iOS版の tableData.enc とAndroid版のエクスポートファイルはそのまま相互利用できます。
実際の使い方
Jetpack Composeでは通常、rememberLauncherForActivityResult() と ActivityResultContracts を使います。
インポート(ファイルを選択する)
ボタンを押してAndroid標準のファイル選択ダイアログを表示する例です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
val importLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { uri: Uri? -> if (uri != null) { scope.launch { dataStore.importFromUri( uri = uri, password = password )?.let { data -> viewModel.replaceAll(data) } } } } Button( onClick = { importLauncher.launch( arrayOf("*/*") ) } ) { Text("インポート") } |
エクスポート(保存先を選択する)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
val exportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument( "application/octet-stream" ) ) { uri: Uri? -> if (uri != null) { scope.launch { dataStore.exportToUri( uri = uri, tableData = viewModel.tableData, password = password ) } } } Button( onClick = { exportLauncher.launch("tableData.enc") } ) { Text("エクスポート") } |
.enc ファイルだけ表示したい場合
Androidは独自拡張子での絞り込みが少し弱いので、
importLauncher.launch(
arrayOf("application/octet-stream")
)
または
importLauncher.launch(
arrayOf("*/*")
)
としておいて、選択後に
val fileName = DocumentFile.fromSingleUri(
context,
uri
)?.name
if (fileName?.endsWith(".enc") != true) {
// エラー表示
return@launch
}
のように判定するのが実用的です。
長期的に同じファイルを扱う場合
ユーザーが一度選択したファイルを次回起動時も使いたいなら、
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
を実行しておくと、再度ファイル選択ダイアログを出さなくてもその Uri を保存して利用できます。
ただ、iOS版の「共有用インポート・エクスポート」に近い使い方なら、毎回 OpenDocument / CreateDocument を呼ぶだけで十分だと思います。