Image file inputs went straight to the gallery picker when the camera was hidden by the system chooser or its permission was missing. They now show a Tomar foto / Elegir de la galería dialog (capture fields open the camera directly), and a denied camera permission explains how to enable it in Settings instead of silently falling back to the picker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
426 lines
18 KiB
Kotlin
426 lines
18 KiB
Kotlin
package mx.joykoo.scanner
|
|
|
|
import android.app.Activity
|
|
import android.app.AlertDialog
|
|
import android.app.DownloadManager
|
|
import android.content.ActivityNotFoundException
|
|
import android.content.BroadcastReceiver
|
|
import android.content.ContentValues
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.IntentFilter
|
|
import android.media.MediaScannerConnection
|
|
import android.net.Uri
|
|
import android.os.Build
|
|
import android.os.Environment
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
import android.provider.MediaStore
|
|
import android.provider.Settings
|
|
import android.util.Base64
|
|
import android.util.Log
|
|
import android.webkit.CookieManager
|
|
import android.webkit.MimeTypeMap
|
|
import android.webkit.URLUtil
|
|
import android.webkit.ValueCallback
|
|
import android.webkit.WebChromeClient.FileChooserParams
|
|
import android.webkit.WebView
|
|
import android.widget.Toast
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.core.content.ContextCompat
|
|
import org.json.JSONObject
|
|
import java.io.File
|
|
import java.text.SimpleDateFormat
|
|
import java.util.Date
|
|
import java.util.Locale
|
|
|
|
/**
|
|
* Saves files the site downloads into the device's Downloads folder, and serves
|
|
* `<input type=file>` uploads, offering the camera for image fields. Photos taken
|
|
* through the camera are kept in Pictures/Joykoo as well as uploaded.
|
|
*
|
|
* Downloads: https URLs on allowed hosts go through DownloadManager (with the session
|
|
* cookie); `data:` URLs are decoded natively; `blob:` URLs are read in the page and
|
|
* handed back through evaluateJavascript polling, so no JavaScript interface is exposed.
|
|
*
|
|
* Must be constructed in onCreate (it registers an activity-result launcher).
|
|
*/
|
|
class FileTransfers(
|
|
private val activity: AppCompatActivity,
|
|
private val permissions: Permissions,
|
|
private val webView: () -> WebView,
|
|
) {
|
|
private val handler = Handler(Looper.getMainLooper())
|
|
private val downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
private val activeDownloads = mutableSetOf<Long>()
|
|
private var blobCounter = 0
|
|
|
|
private var uploadCallback: ValueCallback<Array<Uri>>? = null
|
|
private var pendingPhotoUri: Uri? = null
|
|
|
|
private val chooserLauncher = activity.registerForActivityResult(
|
|
ActivityResultContracts.StartActivityForResult()
|
|
) { result -> onChooserResult(result.resultCode, result.data) }
|
|
|
|
private val downloadCompleteReceiver = object : BroadcastReceiver() {
|
|
override fun onReceive(context: Context, intent: Intent) {
|
|
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
|
if (activeDownloads.remove(id)) reportDownloadResult(id)
|
|
}
|
|
}
|
|
|
|
init {
|
|
// Sent by the system download provider, so the receiver must be exported.
|
|
ContextCompat.registerReceiver(
|
|
activity, downloadCompleteReceiver,
|
|
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
|
|
ContextCompat.RECEIVER_EXPORTED
|
|
)
|
|
}
|
|
|
|
fun onDestroy() {
|
|
handler.removeCallbacksAndMessages(null)
|
|
activity.unregisterReceiver(downloadCompleteReceiver)
|
|
uploadCallback?.onReceiveValue(null)
|
|
uploadCallback = null
|
|
}
|
|
|
|
// ── Downloads ───────────────────────────────────────────────────────
|
|
|
|
/** Wire to WebView.setDownloadListener. */
|
|
fun onDownloadStart(url: String, userAgent: String, contentDisposition: String?, mimeType: String?) {
|
|
val fileName = fileNameFor(url, contentDisposition, mimeType)
|
|
val save: () -> Unit = when {
|
|
url.startsWith("blob:") -> { { readBlob(url, fileName, mimeType) } }
|
|
url.startsWith("data:") -> { { saveDataUrl(url, fileName) } }
|
|
isAllowedHttps(url) -> { { enqueueDownload(url, userAgent, fileName, mimeType) } }
|
|
else -> {
|
|
Log.w(TAG, "Blocked download from $url")
|
|
return
|
|
}
|
|
}
|
|
permissions.withPermissions(Permissions.STORAGE) {
|
|
if (permissions.allGranted(Permissions.STORAGE)) save()
|
|
else toast(R.string.download_permission_denied)
|
|
}
|
|
}
|
|
|
|
private fun enqueueDownload(url: String, userAgent: String, fileName: String, mimeType: String?) {
|
|
try {
|
|
val request = DownloadManager.Request(Uri.parse(url)).apply {
|
|
if (!mimeType.isNullOrBlank()) setMimeType(mimeType)
|
|
CookieManager.getInstance().getCookie(url)?.let { addRequestHeader("Cookie", it) }
|
|
addRequestHeader("User-Agent", userAgent)
|
|
setTitle(fileName)
|
|
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
|
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
|
|
}
|
|
activeDownloads += downloadManager.enqueue(request)
|
|
toast(activity.getString(R.string.download_started, fileName))
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Download failed to start: $url", e)
|
|
toast(R.string.download_failed)
|
|
}
|
|
}
|
|
|
|
private fun reportDownloadResult(id: Long) {
|
|
downloadManager.query(DownloadManager.Query().setFilterById(id))?.use { c ->
|
|
if (!c.moveToFirst()) return
|
|
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
|
val title = c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE))
|
|
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
|
toast(activity.getString(R.string.download_done, title))
|
|
} else {
|
|
Log.e(TAG, "Download $id ended with status $status")
|
|
toast(R.string.download_failed)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun saveDataUrl(url: String, fileName: String) {
|
|
val comma = url.indexOf(',')
|
|
if (comma < 0) {
|
|
toast(R.string.download_failed)
|
|
return
|
|
}
|
|
val header = url.substring(5, comma)
|
|
val payload = url.substring(comma + 1)
|
|
val bytes = try {
|
|
if (header.endsWith(";base64")) Base64.decode(payload, Base64.DEFAULT)
|
|
else Uri.decode(payload).toByteArray()
|
|
} catch (e: IllegalArgumentException) {
|
|
Log.e(TAG, "Bad data: URL", e)
|
|
toast(R.string.download_failed)
|
|
return
|
|
}
|
|
val mime = header.substringBefore(';').ifBlank { "application/octet-stream" }
|
|
saveBytes(fileName, mime, bytes)
|
|
}
|
|
|
|
/**
|
|
* Reads a blob: URL inside the page as a data: URL and polls for the result.
|
|
* The blob comes from the createObjectURL hook in [BLOB_HOOK_JS] when available,
|
|
* since sites often revoke the URL right after triggering the download.
|
|
*/
|
|
private fun readBlob(url: String, fileName: String, mimeType: String?) {
|
|
val key = "b${++blobCounter}"
|
|
webView().evaluateJavascript("""
|
|
(function(url, key) {
|
|
var store = window.__jkBlobResults = window.__jkBlobResults || {};
|
|
var blob = window.__jkBlobs && window.__jkBlobs[url];
|
|
(blob ? Promise.resolve(blob) : fetch(url).then(function(r) { return r.blob(); }))
|
|
.then(function(b) {
|
|
return new Promise(function(resolve, reject) {
|
|
var fr = new FileReader();
|
|
fr.onload = function() { resolve(fr.result); };
|
|
fr.onerror = function() { reject(fr.error); };
|
|
fr.readAsDataURL(b);
|
|
});
|
|
})
|
|
.then(function(data) { store[key] = { ok: true, data: data }; })
|
|
.catch(function(e) { store[key] = { ok: false, error: String(e) }; });
|
|
})(${JSONObject.quote(url)}, ${JSONObject.quote(key)});
|
|
""".trimIndent(), null)
|
|
pollBlob(key, fileName, mimeType, attemptsLeft = BLOB_POLL_ATTEMPTS)
|
|
}
|
|
|
|
private fun pollBlob(key: String, fileName: String, mimeType: String?, attemptsLeft: Int) {
|
|
handler.postDelayed({
|
|
webView().evaluateJavascript("""
|
|
(function(key) {
|
|
var store = window.__jkBlobResults, r = store && store[key];
|
|
if (r) delete store[key];
|
|
return r || null;
|
|
})(${JSONObject.quote(key)});
|
|
""".trimIndent()) { json ->
|
|
if (json == null || json == "null") {
|
|
if (attemptsLeft > 1) pollBlob(key, fileName, mimeType, attemptsLeft - 1)
|
|
else {
|
|
Log.e(TAG, "Timed out reading blob download")
|
|
toast(R.string.download_failed)
|
|
}
|
|
return@evaluateJavascript
|
|
}
|
|
val result = JSONObject(json)
|
|
if (result.optBoolean("ok")) {
|
|
saveDataUrl(result.getString("data"), fileName)
|
|
} else {
|
|
Log.e(TAG, "Blob read failed: ${result.optString("error")}")
|
|
toast(R.string.download_failed)
|
|
}
|
|
}
|
|
}, BLOB_POLL_INTERVAL_MS)
|
|
}
|
|
|
|
/** Writes [bytes] to the public Downloads folder. */
|
|
private fun saveBytes(fileName: String, mimeType: String, bytes: ByteArray) {
|
|
try {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
val resolver = activity.contentResolver
|
|
val values = ContentValues().apply {
|
|
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
|
|
put(MediaStore.Downloads.MIME_TYPE, mimeType)
|
|
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
|
put(MediaStore.Downloads.IS_PENDING, 1)
|
|
}
|
|
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
|
?: throw IllegalStateException("MediaStore insert returned null")
|
|
resolver.openOutputStream(uri)!!.use { it.write(bytes) }
|
|
values.clear()
|
|
values.put(MediaStore.Downloads.IS_PENDING, 0)
|
|
resolver.update(uri, values, null, null)
|
|
} else {
|
|
@Suppress("DEPRECATION")
|
|
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
|
dir.mkdirs()
|
|
val file = uniqueFile(dir, fileName)
|
|
file.writeBytes(bytes)
|
|
MediaScannerConnection.scanFile(activity, arrayOf(file.path), arrayOf(mimeType), null)
|
|
}
|
|
toast(activity.getString(R.string.download_done, fileName))
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Saving $fileName failed", e)
|
|
toast(R.string.download_failed)
|
|
}
|
|
}
|
|
|
|
private fun uniqueFile(dir: File, name: String): File {
|
|
var file = File(dir, name)
|
|
var n = 1
|
|
while (file.exists()) {
|
|
file = File(dir, "${name.substringBeforeLast('.')} ($n)" +
|
|
(name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }))
|
|
n++
|
|
}
|
|
return file
|
|
}
|
|
|
|
private fun fileNameFor(url: String, contentDisposition: String?, mimeType: String?): String {
|
|
// guessFileName only understands http(s); blob/data names come from the disposition.
|
|
val name = URLUtil.guessFileName(
|
|
if (url.startsWith("http")) url else "", contentDisposition, mimeType
|
|
).replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
|
if (name.contains('.')) return name
|
|
val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
|
|
return if (ext != null) "$name.$ext" else name
|
|
}
|
|
|
|
private fun isAllowedHttps(url: String): Boolean {
|
|
val uri = Uri.parse(url)
|
|
return uri.scheme == "https" && uri.host?.let(AppConfig::isAllowedHost) == true
|
|
}
|
|
|
|
// ── Uploads (<input type=file>) ─────────────────────────────────────
|
|
|
|
/**
|
|
* Wire to WebChromeClient.onShowFileChooser. Image fields get an explicit
|
|
* "Tomar foto / Elegir archivo" choice (or the camera directly for `capture` fields)
|
|
* instead of the system chooser, which on many devices opens the gallery picker
|
|
* and hides the camera.
|
|
*/
|
|
fun onShowFileChooser(callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
|
|
uploadCallback?.onReceiveValue(null)
|
|
uploadCallback = callback
|
|
if (!acceptsImages(params.acceptTypes)) {
|
|
launchPicker(params)
|
|
return true
|
|
}
|
|
val needed = Permissions.CAMERA + Permissions.STORAGE
|
|
permissions.withPermissions(needed) {
|
|
when {
|
|
!permissions.allGranted(needed) -> showCameraDenied(params)
|
|
params.isCaptureEnabled -> launchCamera(params)
|
|
else -> showPhotoSourceDialog(params)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
private fun showPhotoSourceDialog(params: FileChooserParams) {
|
|
AlertDialog.Builder(activity)
|
|
.setTitle(R.string.upload_title)
|
|
.setItems(arrayOf(
|
|
activity.getString(R.string.upload_take_photo),
|
|
activity.getString(R.string.upload_pick_file),
|
|
)) { _, which -> if (which == 0) launchCamera(params) else launchPicker(params) }
|
|
.setOnCancelListener { onChooserResult(Activity.RESULT_CANCELED, null) }
|
|
.show()
|
|
}
|
|
|
|
/** Camera permission was refused; Android stops prompting after repeated denials,
|
|
* so offer the app's settings page as the way to turn it back on. */
|
|
private fun showCameraDenied(params: FileChooserParams) {
|
|
AlertDialog.Builder(activity)
|
|
.setTitle(R.string.camera_denied_title)
|
|
.setMessage(R.string.camera_denied_message)
|
|
.setPositiveButton(R.string.camera_open_settings) { _, _ ->
|
|
onChooserResult(Activity.RESULT_CANCELED, null)
|
|
activity.startActivity(
|
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
|
.setData(Uri.fromParts("package", activity.packageName, null))
|
|
)
|
|
}
|
|
.setNeutralButton(R.string.upload_pick_file) { _, _ -> launchPicker(params) }
|
|
.setOnCancelListener { onChooserResult(Activity.RESULT_CANCELED, null) }
|
|
.show()
|
|
}
|
|
|
|
private fun launchCamera(params: FileChooserParams) {
|
|
val camera = cameraIntent()
|
|
if (camera == null) {
|
|
toast(R.string.camera_failed)
|
|
launchPicker(params)
|
|
return
|
|
}
|
|
launch(camera)
|
|
}
|
|
|
|
private fun launchPicker(params: FileChooserParams) = launch(params.createIntent())
|
|
|
|
private fun launch(intent: Intent) {
|
|
try {
|
|
chooserLauncher.launch(intent)
|
|
} catch (e: ActivityNotFoundException) {
|
|
Log.e(TAG, "No app for ${intent.action}", e)
|
|
toast(R.string.camera_failed)
|
|
onChooserResult(Activity.RESULT_CANCELED, null)
|
|
}
|
|
}
|
|
|
|
private fun cameraIntent(): Intent? {
|
|
val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
|
val values = ContentValues().apply {
|
|
put(MediaStore.Images.Media.DISPLAY_NAME, "JOYKOO_$stamp.jpg")
|
|
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Joykoo")
|
|
}
|
|
}
|
|
val uri = try {
|
|
activity.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Could not create photo entry", e)
|
|
null
|
|
} ?: return null
|
|
pendingPhotoUri = uri
|
|
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
|
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
|
|
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
}
|
|
|
|
private fun onChooserResult(resultCode: Int, data: Intent?) {
|
|
val photo = pendingPhotoUri
|
|
pendingPhotoUri = null
|
|
val picked = FileChooserParams.parseResult(resultCode, data)
|
|
val result = when {
|
|
resultCode != Activity.RESULT_OK -> null
|
|
picked != null -> picked
|
|
photo != null -> arrayOf(photo) // camera writes to EXTRA_OUTPUT and returns no data
|
|
else -> null
|
|
}
|
|
// Drop the empty gallery entry unless the camera actually filled it.
|
|
if (photo != null && result?.contains(photo) != true) {
|
|
try { activity.contentResolver.delete(photo, null, null) } catch (_: Exception) {}
|
|
}
|
|
uploadCallback?.onReceiveValue(result)
|
|
uploadCallback = null
|
|
}
|
|
|
|
private fun acceptsImages(acceptTypes: Array<String>?): Boolean {
|
|
val types = acceptTypes.orEmpty().flatMap { it.split(',') }.map { it.trim().lowercase() }
|
|
.filter { it.isNotEmpty() }
|
|
return types.isEmpty() || types.any {
|
|
it.startsWith("image/") || it == "*/*" || it in IMAGE_EXTENSIONS
|
|
}
|
|
}
|
|
|
|
private fun toast(res: Int) = toast(activity.getString(res))
|
|
private fun toast(text: String) = Toast.makeText(activity, text, Toast.LENGTH_LONG).show()
|
|
|
|
companion object {
|
|
private const val TAG = "JoykooScanner"
|
|
private const val BLOB_POLL_INTERVAL_MS = 200L
|
|
private const val BLOB_POLL_ATTEMPTS = 150 // 30 s
|
|
private val IMAGE_EXTENSIONS = setOf(".jpg", ".jpeg", ".png", ".webp", ".heic", ".gif")
|
|
|
|
/** Injected at document start: remembers blobs behind object URLs and delays
|
|
* revocation, so a download whose URL the site revokes immediately can still be read. */
|
|
val BLOB_HOOK_JS = """
|
|
(function() {
|
|
if (window.__jkBlobs) return;
|
|
var blobs = window.__jkBlobs = {};
|
|
var create = URL.createObjectURL, revoke = URL.revokeObjectURL;
|
|
URL.createObjectURL = function(obj) {
|
|
var url = create.apply(URL, arguments);
|
|
if (obj instanceof Blob) blobs[url] = obj;
|
|
return url;
|
|
};
|
|
URL.revokeObjectURL = function(url) {
|
|
setTimeout(function() { delete blobs[url]; revoke.call(URL, url); }, 60000);
|
|
};
|
|
})();
|
|
""".trimIndent()
|
|
}
|
|
}
|