Save downloads and camera photos to the device, prompt for permissions
All checks were successful
APK-BUILD/pipeline/head This commit looks good
All checks were successful
APK-BUILD/pipeline/head This commit looks good
- Downloads (https on allowed hosts, data: and blob: URLs) are saved to the Downloads folder, with a toast when they finish - Image upload fields offer the camera; photos are kept in Pictures/Joykoo - In-page camera (getUserMedia) is granted to allowed hosts only - Camera and storage (Android 9 and below) permissions are requested at first launch and again on demand Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4401519166
commit
d1b8c34fe1
13
CLAUDE.md
13
CLAUDE.md
@ -25,9 +25,11 @@ app/src/main/java/mx/joykoo/scanner/
|
|||||||
NoSoftKeyboardWebView.kt — IME suppression via null InputConnection
|
NoSoftKeyboardWebView.kt — IME suppression via null InputConnection
|
||||||
ScannerIntegration.kt — Scanner lifecycle interface
|
ScannerIntegration.kt — Scanner lifecycle interface
|
||||||
BroadcastScannerIntegration.kt — Intent-output scans → JS injection (React-safe value setter)
|
BroadcastScannerIntegration.kt — Intent-output scans → JS injection (React-safe value setter)
|
||||||
KioskController.kt — Lock Task kiosk toggle (persisted); device-owner full lockdown + HOME alias, else screen pinning
|
KioskController.kt — Lock Task kiosk toggle (session-only until KIOSK_PERSISTED); device-owner full lockdown + HOME alias, else screen pinning
|
||||||
KioskAdminReceiver.kt — DeviceAdminReceiver for `dpm set-device-owner`
|
KioskAdminReceiver.kt — DeviceAdminReceiver for `dpm set-device-owner`
|
||||||
StatusStrip.kt — App-drawn status strip (Wi-Fi + battery only); system bars stay hidden
|
StatusStrip.kt — App-drawn status strip (Wi-Fi + battery only); system bars stay hidden
|
||||||
|
Permissions.kt — Runtime permission prompts (camera, storage ≤ Android 9); asked at launch and on demand
|
||||||
|
FileTransfers.kt — Downloads → Downloads folder; file uploads with camera option; photos kept in Pictures/Joykoo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
@ -79,10 +81,17 @@ All configurable values are in `AppConfig.kt`:
|
|||||||
|
|
||||||
## Kiosk & Errors
|
## Kiosk & Errors
|
||||||
|
|
||||||
- Kiosk mode (`KioskController`): when on, admin dialog hides "Salir" and Back never exits. Device owner required for real lockdown; the app can't be uninstalled while device owner, so set it only with the release-signed APK.
|
- Kiosk mode (`KioskController`): **off by default and not persisted** while it is being tested (`AppConfig.KIOSK_PERSISTED = false`) — each launch starts unlocked and clears any leftover lockdown; scanning `KIOSK_BARCODE` toggles it for the session. When on, admin dialog hides "Salir" and Back never exits. Device owner required for real lockdown; the app can't be uninstalled while device owner, so set it only with the release-signed APK.
|
||||||
- Error overlay: per-cause messages (no Wi-Fi with auto-retry on reconnect, unreachable, 5xx, SSL, repeated renderer crash). 4xx responses are left to the site.
|
- Error overlay: per-cause messages (no Wi-Fi with auto-retry on reconnect, unreachable, 5xx, SSL, repeated renderer crash). 4xx responses are left to the site.
|
||||||
- Renderer crash (`onRenderProcessGone`): the WebView is replaced and the last URL (tracked via `doUpdateVisitedHistory`) reloaded.
|
- Renderer crash (`onRenderProcessGone`): the WebView is replaced and the last URL (tracked via `doUpdateVisitedHistory`) reloaded.
|
||||||
|
|
||||||
|
## Files & Permissions
|
||||||
|
|
||||||
|
- Permissions are requested at first launch (camera; storage on Android ≤ 9) and again when a feature needs one.
|
||||||
|
- Downloads: https on allowed hosts via `DownloadManager` (session cookie forwarded); `data:` decoded natively; `blob:` read in-page and returned via `evaluateJavascript` polling (no JS interface). A document-start hook keeps blobs readable if the site revokes the URL immediately. Saved to Downloads, with a toast on completion.
|
||||||
|
- Uploads: `<input type=file>` image fields offer the camera; `capture` opens it directly. Photos are saved to Pictures/Joykoo.
|
||||||
|
- In-page camera (`getUserMedia`) is granted to allowed hosts only, video only.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- HTTPS only, no cleartext, mixed content never allowed, no SSL error bypass
|
- HTTPS only, no cleartext, mixed content never allowed, no SSL error bypass
|
||||||
|
|||||||
@ -4,6 +4,14 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<!-- Camera for photo uploads and in-page camera (getUserMedia). -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<!-- Saving downloads/photos to shared storage; not needed from Android 10 (MediaStore). -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@ -12,6 +12,9 @@ object AppConfig {
|
|||||||
"joykoo.mx",
|
"joykoo.mx",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun isAllowedHost(host: String): Boolean =
|
||||||
|
ALLOWED_HOSTS.any { host == it || host.endsWith(".$it") }
|
||||||
|
|
||||||
/** Number of rapid Back presses required to exit the app when there is no WebView history. */
|
/** Number of rapid Back presses required to exit the app when there is no WebView history. */
|
||||||
const val BACK_PRESS_EXIT_COUNT = 3
|
const val BACK_PRESS_EXIT_COUNT = 3
|
||||||
|
|
||||||
|
|||||||
377
app/src/main/java/mx/joykoo/scanner/FileTransfers.kt
Normal file
377
app/src/main/java/mx/joykoo/scanner/FileTransfers.kt
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
package mx.joykoo.scanner
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
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.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. */
|
||||||
|
fun onShowFileChooser(callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
|
||||||
|
uploadCallback?.onReceiveValue(null)
|
||||||
|
uploadCallback = callback
|
||||||
|
val offerCamera = acceptsImages(params.acceptTypes)
|
||||||
|
val needed = if (offerCamera) Permissions.CAMERA + Permissions.STORAGE else emptyArray()
|
||||||
|
permissions.withPermissions(needed) { launchChooser(params, offerCamera) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchChooser(params: FileChooserParams, offerCamera: Boolean) {
|
||||||
|
// Launching the camera without the CAMERA grant throws, so it is only offered when granted.
|
||||||
|
val camera = if (offerCamera && permissions.allGranted(Permissions.CAMERA + Permissions.STORAGE))
|
||||||
|
cameraIntent() else null
|
||||||
|
val picker = params.createIntent()
|
||||||
|
val intent = when {
|
||||||
|
camera != null && params.isCaptureEnabled -> camera
|
||||||
|
camera != null -> Intent.createChooser(picker, null)
|
||||||
|
.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(camera))
|
||||||
|
else -> picker
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
chooserLauncher.launch(intent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e(TAG, "No app to pick files", e)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ import android.view.WindowManager
|
|||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.webkit.ConsoleMessage
|
import android.webkit.ConsoleMessage
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.PermissionRequest
|
||||||
import android.webkit.RenderProcessGoneDetail
|
import android.webkit.RenderProcessGoneDetail
|
||||||
import android.webkit.SslErrorHandler
|
import android.webkit.SslErrorHandler
|
||||||
import android.webkit.ValueCallback
|
import android.webkit.ValueCallback
|
||||||
@ -37,6 +38,8 @@ import android.widget.Toast
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
@ -46,8 +49,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private lateinit var scanner: ScannerIntegration
|
private lateinit var scanner: ScannerIntegration
|
||||||
private lateinit var kiosk: KioskController
|
private lateinit var kiosk: KioskController
|
||||||
private lateinit var statusStrip: StatusStrip
|
private lateinit var statusStrip: StatusStrip
|
||||||
|
private lateinit var permissions: Permissions
|
||||||
|
private lateinit var fileTransfers: FileTransfers
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var fileUploadCallback: ValueCallback<Array<Uri>>? = null
|
|
||||||
|
|
||||||
// Error overlay state: what is shown and which URL Retry should load
|
// Error overlay state: what is shown and which URL Retry should load
|
||||||
private var currentError: ErrorKind? = null
|
private var currentError: ErrorKind? = null
|
||||||
@ -68,6 +72,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
permissions = Permissions(this)
|
||||||
|
fileTransfers = FileTransfers(this, permissions) { webView }
|
||||||
keepScreenAwake()
|
keepScreenAwake()
|
||||||
enterImmersiveMode()
|
enterImmersiveMode()
|
||||||
setupWebView()
|
setupWebView()
|
||||||
@ -84,6 +90,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
webView.loadUrl(AppConfig.HOME_URL)
|
webView.loadUrl(AppConfig.HOME_URL)
|
||||||
|
permissions.requestStartupPermissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureWebViewFocus()
|
ensureWebViewFocus()
|
||||||
@ -171,6 +178,15 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
webView.webViewClient = ScannerWebViewClient()
|
webView.webViewClient = ScannerWebViewClient()
|
||||||
webView.webChromeClient = ScannerWebChromeClient()
|
webView.webChromeClient = ScannerWebChromeClient()
|
||||||
|
webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
|
||||||
|
fileTransfers.onDownloadStart(url, userAgent, contentDisposition, mimeType)
|
||||||
|
}
|
||||||
|
// Lets blob: downloads be read even if the site revokes the URL right away.
|
||||||
|
// Older WebViews without this feature get the same hook in onPageFinished.
|
||||||
|
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||||
|
val origins = AppConfig.ALLOWED_HOSTS.flatMap { listOf("https://$it", "https://*.$it") }.toSet()
|
||||||
|
WebViewCompat.addDocumentStartJavaScript(webView, FileTransfers.BLOB_HOOK_JS, origins)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Error Overlay ───────────────────────────────────────────────────
|
// ── Error Overlay ───────────────────────────────────────────────────
|
||||||
@ -360,6 +376,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
scanner.onDestroy()
|
scanner.onDestroy()
|
||||||
|
fileTransfers.onDestroy()
|
||||||
handler.removeCallbacksAndMessages(null)
|
handler.removeCallbacksAndMessages(null)
|
||||||
webView.destroy()
|
webView.destroy()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
@ -548,7 +565,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
): Boolean {
|
): Boolean {
|
||||||
val host = request.url.host ?: return true
|
val host = request.url.host ?: return true
|
||||||
// Allow navigation within the company's domains
|
// Allow navigation within the company's domains
|
||||||
if (AppConfig.ALLOWED_HOSTS.any { host == it || host.endsWith(".$it") }) {
|
if (AppConfig.isAllowedHost(host)) {
|
||||||
return false // let WebView handle it
|
return false // let WebView handle it
|
||||||
}
|
}
|
||||||
// Block navigation to external sites
|
// Block navigation to external sites
|
||||||
@ -570,6 +587,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
CookieManager.getInstance().flush()
|
CookieManager.getInstance().flush()
|
||||||
injectKeyboardSuppression(view)
|
injectKeyboardSuppression(view)
|
||||||
|
view.evaluateJavascript(FileTransfers.BLOB_HOOK_JS, null)
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
ensureWebViewFocus()
|
ensureWebViewFocus()
|
||||||
}
|
}
|
||||||
@ -637,39 +655,27 @@ class MainActivity : AppCompatActivity() {
|
|||||||
webView: WebView,
|
webView: WebView,
|
||||||
filePathCallback: ValueCallback<Array<Uri>>,
|
filePathCallback: ValueCallback<Array<Uri>>,
|
||||||
fileChooserParams: FileChooserParams
|
fileChooserParams: FileChooserParams
|
||||||
): Boolean {
|
): Boolean = fileTransfers.onShowFileChooser(filePathCallback, fileChooserParams)
|
||||||
fileUploadCallback?.onReceiveValue(null)
|
|
||||||
fileUploadCallback = filePathCallback
|
|
||||||
|
|
||||||
val intent = fileChooserParams.createIntent()
|
/** In-page camera (getUserMedia), e.g. for camera barcode/photo widgets on the site. */
|
||||||
try {
|
override fun onPermissionRequest(request: PermissionRequest) {
|
||||||
startActivityForResult(intent, FILE_CHOOSER_REQUEST)
|
val host = request.origin.host
|
||||||
} catch (e: Exception) {
|
val wantsCamera = PermissionRequest.RESOURCE_VIDEO_CAPTURE in request.resources
|
||||||
fileUploadCallback?.onReceiveValue(null)
|
if (host == null || !AppConfig.isAllowedHost(host) || !wantsCamera) {
|
||||||
fileUploadCallback = null
|
request.deny()
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
return true
|
permissions.withPermissions(Permissions.CAMERA) {
|
||||||
}
|
if (permissions.allGranted(Permissions.CAMERA)) {
|
||||||
}
|
request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) {
|
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
|
||||||
if (requestCode == FILE_CHOOSER_REQUEST) {
|
|
||||||
fileUploadCallback?.onReceiveValue(
|
|
||||||
if (resultCode == RESULT_OK && data != null) {
|
|
||||||
WebChromeClient.FileChooserParams.parseResult(resultCode, data)
|
|
||||||
} else {
|
} else {
|
||||||
null
|
request.deny()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
fileUploadCallback = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "JoykooScanner"
|
private const val TAG = "JoykooScanner"
|
||||||
private const val FILE_CHOOSER_REQUEST = 1001
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
app/src/main/java/mx/joykoo/scanner/Permissions.kt
Normal file
55
app/src/main/java/mx/joykoo/scanner/Permissions.kt
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package mx.joykoo.scanner
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime permission prompts. Must be constructed in onCreate (it registers an
|
||||||
|
* activity-result launcher). Requests are asked up front at launch so the device can be
|
||||||
|
* set up once, and again on demand when a feature needs one that was denied.
|
||||||
|
*/
|
||||||
|
class Permissions(private val activity: AppCompatActivity) {
|
||||||
|
|
||||||
|
private var pending: (() -> Unit)? = null
|
||||||
|
|
||||||
|
private val launcher = activity.registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) {
|
||||||
|
val action = pending
|
||||||
|
pending = null
|
||||||
|
action?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isGranted(permission: String): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
fun allGranted(permissions: Array<String>): Boolean = permissions.all(::isGranted)
|
||||||
|
|
||||||
|
/** Prompts for any missing [permissions], then runs [action] whatever the user chose;
|
||||||
|
* [action] checks [isGranted] itself and degrades if something was denied. */
|
||||||
|
fun withPermissions(permissions: Array<String>, action: () -> Unit = {}) {
|
||||||
|
val missing = permissions.filterNot(::isGranted)
|
||||||
|
if (missing.isEmpty()) {
|
||||||
|
action()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending = action
|
||||||
|
launcher.launch(missing.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestStartupPermissions() = withPermissions(CAMERA + STORAGE)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val CAMERA = arrayOf(Manifest.permission.CAMERA)
|
||||||
|
|
||||||
|
/** Writing to shared Downloads/Pictures needs a permission only before Android 10. */
|
||||||
|
val STORAGE: Array<String> =
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P)
|
||||||
|
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
else emptyArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,6 +25,10 @@
|
|||||||
<string name="kiosk_enabled">Modo kiosco activado</string>
|
<string name="kiosk_enabled">Modo kiosco activado</string>
|
||||||
<string name="kiosk_enabled_pinning">Modo kiosco activado (fijación de pantalla: el dispositivo no está configurado como propietario)</string>
|
<string name="kiosk_enabled_pinning">Modo kiosco activado (fijación de pantalla: el dispositivo no está configurado como propietario)</string>
|
||||||
<string name="kiosk_disabled">Modo kiosco desactivado</string>
|
<string name="kiosk_disabled">Modo kiosco desactivado</string>
|
||||||
|
<string name="download_started">Descargando %1$s…</string>
|
||||||
|
<string name="download_done">Guardado en Descargas: %1$s</string>
|
||||||
|
<string name="download_failed">No se pudo descargar el archivo</string>
|
||||||
|
<string name="download_permission_denied">Se necesita permiso de almacenamiento para guardar archivos</string>
|
||||||
<string name="status_no_wifi">Sin Wi-Fi</string>
|
<string name="status_no_wifi">Sin Wi-Fi</string>
|
||||||
<string name="status_no_internet">sin internet</string>
|
<string name="status_no_internet">sin internet</string>
|
||||||
<string name="status_battery">%1$d%%</string>
|
<string name="status_battery">%1$d%%</string>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user