package eu.chj.sm_mobile import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent import android.database.Cursor import android.graphics.Bitmap import android.graphics.Color import android.graphics.pdf.PdfRenderer import android.net.Uri import android.os.Handler import android.os.Looper import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.OpenableColumns import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.FileProvider import androidx.fragment.app.FragmentActivity import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.util.concurrent.Executors /** * Files on the device through the Storage Access Framework. Only documents * the user picks (and folders the user grants) are accessed; no broad * storage permission is requested. */ class Documents(private val activity: FragmentActivity) : MethodChannel.MethodCallHandler { var channel: MethodChannel? = null private val io = Executors.newSingleThreadExecutor() private val main = Handler(Looper.getMainLooper()) private var pending: ((Intent?) -> Unit)? = null private val incoming = mutableListOf>() private val launcher: ActivityResultLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> val callback = pending pending = null callback?.invoke(if (r.resultCode == Activity.RESULT_OK) r.data else null) } private val resolver get() = activity.contentResolver private fun launch(intent: Intent, result: MethodChannel.Result, then: (Intent?) -> Unit) { if (pending != null) { result.error("busy", "A picker is already open", null) return } pending = then try { launcher.launch(intent) } catch (e: ActivityNotFoundException) { pending = null result.error("unsupported", "No document provider", null) } } private fun background(result: MethodChannel.Result, work: () -> Any?) { io.execute { try { val value = work() main.post { result.success(value) } } catch (e: SecurityException) { main.post { result.error("denied", e.message, null) } } catch (e: FileNotFoundException) { main.post { result.error("notFound", e.message, null) } } catch (e: ReadOnly) { main.post { result.error("readOnly", e.message, null) } } catch (e: IOException) { val code = if (e.message?.contains("ENOSPC") == true || e.message?.contains("No space") == true) "noSpace" else "io" main.post { result.error(code, e.message, null) } } catch (e: Exception) { main.post { result.error("io", e.message, null) } } } } class ReadOnly(message: String) : Exception(message) private fun persist(uri: Uri, flags: Int) { val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION try { resolver.takePersistableUriPermission(uri, flags and rw) } catch (_: SecurityException) { try { resolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (_: SecurityException) { // A temporary grant only (for example a file shared by another app). } } } private fun canWrite(uri: Uri): Boolean = activity.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == android.content.pm.PackageManager.PERMISSION_GRANTED private fun describe(uri: Uri, location: String? = null): Map { var name: String? = null var size: Long? = null var mime: String? = null var modified: Long? = null var flags = 0 try { resolver.query(uri, null, null, null, null)?.use { c: Cursor -> if (c.moveToFirst()) { fun col(n: String) = c.getColumnIndex(n) col(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { name = c.getString(it) } col(OpenableColumns.SIZE).takeIf { it >= 0 && !c.isNull(it) }?.let { size = c.getLong(it) } col(DocumentsContract.Document.COLUMN_MIME_TYPE).takeIf { it >= 0 }?.let { mime = c.getString(it) } col(DocumentsContract.Document.COLUMN_LAST_MODIFIED).takeIf { it >= 0 && !c.isNull(it) } ?.let { modified = c.getLong(it) } col(DocumentsContract.Document.COLUMN_FLAGS).takeIf { it >= 0 }?.let { flags = c.getInt(it) } } } } catch (_: Exception) { // Some providers answer only part of the columns. } if (mime == null) mime = resolver.getType(uri) val directory = mime == DocumentsContract.Document.MIME_TYPE_DIR val providerWrite = flags and DocumentsContract.Document.FLAG_SUPPORTS_WRITE != 0 || !DocumentsContract.isDocumentUri(activity, uri) return mapOf( "id" to uri.toString(), "name" to (name ?: uri.lastPathSegment ?: "?"), "size" to size, "mime" to mime, "modified" to modified, "directory" to directory, "writable" to (!directory && providerWrite && canWrite(uri)), "location" to (location ?: uri.authority), ) } private fun readAll(uri: Uri): ByteArray = resolver.openInputStream(uri)?.use { it.readBytes() } ?: throw FileNotFoundException(uri.toString()) private fun writeAll(uri: Uri, bytes: ByteArray) { val out = try { resolver.openOutputStream(uri, "wt") } catch (_: IllegalArgumentException) { resolver.openOutputStream(uri, "w") } ?: throw ReadOnly("No output stream") out.use { it.write(bytes) it.flush() } } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "pick" -> { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("*/*") .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, call.argument("multiple") == true) .addFlags( Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION, ) launch(intent, result) { data -> if (data == null) return@launch result.success(emptyList()) val uris = mutableListOf() data.clipData?.let { clip -> for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri) } if (uris.isEmpty()) data.data?.let { uris.add(it) } background(result) { uris.map { persist(it, data.flags) describe(it) } } } } "pickFolder" -> { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).addFlags( Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION, ) launch(intent, result) { data -> val tree = data?.data ?: return@launch result.success(null) background(result) { persist(tree, data.flags) val doc = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) describe(doc) } } } "list" -> background(result) { val folder = Uri.parse(call.argument("id")!!) val children = DocumentsContract.buildChildDocumentsUriUsingTree( folder, DocumentsContract.getDocumentId(folder), ) val out = mutableListOf>() resolver.query( children, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), null, null, null, )?.use { c -> while (c.moveToNext()) { out.add(describe(DocumentsContract.buildDocumentUriUsingTree(folder, c.getString(0)))) } } out } "stat" -> background(result) { describe(Uri.parse(call.argument("id")!!)) } "read" -> background(result) { readAll(Uri.parse(call.argument("id")!!)) } "write" -> background(result) { val uri = Uri.parse(call.argument("id")!!) val bytes = call.argument("bytes")!! if (!canWrite(uri)) throw ReadOnly("No write permission") // SAF has no atomic replace: keep the old content and put it back if // writing fails part way. val previous = try { readAll(uri) } catch (_: Exception) { null } try { writeAll(uri, bytes) } catch (e: Exception) { if (previous != null) try { writeAll(uri, previous) } catch (_: Exception) {} throw e } describe(uri) } "saveAs" -> { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType(call.argument("mime") ?: "application/octet-stream") .putExtra(Intent.EXTRA_TITLE, call.argument("name")) val bytes = call.argument("bytes")!! launch(intent, result) { data -> val uri = data?.data ?: return@launch result.success(null) background(result) { persist(uri, data.flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) writeAll(uri, bytes) describe(uri) } } } "create" -> background(result) { val folder = Uri.parse(call.argument("folder")!!) val uri = DocumentsContract.createDocument( resolver, folder, call.argument("mime") ?: "application/octet-stream", call.argument("name")!!, ) ?: throw ReadOnly("Cannot create") writeAll(uri, call.argument("bytes")!!) describe(uri) } "preview" -> { val file = File(call.argument("path")!!) if (!file.canonicalPath.startsWith(activity.cacheDir.canonicalPath)) { return result.error("denied", "Only private temporary copies", null) } val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.smfiles", file) val view = Intent(Intent.ACTION_VIEW) .setDataAndType(uri, call.argument("mime") ?: resolver.getType(uri) ?: "*/*") .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) try { activity.startActivity(Intent.createChooser(view, file.name)) result.success(true) } catch (_: ActivityNotFoundException) { result.success(false) } } "pdfPageCount" -> background(result) { ParcelFileDescriptor.open(File(call.argument("path")!!), ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> PdfRenderer(fd).use { it.pageCount } } } "pdfPage" -> background(result) { val width = call.argument("width")!!.coerceIn(200, 3000) ParcelFileDescriptor.open(File(call.argument("path")!!), ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> PdfRenderer(fd).use { renderer -> renderer.openPage(call.argument("index")!!).use { page -> val height = (width.toLong() * page.height / page.width).toInt().coerceAtLeast(1) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) bitmap.eraseColor(Color.WHITE) page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) val out = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) bitmap.recycle() out.toByteArray() } } } } "takeIncoming" -> { val list = incoming.toList() incoming.clear() result.success(list) } "forget" -> { try { resolver.releasePersistableUriPermission( Uri.parse(call.argument("id")!!), Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) } catch (_: Exception) {} result.success(null) } else -> result.notImplemented() } } /** Files from "Open with" / "Share" in other apps. */ fun handle(intent: Intent?) { if (intent == null) return val uris = mutableListOf() when (intent.action) { Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) } Intent.ACTION_SEND -> @Suppress("DEPRECATION") (intent.getParcelableExtra(Intent.EXTRA_STREAM))?.let { uris.add(it) } Intent.ACTION_SEND_MULTIPLE -> @Suppress("DEPRECATION") intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let { uris.addAll(it) } } val content = uris.filter { it.scheme == "content" } if (content.isEmpty()) return io.execute { val docs = content.map { describe(it) } main.post { val ch = channel if (ch == null) incoming.addAll(docs) else { incoming.addAll(docs) ch.invokeMethod("incoming", incoming.toList()) incoming.clear() } } } } }