package com.gramiatechnologies.gramia import android.Manifest import android.app.AlertDialog import android.app.NotificationChannel import android.app.NotificationManager import android.content.ContentValues import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.MediaStore import android.webkit.JavascriptInterface import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Toast import androidx.activity.ComponentActivity import androidx.annotation.RequiresApi import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import android.util.Base64 import android.util.Log import android.view.WindowManager import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebSettings import okhttp3.Call import okhttp3.Callback import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import com.google.zxing.integration.android.IntentIntegrator import com.google.firebase.messaging.FirebaseMessaging import org.json.JSONArray import org.json.JSONObject import java.io.File import java.io.FileOutputStream import androidx.core.content.FileProvider import java.io.IOException import java.util.zip.ZipInputStream import java.nio.charset.StandardCharsets import java.security.MessageDigest import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen class MainActivity : ComponentActivity() { private lateinit var myWebView: WebView val FILE_CHOOSER_REQUEST_CODE = 1000 private val NOTIFICATION_PERMISSION_REQUEST_CODE = 2000 private val appUpdatePrefsName = "gramia_app_updates" private val cameraPermissionRequestCode = 2101 private val webBundleRootDirName = "webbundle" private val webBundleCurrentDirName = "current" private val bundledWebBundleZipAssetPath = "preloaded-webbundle/android/latest.zip" private val bundledWebBundleVersionAssetPath = "preloaded-webbundle/android/version.txt" private val seededBundledWebBundleVersionPrefKey = "seeded_bundled_web_bundle_version" private val requiredBundleFiles = listOf( "index.html", "assets/js/functions.js", "assets/js/main.js" ) private val updateCheckIntervalMs = 6 * 60 * 60 * 1000L private val updatePromptIntervalMs = 24 * 60 * 60 * 1000L @Volatile private var appUpdateCheckInFlight = false private lateinit var playStoreUpdateCoordinator: PlayStoreUpdateCoordinator private var pendingScanField: String? = null private var pendingScanPrompt: String = "" private var keepSplashVisible = true @Volatile private var fcmDeviceRegisteredThisSession = false private val fcmRegistrationProbe = object : Runnable { override fun run() { if (!::myWebView.isInitialized || fcmDeviceRegisteredThisSession) return registerFcmDeviceFromWebStorage(myWebView) myWebView.postDelayed(this, 10_000L) } } @RequiresApi(Build.VERSION_CODES.O) override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen().setKeepOnScreenCondition { keepSplashVisible } super.onCreate(savedInstanceState) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) WindowCompat.setDecorFitsSystemWindows(window, true) setContentView(R.layout.activity_main) resetInstalledWebBundleForDebugBuild() if (!BuildConfig.USE_EMBEDDED_WEB_ASSETS) { installBundledWebBundleIfNeeded() } myWebView = findViewById(R.id.myWebView) playStoreUpdateCoordinator = PlayStoreUpdateCoordinator(this, ::getCurrentVersionCode) val webSettings = myWebView.settings webSettings.javaScriptEnabled = true webSettings.domStorageEnabled = true // ✅ THIS LINE webSettings.allowFileAccess = true webSettings.allowFileAccessFromFileURLs = true webSettings.allowUniversalAccessFromFileURLs = true webSettings.allowContentAccess = true // In your Activity myWebView.addJavascriptInterface(WebAppInterface(), "AndroidInterface") //myWebView.setRenderPriority(WebSettings.RenderPriority.HIGH) // For older versions myWebView.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) keepSplashVisible = false registerFcmDeviceFromWebStorage(view) view?.postDelayed({ registerFcmDeviceFromWebStorage(view) }, 2500L) } override fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError? ) { super.onReceivedError(view, request, error) if (request == null || request.isForMainFrame) { keepSplashVisible = false } } @Deprecated("Deprecated in Java") override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { if (url == "exit://app") { finishAffinity() // or finish() depending on behavior return true } return false } } // So everything opens in this WebView instead of an external browser myWebView.loadUrl(resolveStartPageUrl()) myWebView.postDelayed({ keepSplashVisible = false }, 5000L) myWebView.webChromeClient = MyWebChromeClient(this) requestNotificationPermissionIfNeeded() NotificationUtils.ensureNotificationChannel(this) NotificationPollingScheduler.restore(this)?.let { registration -> registerFcmDevice( registration.userId, registration.institutionUserId, registration.apiToken ) } if (BuildConfig.ENABLE_REMOTE_APP_UPDATES) { checkForAppUpdateIfNeeded(force = shouldOpenAppUpdate(intent)) } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) if (BuildConfig.ENABLE_REMOTE_APP_UPDATES && shouldOpenAppUpdate(intent)) { checkForAppUpdateIfNeeded(force = true) } } override fun onResume() { super.onResume() if (::playStoreUpdateCoordinator.isInitialized) { playStoreUpdateCoordinator.resumeInterruptedUpdate() } if (BuildConfig.ENABLE_REMOTE_APP_UPDATES) { checkForAppUpdateIfNeeded(force = false) } fcmDeviceRegisteredThisSession = false if (::myWebView.isInitialized) { myWebView.removeCallbacks(fcmRegistrationProbe) myWebView.postDelayed(fcmRegistrationProbe, 1_000L) } } override fun onPause() { if (::myWebView.isInitialized) { myWebView.removeCallbacks(fcmRegistrationProbe) } super.onPause() } override fun onDestroy() { if (::playStoreUpdateCoordinator.isInitialized) { playStoreUpdateCoordinator.close() } super.onDestroy() } private fun resetInstalledWebBundleForDebugBuild() { val isDebuggable = (applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 if (!isDebuggable) return try { val rootDir = webBundleRootDir() if (rootDir.exists()) { rootDir.deleteRecursively() } } catch (e: Exception) { Log.w("WebBundle", "Failed to clear debug web bundle override", e) } val prefs = getSharedPreferences(appUpdatePrefsName, Context.MODE_PRIVATE) prefs.edit() .remove("active_web_bundle_version") .remove("last_web_bundle_applied_at") .remove(seededBundledWebBundleVersionPrefKey) .apply() } private fun shouldOpenAppUpdate(intent: Intent?): Boolean { return intent?.getBooleanExtra("open_app_update", false) == true || intent?.getStringExtra("type").equals("app_update", ignoreCase = true) } fun requestNotificationPermissionIfNeeded() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), NOTIFICATION_PERMISSION_REQUEST_CODE ) } } } fun scheduleNotificationPolling(context: Context, institutionUserId: String, userId: String, apiToken: String) { NotificationPollingScheduler.schedule(context, institutionUserId, userId, apiToken) registerFcmDevice(userId, institutionUserId, apiToken) } private fun registerFcmDevice(userId: String, institutionUserId: String, apiToken: String) { if (userId.isBlank() || institutionUserId.isBlank() || apiToken.isBlank()) { saveFcmRegistrationDiagnostic("waiting", "Waiting for an authenticated account context.") return } saveFcmRegistrationDiagnostic("registering", "Requesting an FCM token for account $institutionUserId.") FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> if (!task.isSuccessful || task.result.isNullOrBlank()) { saveFcmRegistrationDiagnostic("token_error", task.exception?.message ?: "Unable to obtain an FCM token.") Log.w("GramiaFCM", "Unable to obtain FCM registration token", task.exception) return@addOnCompleteListener } val token = task.result getSharedPreferences(GramiaFirebaseMessagingService.PREFS_NAME, Context.MODE_PRIVATE) .edit() .putString(GramiaFirebaseMessagingService.TOKEN_KEY, token) .apply() val body = FormBody.Builder() .add("user", userId) .add("institution_user", institutionUserId) .add("token", token) .add("platform", "Android") .add("api", "true") .add("api_token", apiToken) .build() val request = Request.Builder() .url("https://www.gramia.tech/public/register-device") .post(body) .build() OkHttpClient.Builder().build().newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, error: IOException) { saveFcmRegistrationDiagnostic("network_error", error.message ?: "Device registration request failed.") Log.w("GramiaFCM", "FCM token registration request failed", error) } override fun onResponse(call: Call, response: Response) { response.use { val responseBody = it.body?.string().orEmpty() val accepted = try { JSONObject(responseBody).optInt("status", 0) == 1 } catch (_: Exception) { false } if (it.isSuccessful && accepted) { fcmDeviceRegisteredThisSession = true saveFcmRegistrationDiagnostic("registered", "FCM token registered for account $institutionUserId.", it.code) Log.i("GramiaFCM", "FCM token registered") } else { val message = try { JSONObject(responseBody).optString("message", "Registration was rejected.") } catch (_: Exception) { "Registration was rejected." } saveFcmRegistrationDiagnostic("rejected", message, it.code) Log.w("GramiaFCM", "FCM token registration was rejected (HTTP ${it.code})") } } } }) } } private fun saveFcmRegistrationDiagnostic(status: String, message: String, httpCode: Int? = null) { getSharedPreferences(GramiaFirebaseMessagingService.PREFS_NAME, Context.MODE_PRIVATE) .edit() .putString("registration_status", status) .putString("registration_message", message) .putLong("registration_checked_at", System.currentTimeMillis()) .putInt("registration_http_code", httpCode ?: 0) .apply() } private fun registerFcmDeviceFromWebStorage(view: WebView?) { if (view == null) return val script = """ (function () { try { var user = JSON.parse(localStorage.getItem('user') || '{}'); var account = JSON.parse( localStorage.getItem('current_account') || localStorage.getItem('user_account') || '{}' ); var apiToken = localStorage.getItem('api_token') || user.api_token || ''; if (!user.iD || !account.iD || !apiToken) return ''; return JSON.stringify({ userId: String(user.iD), institutionUserId: String(account.iD), apiToken: String(apiToken) }); } catch (error) { return ''; } })(); """.trimIndent() view.evaluateJavascript(script) { rawValue -> try { val payloadText = JSONArray("[$rawValue]").optString(0) if (payloadText.isBlank()) return@evaluateJavascript val payload = JSONObject(payloadText) registerFcmDevice( payload.optString("userId"), payload.optString("institutionUserId"), payload.optString("apiToken") ) } catch (error: Exception) { Log.w("GramiaFCM", "Unable to read device registration context", error) } } } @RequiresApi(Build.VERSION_CODES.O) override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { val scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) if (scanResult != null) { val field = pendingScanField ?: "" val value = scanResult.contents?.trim().orEmpty() if (field.isNotBlank()) { if (value.isNotBlank()) { dispatchNativeScanResult(field, value, cancelled = false, error = null) } else { dispatchNativeScanResult(field, "", cancelled = true, error = null) } } pendingScanField = null pendingScanPrompt = "" return } super.onActivityResult(requestCode, resultCode, data) (myWebView.webChromeClient as? MyWebChromeClient)?.onActivityResult(requestCode, resultCode, data) } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == cameraPermissionRequestCode) { val field = pendingScanField ?: "" if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if (field.isNotBlank()) { launchBarcodeScanner(field, pendingScanPrompt) } } else if (field.isNotBlank()) { dispatchNativeScanResult(field, "", cancelled = true, error = "camera_permission_denied") pendingScanField = null pendingScanPrompt = "" } return } if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Toast.makeText(this, "Notification permission granted", Toast.LENGTH_SHORT).show() } else { Toast.makeText(this, "Notification permission denied", Toast.LENGTH_SHORT).show() } } } @Suppress("MissingSuperCall") override fun onBackPressed() { myWebView.evaluateJavascript("goBackView();", null) } private fun webBundleRootDir(): File { return File(filesDir, webBundleRootDirName) } private fun webBundleCurrentDir(): File { return File(webBundleRootDir(), webBundleCurrentDirName) } private fun resolveStartPageUrl(): String { if (BuildConfig.USE_EMBEDDED_WEB_ASSETS) { return "file:///android_asset/index.html" } if (ensureUsableWebBundle()) { val currentIndex = File(webBundleCurrentDir(), "index.html") return "file://${currentIndex.absolutePath}" } return "file:///android_asset/index.html" } private fun isBundleUsable(dir: File): Boolean { if (!dir.exists() || !dir.isDirectory) return false return requiredBundleFiles.all { relativePath -> File(dir, relativePath).exists() } } private fun clearActiveBundleVersion() { val prefs = getSharedPreferences(appUpdatePrefsName, Context.MODE_PRIVATE) prefs.edit().putString("active_web_bundle_version", "").apply() } private fun getSeededBundledWebBundleVersion(): String { val prefs = getSharedPreferences(appUpdatePrefsName, Context.MODE_PRIVATE) return prefs.getString(seededBundledWebBundleVersionPrefKey, "") ?: "" } private fun setSeededBundledWebBundleVersion(version: String) { val prefs = getSharedPreferences(appUpdatePrefsName, Context.MODE_PRIVATE) prefs.edit().putString(seededBundledWebBundleVersionPrefKey, version).apply() } private fun ensureUsableWebBundle(): Boolean { val rootDir = webBundleRootDir() val currentDir = webBundleCurrentDir() val previousDir = File(rootDir, "previous") if (isBundleUsable(currentDir)) { return true } if (currentDir.exists()) { Log.w("WebBundle", "Current bundle is invalid. Attempting recovery.") } if (previousDir.exists() && isBundleUsable(previousDir)) { if (currentDir.exists()) { currentDir.deleteRecursively() } if (previousDir.renameTo(currentDir) && isBundleUsable(currentDir)) { Log.i("WebBundle", "Recovered previous bundle after invalid current bundle.") clearActiveBundleVersion() return true } } if (currentDir.exists()) { currentDir.deleteRecursively() } clearActiveBundleVersion() return false } private fun getCurrentWebBundleVersion(): String { val prefs = getSharedPreferences(appUpdatePrefsName, Context.MODE_PRIVATE) return prefs.getString("active_web_bundle_version", "") ?: "" } private fun readBundledAssetText(path: String): String? { return try { assets.open(path).bufferedReader().use { it.readText() } } catch (_: Exception) { null } } private fun readBundledAssetBytes(path: String): ByteArray? { return try { assets.open(path).use { it.readBytes() } } catch (_: Exception) { null } } private fun installBundledWebBundleIfNeeded() { val bundledVersion = readBundledAssetText(bundledWebBundleVersionAssetPath)?.trim().orEmpty() if (bundledVersion.isBlank()) { return } val currentVersion = getCurrentWebBundleVersion().trim() val seededVersion = getSeededBundledWebBundleVersion().trim() val hasUsableBundle = ensureUsableWebBundle() if (hasUsableBundle && currentVersion == bundledVersion) { setSeededBundledWebBundleVersion(bundledVersion) return } if (hasUsableBundle && seededVersion == bundledVersion) { return } val bundleBytes = readBundledAssetBytes(bundledWebBundleZipAssetPath) if (bundleBytes == null || bundleBytes.isEmpty()) { Log.w("WebBundle", "Bundled web bundle asset missing or empty") return } val rootDir = webBundleRootDir() if (!rootDir.exists()) { rootDir.mkdirs() } val stagingDir = File(rootDir, "staging_seed_${System.currentTimeMillis()}") if (stagingDir.exists()) { stagingDir.deleteRecursively() } if (!stagingDir.mkdirs()) { Log.w("WebBundle", "Failed to create bundled web bundle staging directory") return } if (!unzipIntoStaging(bundleBytes, stagingDir)) { stagingDir.deleteRecursively() Log.w("WebBundle", "Failed to unzip bundled web bundle") return } val applied = activateStagingBundle(stagingDir, bundledVersion, null) if (applied) { setSeededBundledWebBundleVersion(bundledVersion) Log.i("WebBundle", "Seeded bundled web bundle version $bundledVersion") } else { stagingDir.deleteRecursively() Log.w("WebBundle", "Failed to activate bundled web bundle version $bundledVersion") } } private fun isSafeBundleRelativePath(path: String): Boolean { if (path.isBlank()) return false val normalized = path.replace('\\', '/') if (normalized.startsWith("/")) return false if (normalized.contains("..")) return false return true } private fun decodeBundleFileContent(fileObj: JSONObject): ByteArray? { val content = fileObj.optString("content", "") if (content.isBlank()) return ByteArray(0) val encoding = fileObj.optString("encoding", "base64").lowercase() return try { when (encoding) { "utf8", "text", "plain" -> content.toByteArray(StandardCharsets.UTF_8) else -> Base64.decode(content, Base64.DEFAULT) } } catch (e: Exception) { null } } private fun sha256Hex(data: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256") return digest.digest(data).joinToString("") { b -> "%02x".format(b) } } private fun writeWebBundleFiles(stagingDir: File, files: JSONArray): Boolean { for (i in 0 until files.length()) { val item = files.optJSONObject(i) ?: continue val relativePath = item.optString("path", "").trim() if (!isSafeBundleRelativePath(relativePath)) { Log.w("WebBundle", "Unsafe file path in package: $relativePath") return false } val bytes = decodeBundleFileContent(item) if (bytes == null) { Log.w("WebBundle", "Failed to decode file: $relativePath") return false } val target = File(stagingDir, relativePath) val parent = target.parentFile if (parent != null && !parent.exists() && !parent.mkdirs()) { Log.w("WebBundle", "Failed to create parent dir for: $relativePath") return false } try { target.writeBytes(bytes) } catch (e: Exception) { Log.w("WebBundle", "Failed to write file: $relativePath", e) return false } } return true } private fun applyWebBundleDeleteList(currentDir: File, deleteList: JSONArray?) { if (deleteList == null) return for (i in 0 until deleteList.length()) { val relativePath = deleteList.optString(i, "").trim() if (!isSafeBundleRelativePath(relativePath)) continue val target = File(currentDir, relativePath) if (!target.exists()) continue try { if (target.isDirectory) { target.deleteRecursively() } else { target.delete() } } catch (_: Exception) { } } } private fun copyDirectoryContents(source: File, target: File): Boolean { if (!source.exists()) return false if (!target.exists() && !target.mkdirs()) return false source.listFiles()?.forEach { child -> val out = File(target, child.name) if (child.isDirectory) { if (!copyDirectoryContents(child, out)) return false } else { try { child.inputStream().use { input -> out.outputStream().use { output -> input.copyTo(output) } } } catch (_: Exception) { return false } } } return true } private fun copyBundledAssetsToDir(relativePath: String, targetDir: File): Boolean { return try { val children = assets.list(relativePath) ?: return false if (children.isEmpty()) { val out = File(targetDir, File(relativePath).name) val parent = out.parentFile if (parent != null && !parent.exists() && !parent.mkdirs()) return false assets.open(relativePath).use { input -> out.outputStream().use { output -> input.copyTo(output) } } true } else { val dir = if (relativePath.isBlank()) targetDir else File(targetDir, relativePath) if (!dir.exists() && !dir.mkdirs()) return false for (child in children) { val childPath = if (relativePath.isBlank()) child else "$relativePath/$child" if (!copyBundledAssetsToDir(childPath, targetDir)) return false } true } } catch (_: Exception) { false } } private fun unzipIntoStaging(zipBytes: ByteArray, stagingDir: File): Boolean { try { ZipInputStream(zipBytes.inputStream()).use { zis -> var entry = zis.nextEntry while (entry != null) { val normalized = entry.name.replace('\\', '/').trimStart('/') if (normalized.isNotBlank() && !normalized.contains("..") && !normalized.startsWith("__MACOSX/")) { val outFile = File(stagingDir, normalized) if (entry.isDirectory) { if (!outFile.exists() && !outFile.mkdirs()) return false } else { val parent = outFile.parentFile if (parent != null && !parent.exists() && !parent.mkdirs()) return false outFile.outputStream().use { output -> zis.copyTo(output) } } } zis.closeEntry() entry = zis.nextEntry } } return true } catch (e: Exception) { Log.w("WebBundle", "ZIP extraction failed", e) return false } } private fun activateStagingBundle(stagingDir: File, packageVersion: String, deleteList: JSONArray? = null): Boolean { if (!isBundleUsable(stagingDir)) { Log.w("WebBundle", "Required files missing in staging bundle") return false } val rootDir = webBundleRootDir() val currentDir = webBundleCurrentDir() val previousDir = File(rootDir, "previous") return try { if (previousDir.exists()) previousDir.deleteRecursively() if (currentDir.exists()) currentDir.renameTo(previousDir) if (!stagingDir.renameTo(currentDir)) { if (previousDir.exists()) previousDir.renameTo(currentDir) stagingDir.deleteRecursively() return false } applyWebBundleDeleteList(currentDir, deleteList) val prefs = getSharedPreferences(appUpdatePrefsName, Context.MODE_PRIVATE) prefs.edit() .putString("active_web_bundle_version", packageVersion) .putLong("last_web_bundle_applied_at", System.currentTimeMillis()) .apply() Log.i("WebBundle", "Applied web bundle version $packageVersion") dispatchWebBundleApplied(packageVersion) true } catch (e: Exception) { Log.e("WebBundle", "Failed to activate bundle", e) if (!currentDir.exists() && previousDir.exists()) previousDir.renameTo(currentDir) false } } private fun dispatchWebBundleApplied(packageVersion: String) { val escapedVersion = packageVersion .replace("\\", "\\\\") .replace("'", "\\'") runOnUiThread { myWebView.evaluateJavascript( "window.dispatchEvent(new CustomEvent('native:webBundleApplied', { detail: { version: '$escapedVersion', platform: 'android' } }));", null ) } } private fun dispatchWebBundleInstallStarted(packageVersion: String) { val escapedVersion = packageVersion .replace("\\", "\\\\") .replace("'", "\\'") runOnUiThread { myWebView.evaluateJavascript( "window.dispatchEvent(new CustomEvent('native:webBundleInstallStarted', { detail: { version: '$escapedVersion', platform: 'android' } }));", null ) } } private fun dispatchWebBundleInstallFailed(reason: String) { val escapedReason = reason .replace("\\", "\\\\") .replace("'", "\\'") .replace("\n", "\\n") .replace("\r", "") runOnUiThread { myWebView.evaluateJavascript( "window.dispatchEvent(new CustomEvent('native:webBundleInstallFailed', { detail: { reason: '$escapedReason', platform: 'android' } }));", null ) } } private fun failWebBundleInstall(logMessage: String, userMessage: String = "Update install failed.") { Log.w("WebBundle", logMessage) dispatchWebBundleInstallFailed(logMessage) runOnUiThread { Toast.makeText(this@MainActivity, userMessage, Toast.LENGTH_SHORT).show() } } private fun completeWebBundleInstall(applied: Boolean, packageVersion: String, forceReload: Boolean) { if (!applied) { failWebBundleInstall("Failed to activate staging bundle for version $packageVersion") return } runOnUiThread { Toast.makeText(this@MainActivity, "Update installed.", Toast.LENGTH_SHORT).show() if (forceReload) { val targetUrl = resolveStartPageUrl() myWebView.clearCache(true) myWebView.loadUrl(targetUrl) } } } private fun downloadAndApplyWebBundlePackage( packageUrl: String, bundleVersion: String, checksumSha256: String, forceReload: Boolean ) { val request = Request.Builder().url(packageUrl).get().build() OkHttpClient.Builder().build().newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { failWebBundleInstall("Bundle download failed: ${e.message}", "Update download failed.") } override fun onResponse(call: Call, response: Response) { response.use { if (!it.isSuccessful) { failWebBundleInstall("Bundle download HTTP ${it.code}", "Update download failed.") return } val bytes = it.body?.bytes() if (bytes == null || bytes.isEmpty()) { failWebBundleInstall("Bundle download returned empty body") return } if (checksumSha256.isNotBlank()) { val packageHash = sha256Hex(bytes) if (!checksumSha256.equals(packageHash, ignoreCase = true)) { failWebBundleInstall("Bundle checksum mismatch for version $bundleVersion") return } } val body = try { String(bytes, StandardCharsets.UTF_8) } catch (e: Exception) { failWebBundleInstall("Bundle decode failed: ${e.message}") return } if (body.isBlank()) { failWebBundleInstall("Bundle JSON payload is blank") return } val packageJson = try { JSONObject(body) } catch (e: Exception) { failWebBundleInstall("Bundle JSON parse failed: ${e.message}") return } val packageVersion = packageJson.optString("version", bundleVersion).trim() val files = packageJson.optJSONArray("files") ?: JSONArray() if (packageVersion.isBlank() || files.length() == 0) { failWebBundleInstall("Bundle JSON missing version/files") return } val rootDir = webBundleRootDir() if (!rootDir.exists()) rootDir.mkdirs() val stagingDir = File(rootDir, "staging_${System.currentTimeMillis()}") if (stagingDir.exists()) stagingDir.deleteRecursively() if (!stagingDir.mkdirs()) { failWebBundleInstall("Failed to create staging directory") return } if (!writeWebBundleFiles(stagingDir, files)) { stagingDir.deleteRecursively() failWebBundleInstall("Failed to write staged bundle files") return } val applied = activateStagingBundle(stagingDir, packageVersion, packageJson.optJSONArray("delete")) completeWebBundleInstall(applied, packageVersion, forceReload) } } }) } private fun downloadAndApplyWebBundleZipPackage( packageUrl: String, bundleVersion: String, checksumSha256: String, forceReload: Boolean ) { val request = Request.Builder().url(packageUrl).get().build() OkHttpClient.Builder().build().newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { failWebBundleInstall("ZIP download failed: ${e.message}", "Update download failed.") } override fun onResponse(call: Call, response: Response) { response.use { if (!it.isSuccessful) { failWebBundleInstall("ZIP download HTTP ${it.code}", "Update download failed.") return } val bytes = it.body?.bytes() if (bytes == null || bytes.isEmpty()) { failWebBundleInstall("ZIP download returned empty body") return } if (checksumSha256.isNotBlank()) { val hash = sha256Hex(bytes) if (!checksumSha256.equals(hash, ignoreCase = true)) { failWebBundleInstall("ZIP checksum mismatch for version $bundleVersion") return } } val rootDir = webBundleRootDir() if (!rootDir.exists()) rootDir.mkdirs() val stagingDir = File(rootDir, "staging_${System.currentTimeMillis()}") if (stagingDir.exists()) stagingDir.deleteRecursively() if (!stagingDir.mkdirs()) { failWebBundleInstall("Failed to create ZIP staging directory") return } val currentDir = webBundleCurrentDir() val seeded = if (currentDir.exists()) { copyDirectoryContents(currentDir, stagingDir) } else { copyBundledAssetsToDir("", stagingDir) } if (!seeded) { stagingDir.deleteRecursively() failWebBundleInstall("Failed to seed staging bundle") return } if (!unzipIntoStaging(bytes, stagingDir)) { stagingDir.deleteRecursively() failWebBundleInstall("ZIP extraction failed for version $bundleVersion") return } val applied = activateStagingBundle(stagingDir, bundleVersion, null) completeWebBundleInstall(applied, bundleVersion, forceReload) } } }) } private fun getCurrentVersionCode(): Long { val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) } else { @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0) } return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { packageInfo.longVersionCode } else { @Suppress("DEPRECATION") packageInfo.versionCode.toLong() } } private fun getCurrentVersionName(): String { val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) } else { @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0) } return packageInfo.versionName ?: "0" } private fun checkForAppUpdateIfNeeded(force: Boolean) { if (!BuildConfig.ENABLE_REMOTE_APP_UPDATES) { return } if (appUpdateCheckInFlight) { return } val prefs = getSharedPreferences(appUpdatePrefsName, Context.MODE_PRIVATE) val now = System.currentTimeMillis() val lastCheckAt = prefs.getLong("last_check_at", 0L) if (!force && (now - lastCheckAt) < updateCheckIntervalMs) { return } appUpdateCheckInFlight = true prefs.edit().putLong("last_check_at", now).apply() val currentVersionCode = getCurrentVersionCode() val currentVersionName = getCurrentVersionName() val currentBundleVersion = getCurrentWebBundleVersion() val updateUrl = "${ApiConfig.BASE_URL}/api/mobile-app-update?platform=android" + "&version_code=$currentVersionCode&version_name=${Uri.encode(currentVersionName)}" + "&bundle_version=${Uri.encode(currentBundleVersion)}" val request = Request.Builder() .url(updateUrl) .get() .build() OkHttpClient.Builder().build().newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { appUpdateCheckInFlight = false Log.w("AppUpdate", "Update check failed: ${e.message}") } override fun onResponse(call: Call, response: Response) { response.use { appUpdateCheckInFlight = false if (!it.isSuccessful) { Log.w("AppUpdate", "Update check HTTP ${it.code}") return } val body = it.body?.string().orEmpty() if (body.isBlank()) return val json = try { JSONObject(body) } catch (e: Exception) { Log.w("AppUpdate", "Invalid update JSON: ${e.message}") return } if (json.optInt("status", 0) != 1) return val latestVersionCode = json.optLong("latest_version_code", currentVersionCode) val latestVersionName = json.optString("latest_version_name", "") val packageUrl = json.optString("package_url", "") val playStoreUrl = json.optString("play_store_url", "") val forceUpdate = json.optBoolean("force_update", false) val minimumSupportedVersionCode = json.optLong("min_supported_version_code", 0L) val updatePriority = json.optInt("update_priority", 0).coerceIn(0, 5) if (!json.optBoolean("update_available", false) || latestVersionCode <= currentVersionCode) { if (force) { runOnUiThread { Toast.makeText(this@MainActivity, "Gramia is up to date.", Toast.LENGTH_SHORT).show() } } return } val lastPromptedCode = prefs.getLong("last_prompted_version_code", -1L) val lastPromptedAt = prefs.getLong("last_prompted_at", 0L) if (!force && !forceUpdate && lastPromptedCode == latestVersionCode && (System.currentTimeMillis() - lastPromptedAt) < updatePromptIntervalMs ) { return } val title = json.optString("title", "Update available") val message = json.optString( "message", "A new version is available. Current: $currentVersionName, latest: $latestVersionName" ) val policy = NativeUpdatePolicy( latestVersionCode = latestVersionCode, latestVersionName = latestVersionName, minimumSupportedVersionCode = minimumSupportedVersionCode, priority = updatePriority, title = title, message = message, releaseNotes = json.optString("release_notes", ""), playStoreUrl = playStoreUrl, fallbackPackageUrl = packageUrl, forceUpdate = forceUpdate || ( minimumSupportedVersionCode > 0 && currentVersionCode < minimumSupportedVersionCode ) ) prefs.edit() .putLong("last_prompted_version_code", latestVersionCode) .putLong("last_prompted_at", System.currentTimeMillis()) .apply() runOnUiThread { playStoreUpdateCoordinator.check(policy, userInitiated = force) } } } }) } private fun startBarcodeScan(field: String, prompt: String) { val normalizedField = field.trim() if (normalizedField.isBlank()) { return } pendingScanField = normalizedField pendingScanPrompt = prompt if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.CAMERA), cameraPermissionRequestCode ) return } launchBarcodeScanner(normalizedField, prompt) } private fun launchBarcodeScanner(field: String, prompt: String) { pendingScanField = field.trim() pendingScanPrompt = prompt val integrator = IntentIntegrator(this) integrator.setDesiredBarcodeFormats(IntentIntegrator.ALL_CODE_TYPES) integrator.setPrompt(if (prompt.isBlank()) "Scan code" else prompt) integrator.setBeepEnabled(true) integrator.setOrientationLocked(false) integrator.setBarcodeImageEnabled(false) integrator.initiateScan() } private fun dispatchNativeScanResult(field: String, value: String, cancelled: Boolean, error: String?) { val escapedField = escapeJsValue(field) val escapedValue = escapeJsValue(value) val escapedError = escapeJsValue(error ?: "") val errorPart = if (error.isNullOrBlank()) { "error: ''" } else { "error: '$escapedError'" } val js = "window.dispatchEvent(new CustomEvent('native:scanResult', { detail: { field: '$escapedField', value: '$escapedValue', cancelled: $cancelled, $errorPart } }));" runOnUiThread { myWebView.evaluateJavascript(js, null) } } private fun escapeJsValue(value: String): String { return value .replace("\\", "\\\\") .replace("'", "\\'") .replace("\n", "\\n") .replace("\r", "") } inner class WebAppInterface { @JavascriptInterface fun openUrlExternally(url: String) { val intent = Intent(Intent.ACTION_VIEW) intent.data = Uri.parse(url) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK this@MainActivity.startActivity(intent) } @JavascriptInterface fun getCurrentWebBundleVersion(): String { return this@MainActivity.getCurrentWebBundleVersion() } @JavascriptInterface fun isRemoteWebBundleUpdatesEnabled(): Boolean { return BuildConfig.ENABLE_REMOTE_WEB_BUNDLE_UPDATES } @JavascriptInterface fun isBetaBuild(): Boolean { return BuildConfig.IS_BETA_BUILD } @JavascriptInterface fun checkForNativeAppUpdate() { runOnUiThread { checkForAppUpdateIfNeeded(force = true) } } @JavascriptInterface fun installWebBundleUpdate( packageUrl: String, bundleVersion: String, checksumSha256: String, packageType: String, forceReload: Boolean ) { runOnUiThread { if (!BuildConfig.ENABLE_REMOTE_WEB_BUNDLE_UPDATES) { Toast.makeText(this@MainActivity, "Updates are disabled for this beta build.", Toast.LENGTH_SHORT).show() return@runOnUiThread } if (packageUrl.isBlank() || bundleVersion.isBlank()) { Toast.makeText(this@MainActivity, "Invalid update payload", Toast.LENGTH_SHORT).show() return@runOnUiThread } dispatchWebBundleInstallStarted(bundleVersion) Toast.makeText(this@MainActivity, "Downloading update...", Toast.LENGTH_SHORT).show() if (packageType.trim().lowercase() == "zip") { downloadAndApplyWebBundleZipPackage(packageUrl, bundleVersion, checksumSha256, forceReload) } else { downloadAndApplyWebBundlePackage(packageUrl, bundleVersion, checksumSha256, forceReload) } } } @JavascriptInterface fun saveBase64Image(base64: String, filename: String) { try { Log.d("WebAppInterface", "saveBase64Image called filename=$filename base64Length=${base64.length}") runOnUiThread { Toast.makeText(this@MainActivity, "Saving card: $filename", Toast.LENGTH_SHORT).show() } val clean = base64.substringAfter("base64,") val bytes = Base64.decode(clean, Base64.DEFAULT) val values = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, filename) put(MediaStore.Images.Media.MIME_TYPE, "image/png") put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/School Reports") } val uri = contentResolver.insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values ) uri?.let { contentResolver.openOutputStream(it)?.use { out -> out.write(bytes) } Log.d("WebAppInterface", "saveBase64Image saved successfully uri=$it") runOnUiThread { Toast.makeText(this@MainActivity, "Card saved to Pictures/School Reports", Toast.LENGTH_LONG).show() } } ?: run { Log.e("WebAppInterface", "saveBase64Image failed: MediaStore insert returned null") runOnUiThread { Toast.makeText(this@MainActivity, "Failed to save card: no media location", Toast.LENGTH_LONG).show() } } } catch (e: Exception) { Log.e("WebAppInterface", "saveBase64Image error for $filename", e) runOnUiThread { Toast.makeText(this@MainActivity, "Failed to save card: ${e.message}", Toast.LENGTH_LONG).show() } e.printStackTrace() } } @JavascriptInterface fun showToast(message: String) { Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show() } @JavascriptInterface fun scanCode(field: String, prompt: String) { runOnUiThread { startBarcodeScan(field, prompt) } } @JavascriptInterface fun startNotificationPolling(institutionUserId: String, userId: String, apiToken: String) { runOnUiThread { scheduleNotificationPolling(this@MainActivity, institutionUserId, userId, apiToken) } } @JavascriptInterface fun registerFcmDevice(userId: String, institutionUserId: String, apiToken: String) { this@MainActivity.registerFcmDevice(userId, institutionUserId, apiToken) } @JavascriptInterface fun getFcmRegistrationDiagnostics(): String { val prefs = getSharedPreferences(GramiaFirebaseMessagingService.PREFS_NAME, Context.MODE_PRIVATE) val storedToken = prefs.getString(GramiaFirebaseMessagingService.TOKEN_KEY, "").orEmpty() val notificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED return JSONObject().apply { put("app_version", BuildConfig.VERSION_NAME) put("package_name", packageName) put("notification_permission", notificationPermission) put("token_available", storedToken.isNotBlank()) put("token_length", storedToken.length) put("token_fingerprint", if (storedToken.isBlank()) JSONObject.NULL else sha256Hex(storedToken.toByteArray(StandardCharsets.UTF_8)).take(12)) put("registration_status", prefs.getString("registration_status", "not_attempted")) put("registration_message", prefs.getString("registration_message", "No registration attempt recorded.")) put("registration_checked_at", prefs.getLong("registration_checked_at", 0L)) put("registration_http_code", prefs.getInt("registration_http_code", 0)) }.toString() } @JavascriptInterface fun stopNotificationPolling() { NotificationPollingScheduler.cancel(this@MainActivity) } @JavascriptInterface fun saveBase64PDF(base64: String, filename: String) { try { // val pdfData = base64.substringAfter("base64,", "") val pdfData = if (base64.contains("base64,")) { base64.substringAfter("base64,") } else { base64 } val bytes = Base64.decode(pdfData, Base64.DEFAULT) val file = File(this@MainActivity.getExternalFilesDir(null), filename) FileOutputStream(file).use { it.write(bytes) } val uri = FileProvider.getUriForFile( this@MainActivity, "${this@MainActivity.packageName}.provider", file ) val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "application/pdf") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } this@MainActivity.startActivity(intent) } catch (e: Exception) { e.printStackTrace() } } @JavascriptInterface fun requestNotificationPermission() { requestNotificationPermissionIfNeeded() } @JavascriptInterface fun startBackgroundNotificationService(userId: String) { val intent = Intent(this@MainActivity, NotificationService::class.java) intent.putExtra("institution_user_id", userId) startService(intent) } @JavascriptInterface fun showNativeNotification(title: String, message: String) { NotificationUtils.ensureNotificationChannel(this@MainActivity) val contentIntent = NotificationUtils.buildLaunchAppPendingIntent( this@MainActivity, System.currentTimeMillis().toInt() ) val builder = NotificationCompat.Builder(this@MainActivity, "easiwrap_channel") .setSmallIcon(R.drawable.ic_notification_ee) // make sure this icon exists .setContentTitle(title) .setContentText(message) .setContentIntent(contentIntent) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) val notificationManager = NotificationManagerCompat.from(this@MainActivity) if (ActivityCompat.checkSelfPermission( this@MainActivity, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { // TODO: Consider calling // ActivityCompat#requestPermissions // here to request the missing permissions, and then overriding // public void onRequestPermissionsResult(int requestCode, String[] permissions, // int[] grantResults) // to handle the case where the user grants the permission. See the documentation // for ActivityCompat#requestPermissions for more details. return } notificationManager.notify(System.currentTimeMillis().toInt(), builder.build()) } } inner class MyWebChromeClient(private val activity: ComponentActivity) : android.webkit.WebChromeClient() { private var filePathCallback: android.webkit.ValueCallback>? = null override fun onShowFileChooser( webView: WebView, filePathCallback: android.webkit.ValueCallback>, fileChooserParams: FileChooserParams ): Boolean { this.filePathCallback?.onReceiveValue(null) this.filePathCallback = filePathCallback val intent = fileChooserParams.createIntent() try { activity.startActivityForResult(intent, FILE_CHOOSER_REQUEST_CODE) } catch (e: android.content.ActivityNotFoundException) { this.filePathCallback = null Toast.makeText(activity, "Cannot open file chooser", Toast.LENGTH_SHORT).show() return false } return true } fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == FILE_CHOOSER_REQUEST_CODE) { val results: Array? = if (resultCode == RESULT_OK && data != null) { FileChooserParams.parseResult(resultCode, data) } else null filePathCallback?.onReceiveValue(results) filePathCallback = null } } } }