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 * `` 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() private var blobCounter = 0 private var uploadCallback: ValueCallback>? = 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 () ───────────────────────────────────── /** * 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>, 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?): 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() } }