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()
}
}