diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..082fa10 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +**/.cxx +**/.gradle +**/build +build/native +.log_capture +*.apk +*.iml +.idea +local.properties diff --git a/app/build.gradle b/app/build.gradle index 9b59889..a7ae619 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,6 +26,27 @@ def buildTimeEpochMs = System.currentTimeMillis() def buildTimeZoneId = java.util.TimeZone.getDefault().getID() def buildTimeDisplay = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", java.util.Locale.US) .format(new java.util.Date(buildTimeEpochMs)) +def otaMajor = 0 +def otaMinor = 1 +def otaBuild = 0 +def otaChannelUrlDefault = '' +def otaManifestUrlDefault = '' +try { + def lpFile = rootProject.file('local.properties') + if (lpFile.exists()) { + def lp = new Properties() + lp.load(new FileInputStream(lpFile)) + otaMajor = (lp.getProperty('ota.major', "${otaMajor}") ?: otaMajor) as int + otaMinor = (lp.getProperty('ota.minor', "${otaMinor}") ?: otaMinor) as int + otaBuild = (lp.getProperty('ota.build', "${otaBuild}") ?: otaBuild) as int + otaChannelUrlDefault = lp.getProperty('ota.channel.url', '') ?: '' + otaManifestUrlDefault = lp.getProperty('ota.manifest.url', '') ?: '' + } +} catch (Exception ignored) { +} +def otaVersionCode = otaMajor * 10000 + otaMinor * 100 + otaBuild +def otaVersionName = "${otaMajor}.${otaMinor}.${otaBuild}" + try { gitCommitShort = ['git', 'rev-parse', '--short', 'HEAD'].execute(null, rootProject.projectDir).text.trim() gitCommitAuthor = ['git', 'log', '-1', '--format=%an'].execute(null, rootProject.projectDir).text.trim() @@ -40,13 +61,18 @@ android { applicationId "com.foxx.androidcast" minSdk 29 targetSdk 34 - versionCode 1 - versionName "0.1.0-poc" + versionCode otaVersionCode + versionName otaVersionName + buildConfigField 'int', 'VERSION_MAJOR', "${otaMajor}" + buildConfigField 'int', 'VERSION_MINOR', "${otaMinor}" + buildConfigField 'int', 'VERSION_BUILD', "${otaBuild}" buildConfigField 'String', 'GIT_COMMIT_SHORT', "\"${gitCommitShort}\"" buildConfigField 'String', 'GIT_COMMIT_AUTHOR', "\"${gitCommitAuthor.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" buildConfigField 'long', 'BUILD_TIME_EPOCH_MS', "${buildTimeEpochMs}L" buildConfigField 'String', 'BUILD_TIME_ZONE_ID', "\"${buildTimeZoneId.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" buildConfigField 'String', 'BUILD_TIME_DISPLAY', "\"${buildTimeDisplay.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" + buildConfigField 'String', 'OTA_CHANNEL_URL_DEFAULT', "\"${otaChannelUrlDefault.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" + buildConfigField 'String', 'OTA_MANIFEST_URL_DEFAULT', "\"${otaManifestUrlDefault.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" externalNativeBuild { cmake { arguments "-DANDROID_STL=c++_shared" @@ -95,6 +121,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.0' implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'com.google.android.gms:play-services-cronet:18.1.0' + implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5' testImplementation 'junit:junit:4.13.2' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ef1fb7..e4e6476 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + @@ -15,6 +16,7 @@ + + + + + + AppPreferences.setSendAnonymousCrashLogs(requireContext(), checked)); + } themeSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { private boolean first = true; diff --git a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java index be0877d..a0134a2 100644 --- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java +++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java @@ -4,12 +4,16 @@ import android.content.Context; import android.os.Bundle; import android.widget.Button; import android.widget.CheckBox; +import android.widget.TextView; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import com.foxx.androidcast.media.CodecPriorityCatalog; +import com.foxx.androidcast.ota.OtaUpdateChecker; +import com.foxx.androidcast.ota.OtaUpdateCoordinator; import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity; +import com.google.android.material.textfield.TextInputEditText; /** Hidden developer settings (opened via version label easter egg). */ public class DeveloperSettingsActivity extends AppCompatActivity { @@ -39,6 +43,22 @@ public class DeveloperSettingsActivity extends AppCompatActivity { String summary = CodecPriorityCatalog.reload(this); Toast.makeText(this, summary, Toast.LENGTH_LONG).show(); }); + + TextView installedVersion = findViewById(R.id.text_ota_installed_version); + TextInputEditText manifestUrl = findViewById(R.id.edit_ota_manifest_url); + CheckBox otaOnLaunch = findViewById(R.id.check_ota_on_launch); + Button checkOta = findViewById(R.id.btn_check_ota); + + refreshOtaUi(installedVersion, manifestUrl, otaOnLaunch); + + otaOnLaunch.setOnCheckedChangeListener((buttonView, isChecked) -> + AppPreferences.setOtaCheckOnLaunch(this, isChecked)); + + checkOta.setOnClickListener(v -> { + String url = manifestUrl.getText() != null ? manifestUrl.getText().toString().trim() : ""; + AppPreferences.setOtaChannelUrl(this, url); + OtaUpdateCoordinator.requestManualCheck(this); + }); } @Override @@ -48,6 +68,34 @@ public class DeveloperSettingsActivity extends AppCompatActivity { findViewById(R.id.check_receiver_debug_overlay), findViewById(R.id.check_show_codec_priority_scores), findViewById(R.id.check_grab_session_stats)); + refreshOtaUi( + findViewById(R.id.text_ota_installed_version), + findViewById(R.id.edit_ota_manifest_url), + findViewById(R.id.check_ota_on_launch)); + } + + private void refreshOtaUi(TextView installedVersion, TextInputEditText manifestUrl, CheckBox otaOnLaunch) { + if (installedVersion != null) { + installedVersion.setText(getString(R.string.ota_installed_version, + OtaUpdateChecker.installedVersionName(this), + OtaUpdateChecker.installedVersionCode(this))); + } + if (manifestUrl != null) { + String url = AppPreferences.getOtaChannelUrl(this); + if (url.isEmpty() && BuildConfig.OTA_CHANNEL_URL_DEFAULT != null) { + url = BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim(); + } + if (url.isEmpty() && BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) { + url = BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim(); + } + manifestUrl.setText(url); + } + if (otaOnLaunch != null) { + otaOnLaunch.setOnCheckedChangeListener(null); + otaOnLaunch.setChecked(AppPreferences.isOtaCheckOnLaunch(this)); + otaOnLaunch.setOnCheckedChangeListener((buttonView, isChecked) -> + AppPreferences.setOtaCheckOnLaunch(this, isChecked)); + } } private void refreshFromPreferences(CheckBox debugOverlay, CheckBox codecScores, CheckBox sessionStats) { diff --git a/app/src/main/java/com/foxx/androidcast/MainActivity.java b/app/src/main/java/com/foxx/androidcast/MainActivity.java index d8bcec0..198dbea 100644 --- a/app/src/main/java/com/foxx/androidcast/MainActivity.java +++ b/app/src/main/java/com/foxx/androidcast/MainActivity.java @@ -1,14 +1,41 @@ package com.foxx.androidcast; +import android.content.BroadcastReceiver; +import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; import android.os.Bundle; -import android.widget.Button; +import com.foxx.androidcast.ota.OtaManifest; +import com.foxx.androidcast.ota.OtaUpdateCoordinator; +import com.foxx.androidcast.ota.OtaUpdateService; import com.foxx.androidcast.receiver.ReceiverPlaybackActivity; import com.foxx.androidcast.sender.SenderActivity; import com.foxx.androidcast.stats.SessionStatsStore; public class MainActivity extends DrawerHostActivity { + private final BroadcastReceiver otaUpdateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (OtaUpdateService.ACTION_UPDATE_AVAILABLE.equals(intent.getAction())) { + String json = intent.getStringExtra(OtaUpdateService.EXTRA_MANIFEST_JSON); + String sourceUrl = intent.getStringExtra(OtaUpdateService.EXTRA_SOURCE_URL); + boolean trustedSource = intent.getBooleanExtra( + OtaUpdateService.EXTRA_TRUSTED_SOURCE, + false); + OtaManifest manifest = OtaManifest.fromJsonString(json); + if (manifest != null && !isFinishing()) { + OtaUpdateCoordinator.presentUpdateDialog( + MainActivity.this, + manifest, + sourceUrl != null ? sourceUrl : "", + trustedSource); + } + } + } + }; + @Override protected int getContentLayoutId() { return R.layout.activity_main; @@ -32,10 +59,28 @@ public class MainActivity extends DrawerHostActivity { }); } + @Override + protected void onStart() { + super.onStart(); + IntentFilter filter = new IntentFilter(OtaUpdateService.ACTION_UPDATE_AVAILABLE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(otaUpdateReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(otaUpdateReceiver, filter); + } + } + + @Override + protected void onStop() { + unregisterReceiver(otaUpdateReceiver); + super.onStop(); + } + @Override protected void onResume() { super.onResume(); PermissionHelper.remindIfMissing(this, false); + OtaUpdateCoordinator.requestCheckOnLaunch(this); } @Override diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashNativeBridge.java b/app/src/main/java/com/foxx/androidcast/crash/CrashNativeBridge.java new file mode 100644 index 0000000..19e962b --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashNativeBridge.java @@ -0,0 +1,40 @@ +package com.foxx.androidcast.crash; + +import android.content.Context; +import android.util.Log; + +/** Installs native SIG* handlers that write backtrace stubs for the watcher process. */ +public final class CrashNativeBridge { + private static final String TAG = "CrashNativeBridge"; + private static final String LIB = "androidcast_codecs"; + private static volatile boolean loaded; + + private CrashNativeBridge() {} + + public static void init(Context context) { + if (!ensureLoaded()) { + return; + } + try { + String dir = context.getFilesDir().getAbsolutePath() + "/crash_reports/native"; + nativeInstallCrashHook(dir); + } catch (Throwable t) { + Log.w(TAG, "nativeInstallCrashHook failed: " + t.getMessage()); + } + } + + private static boolean ensureLoaded() { + if (loaded) { + return true; + } + try { + System.loadLibrary(LIB); + loaded = true; + return true; + } catch (UnsatisfiedLinkError e) { + return false; + } + } + + private static native void nativeInstallCrashHook(String nativeReportDir); +} diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashReportBuilder.java b/app/src/main/java/com/foxx/androidcast/crash/CrashReportBuilder.java new file mode 100644 index 0000000..25cdc8d --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashReportBuilder.java @@ -0,0 +1,179 @@ +package com.foxx.androidcast.crash; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Build; + +import com.foxx.androidcast.BuildConfig; +import com.foxx.androidcast.stats.SessionStatsContext; +import com.foxx.androidcast.stats.SessionStatsRecorder; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.security.MessageDigest; +import java.util.Locale; +import java.util.UUID; + +/** Builds anonymous crash report JSON (schema v1). */ +public final class CrashReportBuilder { + private CrashReportBuilder() {} + + public static JSONObject buildJavaCrash(Context context, Thread thread, Throwable throwable) { + JSONObject root = baseReport(context, "java"); + try { + JSONObject java = new JSONObject(); + java.put("thread", thread != null ? thread.getName() : "unknown"); + java.put("exception", throwable.getClass().getName()); + String msg = throwable.getMessage(); + if (msg != null) { + java.put("message", msg); + } + JSONArray frames = new JSONArray(); + for (StackTraceElement el : throwable.getStackTrace()) { + frames.put(el.toString()); + } + Throwable cause = throwable.getCause(); + if (cause != null && cause != throwable) { + java.put("cause", cause.getClass().getName() + ": " + cause.getMessage()); + } + java.put("stack_frames", frames); + root.put("java", java); + root.put("fingerprint", fingerprintJava(throwable)); + } catch (Exception ignored) { + } + attachSessionContext(root); + return root; + } + + public static JSONObject buildNativeCrash(Context context, String signalName, String backtraceText) { + JSONObject root = baseReport(context, "native"); + try { + JSONObject nat = new JSONObject(); + nat.put("signal", signalName != null ? signalName : "UNKNOWN"); + JSONArray frames = new JSONArray(); + if (backtraceText != null) { + for (String line : backtraceText.split("\n")) { + String t = line.trim(); + if (!t.isEmpty()) { + frames.put(t); + } + } + } + nat.put("backtrace", frames); + root.put("native", nat); + root.put("fingerprint", fingerprintNative(signalName, backtraceText)); + } catch (Exception ignored) { + } + attachSessionContext(root); + return root; + } + + private static JSONObject baseReport(Context context, String crashType) { + JSONObject root = new JSONObject(); + try { + long now = System.currentTimeMillis(); + root.put("schema_version", 1); + root.put("report_id", UUID.randomUUID().toString()); + root.put("generated_at_epoch_ms", now); + root.put("crash_type", crashType); + root.put("process", "main"); + root.put("device", deviceJson()); + root.put("app", appJson(context)); + root.put("build", buildJson()); + } catch (Exception ignored) { + } + return root; + } + + private static JSONObject deviceJson() throws Exception { + JSONObject d = new JSONObject(); + d.put("manufacturer", Build.MANUFACTURER); + d.put("brand", Build.BRAND); + d.put("model", Build.MODEL); + d.put("device", Build.DEVICE); + d.put("product", Build.PRODUCT); + d.put("sdk_int", Build.VERSION.SDK_INT); + d.put("release", Build.VERSION.RELEASE); + JSONArray abis = new JSONArray(); + for (String abi : Build.SUPPORTED_ABIS) { + abis.put(abi); + } + d.put("abis", abis); + return d; + } + + private static JSONObject appJson(Context context) throws Exception { + JSONObject a = new JSONObject(); + a.put("package", context.getPackageName()); + PackageManager pm = context.getPackageManager(); + PackageInfo info = pm.getPackageInfo(context.getPackageName(), 0); + a.put("version_name", info.versionName != null ? info.versionName : ""); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + a.put("version_code", info.getLongVersionCode()); + } else { + a.put("version_code", info.versionCode); + } + return a; + } + + private static JSONObject buildJson() throws Exception { + JSONObject b = new JSONObject(); + b.put("debug", BuildConfig.DEBUG); + b.put("git_commit", BuildConfig.GIT_COMMIT_SHORT); + return b; + } + + private static void attachSessionContext(JSONObject root) { + try { + SessionStatsRecorder recorder = SessionStatsContext.get(); + if (recorder == null) { + return; + } + JSONObject ctx = new JSONObject(); + ctx.put("direction", recorder.getDirection()); + ctx.put("role", "recv".equals(recorder.getDirection()) ? "receiver" : "sender"); + root.put("session_context", ctx); + } catch (Exception ignored) { + } + } + + static String fingerprintJava(Throwable t) { + StringBuilder sb = new StringBuilder(); + sb.append(t.getClass().getName()); + StackTraceElement[] st = t.getStackTrace(); + int limit = Math.min(8, st.length); + for (int i = 0; i < limit; i++) { + sb.append('|').append(st[i].getClassName()).append('.').append(st[i].getMethodName()); + } + return sha256Hex(sb.toString()); + } + + static String fingerprintNative(String signal, String backtrace) { + StringBuilder sb = new StringBuilder(); + sb.append(signal != null ? signal : ""); + if (backtrace != null) { + String[] lines = backtrace.split("\n"); + int limit = Math.min(6, lines.length); + for (int i = 0; i < limit; i++) { + sb.append('|').append(lines[i].trim()); + } + } + return sha256Hex(sb.toString()); + } + + private static String sha256Hex(String text) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format(Locale.US, "%02x", b & 0xff)); + } + return sb.toString(); + } catch (Exception e) { + return ""; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashReportStore.java b/app/src/main/java/com/foxx/androidcast/crash/CrashReportStore.java new file mode 100644 index 0000000..15262c1 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashReportStore.java @@ -0,0 +1,89 @@ +package com.foxx.androidcast.crash; + +import org.json.JSONObject; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** Pending/uploaded crash JSON in app-private storage (shared across processes). */ +public final class CrashReportStore { + private static final String ROOT = "crash_reports"; + private static final String PENDING = "pending"; + private static final String UPLOADED = "uploaded"; + + private CrashReportStore() {} + + public static File pendingDir(File filesDir) { + File dir = new File(new File(filesDir, ROOT), PENDING); + if (!dir.exists()) { + dir.mkdirs(); + } + return dir; + } + + public static File uploadedDir(File filesDir) { + File dir = new File(new File(filesDir, ROOT), UPLOADED); + if (!dir.exists()) { + dir.mkdirs(); + } + return dir; + } + + public static File newPendingFile(File filesDir, String reportId) { + return new File(pendingDir(filesDir), "crash_" + reportId + ".json"); + } + + public static void writeJson(File file, JSONObject json) throws Exception { + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(bytes); + } + } + + public static JSONObject readJson(File file) { + try (FileInputStream fis = new FileInputStream(file)) { + return new JSONObject(new String(fis.readAllBytes(), StandardCharsets.UTF_8)); + } catch (Exception e) { + return null; + } + } + + public static List listPending(File filesDir) { + File dir = pendingDir(filesDir); + File[] files = dir.listFiles((d, n) -> n.endsWith(".json")); + if (files == null || files.length == 0) { + return Collections.emptyList(); + } + List list = new ArrayList<>(); + Collections.addAll(list, files); + list.sort(Comparator.comparing(File::getName)); + return list; + } + + public static void markUploaded(File filesDir, File pending, boolean prune) { + if (prune) { + pending.delete(); + return; + } + File dest = new File(uploadedDir(filesDir), pending.getName()); + pending.renameTo(dest); + } + + public static void pruneOldestPending(File filesDir, int maxFiles) { + List pending = listPending(filesDir); + while (pending.size() > maxFiles) { + pending.get(0).delete(); + pending = listPending(filesDir); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java b/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java new file mode 100644 index 0000000..8c98e9c --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java @@ -0,0 +1,82 @@ +package com.foxx.androidcast.crash; + +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.foxx.androidcast.stats.SessionStatsContext; + +import org.json.JSONObject; + +import java.io.File; + +/** Main-process entry: write pending crash JSON and wake the watcher process. */ +public final class CrashReporter { + private static final String TAG = "CrashReporter"; + + private CrashReporter() {} + + public static void install(Context context) { + Context app = context.getApplicationContext(); + CrashNativeBridge.init(app); + final Thread.UncaughtExceptionHandler previous = + Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + try { + SessionStatsContext.flushActiveOnUncaughtException(app); + } catch (Throwable ignored) { + } + try { + recordJavaCrash(app, thread, throwable); + } catch (Throwable t) { + Log.e(TAG, "recordJavaCrash failed", t); + } + if (previous != null) { + previous.uncaughtException(thread, throwable); + } + }); + CrashWatcherService.scheduleUploadSweep(app); + } + + public static void recordJavaCrash(Context context, Thread thread, Throwable throwable) { + if (!CrashSettingsStore.load(context).enabled) { + return; + } + JSONObject report = CrashReportBuilder.buildJavaCrash(context, thread, throwable); + persistAndWake(context, report); + } + + public static void recordNativeCrash(Context context, String signalName, String backtrace) { + if (!CrashSettingsStore.load(context).enabled) { + return; + } + JSONObject report = CrashReportBuilder.buildNativeCrash(context, signalName, backtrace); + persistAndWake(context, report); + } + + static void persistAndWake(Context context, JSONObject report) { + if (report == null) { + return; + } + try { + String id = report.optString("report_id", String.valueOf(System.currentTimeMillis())); + File file = CrashReportStore.newPendingFile(context.getFilesDir(), id); + CrashReportStore.writeJson(file, report); + CrashReportStore.pruneOldestPending(context.getFilesDir(), + CrashSettingsStore.load(context).maxPendingFiles); + wakeWatcher(context); + } catch (Exception e) { + Log.e(TAG, "persist failed", e); + } + } + + private static void wakeWatcher(Context context) { + try { + Intent i = new Intent(context, CrashWatcherService.class); + i.setAction(CrashWatcherService.ACTION_UPLOAD_PENDING); + context.startService(i); + } catch (Exception e) { + Log.w(TAG, "wake watcher failed: " + e.getMessage()); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashSettings.java b/app/src/main/java/com/foxx/androidcast/crash/CrashSettings.java new file mode 100644 index 0000000..673e448 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashSettings.java @@ -0,0 +1,22 @@ +package com.foxx.androidcast.crash; + +/** Crash reporter tuning from {@code settings.json} → {@code crash} (overrides app defaults). */ +public final class CrashSettings { + public static final String DEFAULT_UPLOAD_URL = + "https://f0xx.org/app/androidcast_project/crashes/api/upload.php"; + + public final boolean enabled; + public final String uploadUrl; + public final boolean pruneAfterUpload; + public final long uploadIntervalMs; + public final int maxPendingFiles; + + public CrashSettings(boolean enabled, String uploadUrl, boolean pruneAfterUpload, + long uploadIntervalMs, int maxPendingFiles) { + this.enabled = enabled; + this.uploadUrl = uploadUrl != null ? uploadUrl.trim() : DEFAULT_UPLOAD_URL; + this.pruneAfterUpload = pruneAfterUpload; + this.uploadIntervalMs = Math.max(30_000L, uploadIntervalMs); + this.maxPendingFiles = Math.max(1, maxPendingFiles); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashSettingsStore.java b/app/src/main/java/com/foxx/androidcast/crash/CrashSettingsStore.java new file mode 100644 index 0000000..3c01fbc --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashSettingsStore.java @@ -0,0 +1,79 @@ +package com.foxx.androidcast.crash; + +import android.content.Context; + +import com.foxx.androidcast.AppPreferences; +import com.foxx.androidcast.settings.AppSettingsJson; + +import org.json.JSONObject; + +/** Loads effective crash settings (preferences + settings.json overrides). */ +public final class CrashSettingsStore { + private static final long DEFAULT_UPLOAD_INTERVAL_MS = 5 * 60_000L; + private static final int DEFAULT_MAX_PENDING = 32; + + private CrashSettingsStore() {} + + public static CrashSettings load(Context context) { + boolean enabled = AppPreferences.isSendAnonymousCrashLogs(context); + String uploadUrl = CrashSettings.DEFAULT_UPLOAD_URL; + boolean prune = true; + long interval = DEFAULT_UPLOAD_INTERVAL_MS; + int maxPending = DEFAULT_MAX_PENDING; + + JSONObject crash = AppSettingsJson.section(context, "crash"); + if (crash != null) { + if (crash.has("enabled")) { + enabled = parseBool(crash.optString("enabled", "true"), enabled); + } + String url = crash.optString("upload_url", "").trim(); + if (!url.isEmpty()) { + uploadUrl = url; + } + if (crash.has("prune_after_upload")) { + prune = parseBool(crash.optString("prune_after_upload", "true"), prune); + } + String intervalRaw = crash.optString("upload_interval", ""); + if (!intervalRaw.isEmpty()) { + interval = parseDurationMs(intervalRaw, interval); + } + maxPending = crash.optInt("max_pending", maxPending); + } + return new CrashSettings(enabled, uploadUrl, prune, interval, maxPending); + } + + private static boolean parseBool(String raw, boolean fallback) { + if (raw == null) { + return fallback; + } + String v = raw.trim().toLowerCase(); + if ("true".equals(v) || "1".equals(v) || "yes".equals(v)) { + return true; + } + if ("false".equals(v) || "0".equals(v) || "no".equals(v)) { + return false; + } + return fallback; + } + + private static long parseDurationMs(String raw, long fallback) { + try { + String t = raw.trim().toLowerCase(); + if (t.endsWith("ms")) { + return Long.parseLong(t.substring(0, t.length() - 2).trim()); + } + if (t.endsWith("s")) { + return Long.parseLong(t.substring(0, t.length() - 1).trim()) * 1000L; + } + if (t.endsWith("m")) { + return Long.parseLong(t.substring(0, t.length() - 1).trim()) * 60_000L; + } + if (t.endsWith("h")) { + return Long.parseLong(t.substring(0, t.length() - 1).trim()) * 3_600_000L; + } + return Long.parseLong(t); + } catch (Exception e) { + return fallback; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashUploadClient.java b/app/src/main/java/com/foxx/androidcast/crash/CrashUploadClient.java new file mode 100644 index 0000000..13d312c --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashUploadClient.java @@ -0,0 +1,57 @@ +package com.foxx.androidcast.crash; + +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** POST crash JSON to the PHP receiver. */ +public final class CrashUploadClient { + private static final int CONNECT_MS = 12_000; + private static final int READ_MS = 20_000; + + private CrashUploadClient() {} + + public static boolean upload(String uploadUrl, JSONObject report) { + if (uploadUrl == null || uploadUrl.isEmpty() || report == null) { + return false; + } + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL(uploadUrl).openConnection(); + conn.setConnectTimeout(CONNECT_MS); + conn.setReadTimeout(READ_MS); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + conn.setRequestProperty("Accept", "application/json"); + byte[] body = report.toString().getBytes(StandardCharsets.UTF_8); + try (OutputStream out = conn.getOutputStream()) { + out.write(body); + } + int code = conn.getResponseCode(); + if (code < 200 || code >= 300) { + return false; + } + try (BufferedInputStream in = new BufferedInputStream(conn.getInputStream()); + ByteArrayOutputStream buf = new ByteArrayOutputStream()) { + byte[] tmp = new byte[1024]; + int n; + while ((n = in.read(tmp)) != -1) { + buf.write(tmp, 0, n); + } + } + return true; + } catch (Exception e) { + return false; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashWatcherService.java b/app/src/main/java/com/foxx/androidcast/crash/CrashWatcherService.java new file mode 100644 index 0000000..080e15a --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashWatcherService.java @@ -0,0 +1,132 @@ +package com.foxx.androidcast.crash; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; + +import org.json.JSONObject; + +import java.io.File; +import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Standalone process ({@code :crashwatcher}) — uploads pending crash JSON even if the main + * cast process died. Reads {@code settings.json} crash overrides on each sweep. + */ +public final class CrashWatcherService extends Service { + private static final String TAG = "CrashWatcher"; + public static final String ACTION_UPLOAD_PENDING = "com.foxx.androidcast.crash.UPLOAD_PENDING"; + private static final int ALARM_REQUEST = 4102; + + public static void scheduleUploadSweep(Context context) { + try { + Intent i = new Intent(context, CrashWatcherService.class); + i.setAction(ACTION_UPLOAD_PENDING); + context.startService(i); + } catch (Exception e) { + Log.w(TAG, "scheduleUploadSweep: " + e.getMessage()); + } + CrashSettings settings = CrashSettingsStore.load(context); + if (!settings.enabled) { + return; + } + AlarmManager alarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarm == null) { + return; + } + Intent periodic = new Intent(context, CrashWatcherService.class); + periodic.setAction(ACTION_UPLOAD_PENDING); + PendingIntent pi = PendingIntent.getService( + context, + ALARM_REQUEST, + periodic, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + long trigger = System.currentTimeMillis() + settings.uploadIntervalMs; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarm.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pi); + } else { + alarm.set(AlarmManager.RTC_WAKEUP, trigger, pi); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + new Thread(() -> { + try { + runSweep(); + } finally { + scheduleUploadSweep(getApplicationContext()); + stopSelf(startId); + } + }, "CrashWatcherSweep").start(); + return START_NOT_STICKY; + } + + private void runSweep() { + CrashSettings settings = CrashSettingsStore.load(this); + if (!settings.enabled) { + return; + } + ingestNativeStubs(getFilesDir()); + List pending = CrashReportStore.listPending(getFilesDir()); + for (File file : pending) { + JSONObject report = CrashReportStore.readJson(file); + if (report == null) { + file.delete(); + continue; + } + boolean ok = CrashUploadClient.upload(settings.uploadUrl, report); + if (ok) { + CrashReportStore.markUploaded(getFilesDir(), file, settings.pruneAfterUpload); + Log.i(TAG, "Uploaded " + file.getName()); + } else { + Log.w(TAG, "Upload failed for " + file.getName()); + } + } + } + + /** Converts native stub text files (written from JNI signal handler) into pending JSON. */ + private void ingestNativeStubs(File filesDir) { + File nativeDir = new File(new File(filesDir, "crash_reports"), "native"); + if (!nativeDir.exists()) { + return; + } + File[] stubs = nativeDir.listFiles((d, n) -> n.endsWith(".txt")); + if (stubs == null) { + return; + } + for (File stub : stubs) { + try { + byte[] buf; + try (FileInputStream fis = new FileInputStream(stub)) { + buf = fis.readAllBytes(); + } + String text = new String(buf, StandardCharsets.UTF_8); + String signal = "UNKNOWN"; + int sigIdx = text.indexOf("signal="); + if (sigIdx >= 0) { + int end = text.indexOf('\n', sigIdx); + signal = text.substring(sigIdx + 7, end > sigIdx ? end : text.length()).trim(); + } + JSONObject report = CrashReportBuilder.buildNativeCrash(this, signal, text); + String id = report.optString("report_id", stub.getName()); + CrashReportStore.writeJson(CrashReportStore.newPendingFile(filesDir, id), report); + stub.delete(); + } catch (Exception e) { + Log.w(TAG, "ingest native stub failed: " + stub.getName()); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaApkInstaller.java b/app/src/main/java/com/foxx/androidcast/ota/OtaApkInstaller.java new file mode 100644 index 0000000..a599a23 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaApkInstaller.java @@ -0,0 +1,124 @@ +package com.foxx.androidcast.ota; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; + +import androidx.core.content.FileProvider; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.MessageDigest; + +/** Downloads an APK and launches the package installer. */ +public final class OtaApkInstaller { + private static final String TAG = "OtaApkInstaller"; + private static final int CONNECT_TIMEOUT_MS = 15_000; + private static final int READ_TIMEOUT_MS = 300_000; + + public interface ProgressListener { + void onProgress(long downloadedBytes, long totalBytes); + } + + private OtaApkInstaller() {} + + public static File downloadApk(Context context, OtaManifest manifest, ProgressListener listener) + throws Exception { + if (manifest == null || manifest.apkUrl.isEmpty()) { + throw new IllegalArgumentException("Missing apkUrl"); + } + File dir = new File(context.getCacheDir(), "ota"); + if (!dir.exists() && !dir.mkdirs()) { + throw new IllegalStateException("Cannot create OTA cache dir"); + } + File out = new File(dir, "update-" + manifest.effectiveVersionCode() + ".apk"); + if (out.exists() && !out.delete()) { + throw new IllegalStateException("Cannot replace cached APK"); + } + String scheme = OtaJsonFetch.schemeOf(manifest.apkUrl); + if ("mqtt".equals(scheme) || "mqtts".equals(scheme)) { + byte[] payload = OtaJsonFetch.getBytes(manifest.apkUrl, 512 * 1024 * 1024); + long total = payload.length; + try (InputStream in = new BufferedInputStream(new ByteArrayInputStream(payload)); + FileOutputStream fos = new FileOutputStream(out)) { + byte[] buf = new byte[32 * 1024]; + long downloaded = 0; + int n; + while ((n = in.read(buf)) != -1) { + fos.write(buf, 0, n); + downloaded += n; + if (listener != null) { + listener.onProgress(downloaded, total); + } + } + } + } else { + HttpURLConnection conn = (HttpURLConnection) new URL(manifest.apkUrl).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setInstanceFollowRedirects(true); + int code = conn.getResponseCode(); + if (code < 200 || code >= 300) { + conn.disconnect(); + throw new IllegalStateException("Download HTTP " + code); + } + long total = conn.getContentLengthLong(); + try (InputStream in = new BufferedInputStream(conn.getInputStream()); + FileOutputStream fos = new FileOutputStream(out)) { + byte[] buf = new byte[32 * 1024]; + long downloaded = 0; + int n; + while ((n = in.read(buf)) != -1) { + fos.write(buf, 0, n); + downloaded += n; + if (listener != null) { + listener.onProgress(downloaded, total); + } + } + } finally { + conn.disconnect(); + } + } + if (!manifest.sha256Hex.isEmpty()) { + String actual = sha256Hex(out); + if (!manifest.sha256Hex.equalsIgnoreCase(actual)) { + out.delete(); + throw new IllegalStateException("APK checksum mismatch"); + } + } + Log.i(TAG, "Downloaded OTA APK " + out.length() + " bytes -> " + out.getName()); + return out; + } + + public static void launchInstall(Context context, File apkFile) { + Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", apkFile); + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(uri, "application/vnd.android.package-archive"); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + static String sha256Hex(File file) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + try (BufferedInputStream in = new BufferedInputStream(new java.io.FileInputStream(file))) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + md.update(buf, 0, n); + } + } + byte[] digest = md.digest(); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaChannelPointer.java b/app/src/main/java/com/foxx/androidcast/ota/OtaChannelPointer.java new file mode 100644 index 0000000..f2c86b9 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaChannelPointer.java @@ -0,0 +1,23 @@ +package com.foxx.androidcast.ota; + +import org.json.JSONObject; + +/** Default-channel entry ({@code v0/ota/channel/stable.json}). */ +public final class OtaChannelPointer { + public final String manifestUrl; + + public OtaChannelPointer(String manifestUrl) { + this.manifestUrl = manifestUrl != null ? manifestUrl.trim() : ""; + } + + public boolean isValid() { + return !manifestUrl.isEmpty(); + } + + public static OtaChannelPointer fromJson(JSONObject root) { + if (root == null) { + return new OtaChannelPointer(""); + } + return new OtaChannelPointer(root.optString("manifestUrl", "")); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaJsonFetch.java b/app/src/main/java/com/foxx/androidcast/ota/OtaJsonFetch.java new file mode 100644 index 0000000..2efb3c0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaJsonFetch.java @@ -0,0 +1,116 @@ +package com.foxx.androidcast.ota; + +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +/** Small HTTP JSON helper for OTA endpoints. */ +final class OtaJsonFetch { + private static final int CONNECT_TIMEOUT_MS = 12_000; + private static final int READ_TIMEOUT_MS = 20_000; + + private OtaJsonFetch() {} + + static JSONObject getJson(String url, int maxBytes) throws Exception { + byte[] body = getBytes(url, maxBytes); + return new JSONObject(new String(body, StandardCharsets.UTF_8)); + } + + static byte[] getBytes(String url, int maxBytes) throws Exception { + String scheme = schemeOf(url); + if ("mqtt".equals(scheme) || "mqtts".equals(scheme)) { + byte[] body = OtaMqttFetch.getPayload(url, READ_TIMEOUT_MS); + if (body.length > maxBytes) { + throw new IllegalStateException("Response too large"); + } + return body; + } + if (!isHttpUrl(url)) { + throw new IllegalStateException("Unsupported OTA scheme: " + scheme); + } + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setInstanceFollowRedirects(true); + conn.setRequestProperty("Accept", "application/json"); + int code = conn.getResponseCode(); + InputStream in = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream(); + if (in == null) { + conn.disconnect(); + throw new IllegalStateException("HTTP " + code); + } + byte[] body = readLimited(in, maxBytes); + conn.disconnect(); + if (code < 200 || code >= 300) { + throw new IllegalStateException("HTTP " + code); + } + return body; + } + + static long getContentLength(String url) { + String scheme = schemeOf(url); + if ("mqtt".equals(scheme) || "mqtts".equals(scheme)) { + return 0L; + } + if (!isHttpUrl(url)) { + return 0L; + } + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setInstanceFollowRedirects(true); + conn.setRequestMethod("HEAD"); + long len = conn.getContentLengthLong(); + if (len > 0L) { + return len; + } + } catch (Exception ignored) { + } finally { + if (conn != null) { + conn.disconnect(); + } + } + return 0L; + } + + static String schemeOf(String url) { + try { + URI uri = URI.create(url); + String scheme = uri.getScheme(); + return scheme == null ? "" : scheme.toLowerCase(Locale.US); + } catch (Exception ignored) { + return ""; + } + } + + static boolean isHttpUrl(String url) { + String scheme = schemeOf(url); + return "http".equals(scheme) || "https".equals(scheme); + } + + private static byte[] readLimited(InputStream in, int maxBytes) throws Exception { + try (BufferedInputStream bin = new BufferedInputStream(in); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buf = new byte[4096]; + int total = 0; + int n; + while ((n = bin.read(buf)) != -1) { + total += n; + if (total > maxBytes) { + throw new IllegalStateException("Response too large"); + } + out.write(buf, 0, n); + } + return out.toByteArray(); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaManifest.java b/app/src/main/java/com/foxx/androidcast/ota/OtaManifest.java new file mode 100644 index 0000000..11c035b --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaManifest.java @@ -0,0 +1,130 @@ +package com.foxx.androidcast.ota; + +import android.text.TextUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +/** Per-build OTA descriptor ({@code *_manifest.json}). */ +public final class OtaManifest { + private static final int UNSET = -1; + + public final int major; + public final int minor; + public final int build; + /** Legacy field; ignored when {@link #hasVersionTriple()} is true. */ + public final int versionCode; + public final String versionName; + public final String apkUrl; + public final String signUrl; + public final String sha256Hex; + public final long packageSizeBytes; + public final boolean mandatory; + public final String releaseNotes; + + public OtaManifest(int major, int minor, int build, int versionCode, String versionName, + String apkUrl, String signUrl, String sha256Hex, long packageSizeBytes, + boolean mandatory, String releaseNotes) { + this.major = major; + this.minor = minor; + this.build = build; + this.versionCode = versionCode; + this.versionName = versionName != null ? versionName : ""; + this.apkUrl = apkUrl != null ? apkUrl : ""; + this.signUrl = signUrl != null ? signUrl : ""; + this.sha256Hex = sha256Hex != null ? sha256Hex.toLowerCase() : ""; + this.packageSizeBytes = Math.max(0L, packageSizeBytes); + this.mandatory = mandatory; + this.releaseNotes = releaseNotes != null ? releaseNotes : ""; + } + + public boolean hasVersionTriple() { + return major >= 0 && minor >= 0 && build >= 0; + } + + public int effectiveVersionCode() { + if (hasVersionTriple()) { + return OtaVersion.pack(major, minor, build); + } + return versionCode; + } + + public String displayVersion() { + if (!versionName.isEmpty()) { + return versionName; + } + if (hasVersionTriple()) { + return new OtaVersion(major, minor, build).displayName(); + } + return String.valueOf(versionCode); + } + + public boolean isValid() { + return !apkUrl.isEmpty() && effectiveVersionCode() > 0; + } + + public static OtaManifest fromJson(JSONObject root) { + if (root == null) { + return null; + } + return new OtaManifest( + root.has("major") ? root.optInt("major", UNSET) : UNSET, + root.has("minor") ? root.optInt("minor", UNSET) : UNSET, + root.has("build") ? root.optInt("build", UNSET) : UNSET, + root.optInt("versionCode", 0), + root.optString("versionName", ""), + root.optString("apkUrl", ""), + root.optString("signUrl", ""), + root.optString("sha256", ""), + root.optLong("sizeBytes", 0L), + root.optBoolean("mandatory", false), + root.optString("releaseNotes", "")); + } + + public static OtaManifest fromJsonString(String json) { + if (TextUtils.isEmpty(json)) { + return null; + } + try { + return fromJson(new JSONObject(json)); + } catch (JSONException e) { + return null; + } + } + + public JSONObject toJson() throws JSONException { + JSONObject o = new JSONObject(); + if (hasVersionTriple()) { + o.put("major", major); + o.put("minor", minor); + o.put("build", build); + } + if (versionCode != 0) { + o.put("versionCode", versionCode); + } + if (!versionName.isEmpty()) { + o.put("versionName", versionName); + } + o.put("apkUrl", apkUrl); + if (!signUrl.isEmpty()) { + o.put("signUrl", signUrl); + } + if (!sha256Hex.isEmpty()) { + o.put("sha256", sha256Hex); + } + if (packageSizeBytes > 0) { + o.put("sizeBytes", packageSizeBytes); + } + o.put("mandatory", mandatory); + o.put("releaseNotes", releaseNotes); + return o; + } + + public String toJsonString() { + try { + return toJson().toString(); + } catch (JSONException e) { + return "{}"; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaMqttFetch.java b/app/src/main/java/com/foxx/androidcast/ota/OtaMqttFetch.java new file mode 100644 index 0000000..420ca63 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaMqttFetch.java @@ -0,0 +1,114 @@ +package com.foxx.androidcast.ota; + +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; + +import java.net.URI; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** Fetches retained payloads from MQTT topics addressed by mqtt:// or mqtts:// URLs. */ +final class OtaMqttFetch { + private static final int DEFAULT_MQTT_PORT = 1883; + private static final int DEFAULT_MQTTS_PORT = 8883; + + private OtaMqttFetch() {} + + static byte[] getPayload(String mqttUrl, int timeoutMs) throws Exception { + URI uri = URI.create(mqttUrl); + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.US); + boolean tls; + if ("mqtt".equals(scheme)) { + tls = false; + } else if ("mqtts".equals(scheme)) { + tls = true; + } else { + throw new IllegalStateException("Unsupported MQTT scheme: " + scheme); + } + String host = uri.getHost(); + if (host == null || host.isEmpty()) { + throw new IllegalStateException("MQTT URL missing host"); + } + int port = uri.getPort(); + if (port <= 0) { + port = tls ? DEFAULT_MQTTS_PORT : DEFAULT_MQTT_PORT; + } + String topic = uri.getPath(); + if (topic == null || topic.isEmpty() || "/".equals(topic)) { + throw new IllegalStateException("MQTT URL missing topic path"); + } + if (topic.startsWith("/")) { + topic = topic.substring(1); + } + String brokerUri = (tls ? "ssl" : "tcp") + "://" + host + ":" + port; + + AtomicReference payload = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + String clientId = "androidcast-ota-" + UUID.randomUUID().toString().replace("-", ""); + MqttClient client = new MqttClient(brokerUri, clientId, new MemoryPersistence()); + try { + MqttConnectOptions opts = new MqttConnectOptions(); + opts.setAutomaticReconnect(false); + opts.setCleanSession(true); + opts.setConnectionTimeout(Math.max(3, timeoutMs / 1000)); + opts.setKeepAliveInterval(20); + client.setCallback(new MqttCallback() { + @Override + public void connectionLost(Throwable cause) { + error.compareAndSet(null, cause); + latch.countDown(); + } + + @Override + public void messageArrived(String topic, MqttMessage message) { + payload.compareAndSet(null, message.getPayload()); + latch.countDown(); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + // not used for OTA fetch + } + }); + client.connect(opts); + client.subscribe(topic, 0); + boolean got = latch.await(Math.max(1, timeoutMs), TimeUnit.MILLISECONDS); + client.unsubscribe(topic); + if (!got) { + throw new IllegalStateException("MQTT timeout waiting for topic: " + topic); + } + if (error.get() != null) { + Throwable t = error.get(); + throw new IllegalStateException(t.getMessage() != null ? t.getMessage() : "MQTT error", t); + } + byte[] body = payload.get(); + if (body == null || body.length == 0) { + throw new IllegalStateException("MQTT topic has empty payload: " + topic); + } + return body; + } catch (MqttException e) { + throw new IllegalStateException(e.getMessage() != null ? e.getMessage() : "MQTT error", e); + } finally { + try { + if (client.isConnected()) { + client.disconnect(); + } + } catch (Exception ignored) { + } + try { + client.close(); + } catch (Exception ignored) { + } + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaRuntimeSettings.java b/app/src/main/java/com/foxx/androidcast/ota/OtaRuntimeSettings.java new file mode 100644 index 0000000..733c995 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaRuntimeSettings.java @@ -0,0 +1,20 @@ +package com.foxx.androidcast.ota; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Runtime OTA configuration loaded from files/private preferences. */ +public final class OtaRuntimeSettings { + public static final long DEFAULT_CHECK_INTERVAL_MS = 60_000L; + + public final List sourceUrls; + public final long checkIntervalMs; + public final boolean trusted; + + OtaRuntimeSettings(List sourceUrls, long checkIntervalMs, boolean trusted) { + this.sourceUrls = Collections.unmodifiableList(new ArrayList<>(sourceUrls)); + this.checkIntervalMs = Math.max(10_000L, checkIntervalMs); + this.trusted = trusted; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaSettingsStore.java b/app/src/main/java/com/foxx/androidcast/ota/OtaSettingsStore.java new file mode 100644 index 0000000..4ff6570 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaSettingsStore.java @@ -0,0 +1,189 @@ +package com.foxx.androidcast.ota; + +import android.content.Context; + +import com.foxx.androidcast.AppPreferences; +import com.foxx.androidcast.crash.CrashSettings; +import com.foxx.androidcast.BuildConfig; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; + +/** Reads app-private settings.json and composes effective OTA runtime settings. */ +final class OtaSettingsStore { + private static final String SETTINGS_FILE = "settings.json"; + + private OtaSettingsStore() {} + + static OtaRuntimeSettings load(Context context) { + ensureSettingsFile(context); + JSONObject root = readSettingsJson(context); + JSONObject ota = root.optJSONObject("ota"); + + LinkedHashMap dedup = new LinkedHashMap<>(); + addSource(dedup, AppPreferences.getOtaChannelUrl(context)); + if (BuildConfig.OTA_CHANNEL_URL_DEFAULT != null) { + addSource(dedup, BuildConfig.OTA_CHANNEL_URL_DEFAULT); + } + if (BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) { + addSource(dedup, BuildConfig.OTA_MANIFEST_URL_DEFAULT); + } + if (ota != null) { + addSource(dedup, ota.optString("base_url", "")); + JSONArray arr = ota.optJSONArray("base_urls"); + if (arr != null) { + for (int i = 0; i < arr.length(); i++) { + addSource(dedup, arr.optString(i, "")); + } + } + } + + List urls = new ArrayList<>(dedup.keySet()); + long interval = OtaRuntimeSettings.DEFAULT_CHECK_INTERVAL_MS; + if (ota != null) { + interval = parseDurationMs(ota.optString("check_interval", "1m"), interval); + } + boolean trusted = ota != null && parseBoolean(ota.optString("trusted", "false")); + return new OtaRuntimeSettings(urls, interval, trusted); + } + + private static void addSource(LinkedHashMap dedup, String raw) { + if (raw == null) { + return; + } + String value = raw.trim(); + if (value.isEmpty()) { + return; + } + if (value.endsWith(",")) { + value = value.substring(0, value.length() - 1).trim(); + } + if (value.contains(",")) { + String[] split = value.split(","); + for (String s : split) { + addSource(dedup, s); + } + return; + } + dedup.put(value, Boolean.TRUE); + } + + private static JSONObject readSettingsJson(Context context) { + File f = new File(context.getFilesDir(), SETTINGS_FILE); + try (FileInputStream fis = new FileInputStream(f)) { + byte[] data = fis.readAllBytes(); + String text = new String(data, StandardCharsets.UTF_8); + return new JSONObject(text); + } catch (Exception ignored) { + return new JSONObject(); + } + } + + private static void ensureSettingsFile(Context context) { + File f = new File(context.getFilesDir(), SETTINGS_FILE); + if (f.exists()) { + return; + } + JSONObject ota = new JSONObject(); + try { + ota.put("base_url", defaultBaseUrl(context)); + ota.put("trusted", "false"); + ota.put("check_interval", "1m"); + JSONObject crash = new JSONObject(); + crash.put("enabled", "true"); + crash.put("upload_url", CrashSettings.DEFAULT_UPLOAD_URL); + crash.put("prune_after_upload", "true"); + crash.put("upload_interval", "5m"); + crash.put("max_pending", 32); + JSONObject root = new JSONObject(); + root.put("ota", ota); + root.put("crash", crash); + try (FileOutputStream fos = new FileOutputStream(f)) { + fos.write(root.toString(2).getBytes(StandardCharsets.UTF_8)); + } + } catch (Exception ignored) { + } + } + + private static String defaultBaseUrl(Context context) { + String pref = AppPreferences.getOtaChannelUrl(context); + if (!pref.isEmpty()) { + return pref; + } + if (BuildConfig.OTA_CHANNEL_URL_DEFAULT != null && !BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim().isEmpty()) { + return BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim(); + } + if (BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) { + return BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim(); + } + return "https://foxx.org/v0/ota/channel/stable.json"; + } + + static long parseDurationMs(String raw, long fallback) { + if (raw == null) { + return fallback; + } + String text = raw.trim().toLowerCase(Locale.US); + if (text.isEmpty()) { + return fallback; + } + try { + long total = 0L; + int i = 0; + while (i < text.length()) { + while (i < text.length() && Character.isWhitespace(text.charAt(i))) { + i++; + } + int start = i; + while (i < text.length() + && (Character.isDigit(text.charAt(i)) || text.charAt(i) == '.')) { + i++; + } + if (start == i) { + return fallback; + } + double value = Double.parseDouble(text.substring(start, i)); + int unitStart = i; + while (i < text.length() && Character.isLetter(text.charAt(i))) { + i++; + } + String unit = text.substring(unitStart, i); + long mult; + if ("ms".equals(unit)) { + mult = 1L; + } else if ("s".equals(unit)) { + mult = 1_000L; + } else if ("m".equals(unit)) { + mult = 60_000L; + } else if ("h".equals(unit)) { + mult = 3_600_000L; + } else if ("d".equals(unit)) { + mult = 86_400_000L; + } else { + return fallback; + } + total += Math.round(value * mult); + } + return total > 0L ? total : fallback; + } catch (Exception e) { + return fallback; + } + } + + private static boolean parseBoolean(String raw) { + if (raw == null) { + return false; + } + String v = raw.trim().toLowerCase(Locale.US); + return "true".equals(v) || "1".equals(v) || "yes".equals(v) || "on".equals(v); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaSignBundle.java b/app/src/main/java/com/foxx/androidcast/ota/OtaSignBundle.java new file mode 100644 index 0000000..68d3269 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaSignBundle.java @@ -0,0 +1,19 @@ +package com.foxx.androidcast.ota; + +import org.json.JSONObject; + +/** Checksums and digests for one OTA build ({@code *_sign.json}). */ +public final class OtaSignBundle { + public final String sha256Hex; + + public OtaSignBundle(String sha256Hex) { + this.sha256Hex = sha256Hex != null ? sha256Hex.toLowerCase() : ""; + } + + public static OtaSignBundle fromJson(JSONObject root) { + if (root == null) { + return new OtaSignBundle(""); + } + return new OtaSignBundle(root.optString("sha256", "")); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateChecker.java b/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateChecker.java new file mode 100644 index 0000000..026adbd --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateChecker.java @@ -0,0 +1,142 @@ +package com.foxx.androidcast.ota; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; + +import org.json.JSONObject; + +/** Resolves v0 channel or manifest URL and compares against the installed build. */ +public final class OtaUpdateChecker { + private static final int MAX_CHANNEL_BYTES = 8 * 1024; + private static final int MAX_MANIFEST_BYTES = 64 * 1024; + private static final int MAX_SIGN_BYTES = 16 * 1024; + + private OtaUpdateChecker() {} + + public static final class Result { + public final OtaManifest manifest; + public final int installedVersionCode; + public final String installedVersionName; + public final boolean updateAvailable; + public final String sourceUrl; + public final String error; + + Result(OtaManifest manifest, int installedVersionCode, String installedVersionName, + boolean updateAvailable, String sourceUrl, String error) { + this.manifest = manifest; + this.installedVersionCode = installedVersionCode; + this.installedVersionName = installedVersionName != null ? installedVersionName : ""; + this.updateAvailable = updateAvailable; + this.sourceUrl = sourceUrl != null ? sourceUrl : ""; + this.error = error; + } + + static Result error(String sourceUrl, String message) { + return new Result(null, 0, "", false, sourceUrl, message); + } + } + + public static int installedVersionCode(Context context) { + return OtaVersion.installed().packed(); + } + + public static String installedVersionName(Context context) { + try { + PackageManager pm = context.getPackageManager(); + PackageInfo info = pm.getPackageInfo(context.getPackageName(), 0); + return info.versionName != null ? info.versionName : OtaVersion.installed().displayName(); + } catch (PackageManager.NameNotFoundException e) { + return OtaVersion.installed().displayName(); + } + } + + public static Result check(Context context, String entryUrl) { + return check(context, entryUrl, true); + } + + public static Result check(Context context, String entryUrl, boolean strictVerification) { + if (entryUrl == null || entryUrl.trim().isEmpty()) { + return Result.error("", "No OTA channel URL configured"); + } + String normalized = entryUrl.trim(); + int installed = installedVersionCode(context); + String installedName = installedVersionName(context); + try { + OtaManifest manifest = resolveManifest(normalized); + if (manifest == null) { + return Result.error(normalized, "Invalid manifest JSON"); + } + if (!manifest.isValid()) { + return Result.error(normalized, "Manifest missing version or apkUrl"); + } + manifest = enrichFromSignBundle(manifest, strictVerification); + long sizeBytes = manifest.packageSizeBytes > 0L + ? manifest.packageSizeBytes + : OtaJsonFetch.getContentLength(manifest.apkUrl); + if (sizeBytes > 0L && sizeBytes != manifest.packageSizeBytes) { + manifest = new OtaManifest( + manifest.major, + manifest.minor, + manifest.build, + manifest.versionCode, + manifest.versionName, + manifest.apkUrl, + manifest.signUrl, + manifest.sha256Hex, + sizeBytes, + manifest.mandatory, + manifest.releaseNotes); + } + boolean available = OtaVersion.isUpdateAvailable( + manifest.effectiveVersionCode(), installed); + return new Result(manifest, installed, installedName, available, normalized, null); + } catch (Exception e) { + return Result.error( + normalized, + e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); + } + } + + static OtaManifest resolveManifest(String entryUrl) throws Exception { + JSONObject root = OtaJsonFetch.getJson(entryUrl, MAX_MANIFEST_BYTES); + if (root.has("apkUrl")) { + return OtaManifest.fromJson(root); + } + OtaChannelPointer channel = OtaChannelPointer.fromJson(root); + if (!channel.isValid()) { + throw new IllegalStateException("Channel JSON missing manifestUrl"); + } + return fetchManifest(channel.manifestUrl); + } + + static OtaManifest fetchManifest(String manifestUrl) throws Exception { + return OtaManifest.fromJson(OtaJsonFetch.getJson(manifestUrl, MAX_MANIFEST_BYTES)); + } + + static OtaManifest enrichFromSignBundle(OtaManifest manifest, boolean strictVerification) throws Exception { + if (manifest == null) { + return manifest; + } + if (!manifest.sha256Hex.isEmpty()) { + return manifest; + } + if (manifest.signUrl.isEmpty()) { + if (strictVerification) { + throw new IllegalStateException("Manifest missing sha256/signUrl"); + } + return manifest; + } + OtaSignBundle sign = OtaSignBundle.fromJson( + OtaJsonFetch.getJson(manifest.signUrl, MAX_SIGN_BYTES)); + if (sign.sha256Hex.isEmpty()) { + if (strictVerification) { + throw new IllegalStateException("sign.json missing sha256"); + } + return manifest; + } + return new OtaManifest(manifest.major, manifest.minor, manifest.build, manifest.versionCode, + manifest.versionName, manifest.apkUrl, manifest.signUrl, sign.sha256Hex, + manifest.packageSizeBytes, manifest.mandatory, manifest.releaseNotes); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateCoordinator.java b/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateCoordinator.java new file mode 100644 index 0000000..6c9b0c0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateCoordinator.java @@ -0,0 +1,130 @@ +package com.foxx.androidcast.ota; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.ResultReceiver; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; + +import com.foxx.androidcast.AppPreferences; +import com.foxx.androidcast.BuildConfig; +import com.foxx.androidcast.R; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** Starts {@link OtaUpdateService} and hosts update prompts for visible activities. */ +public final class OtaUpdateCoordinator { + private static final AtomicBoolean sUpdateDialogOpen = new AtomicBoolean(false); + + private OtaUpdateCoordinator() {} + + public static String resolveEntryUrl(Context context) { + String url = AppPreferences.getOtaChannelUrl(context); + if (url.isEmpty()) { + url = BuildConfig.OTA_CHANNEL_URL_DEFAULT != null + ? BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim() : ""; + } + if (url.isEmpty() && BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) { + url = BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim(); + } + return url; + } + + public static void requestCheckOnLaunch(Context context) { + OtaUpdateService.enqueueCheckOnLaunch(context.getApplicationContext()); + } + + public static void requestManualCheck(Activity activity) { + ResultReceiver receiver = new ResultReceiver(new Handler(Looper.getMainLooper())) { + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + if (activity.isFinishing()) { + return; + } + if (resultCode == OtaUpdateService.RESULT_ERROR) { + String err = resultData != null + ? resultData.getString(OtaUpdateService.EXTRA_ERROR, "?") : "?"; + Toast.makeText(activity, activity.getString(R.string.ota_check_failed, err), + Toast.LENGTH_LONG).show(); + return; + } + if (resultCode == OtaUpdateService.RESULT_UP_TO_DATE) { + String name = resultData != null + ? resultData.getString(OtaUpdateService.EXTRA_INSTALLED_VERSION_NAME, "") : ""; + int code = resultData != null + ? resultData.getInt(OtaUpdateService.EXTRA_INSTALLED_VERSION_CODE, 0) : 0; + Toast.makeText(activity, + activity.getString(R.string.ota_up_to_date, name, code), + Toast.LENGTH_LONG).show(); + return; + } + if (resultCode == OtaUpdateService.RESULT_DISMISSED) { + Toast.makeText(activity, R.string.ota_dismissed_hint, Toast.LENGTH_SHORT).show(); + return; + } + if (resultCode == OtaUpdateService.RESULT_UPDATE_AVAILABLE) { + String json = resultData != null + ? resultData.getString(OtaUpdateService.EXTRA_MANIFEST_JSON) : null; + OtaManifest manifest = OtaManifest.fromJsonString(json); + String sourceUrl = resultData != null + ? resultData.getString(OtaUpdateService.EXTRA_SOURCE_URL, "") : ""; + boolean trustedSource = resultData != null + && resultData.getBoolean(OtaUpdateService.EXTRA_TRUSTED_SOURCE, false); + if (manifest != null) { + presentUpdateDialog(activity, manifest, sourceUrl, trustedSource); + } + } + } + }; + OtaUpdateService.enqueueCheckManual(activity, receiver); + } + + /** + * Shows the update dialog (used from {@link com.foxx.androidcast.MainActivity} when the + * service posts {@link OtaUpdateService#ACTION_UPDATE_AVAILABLE}). + */ + public static void presentUpdateDialog(Activity activity, OtaManifest manifest) { + presentUpdateDialog(activity, manifest, "", false); + } + + public static void presentUpdateDialog(Activity activity, OtaManifest manifest, + String sourceUrl, boolean trustedSource) { + if (manifest == null || activity == null || activity.isFinishing()) { + return; + } + if (!sUpdateDialogOpen.compareAndSet(false, true)) { + return; + } + String notes = manifest.releaseNotes.isEmpty() + ? activity.getString(R.string.ota_no_release_notes) + : manifest.releaseNotes; + String currentVersion = OtaUpdateChecker.installedVersionName(activity) + + " (" + OtaUpdateChecker.installedVersionCode(activity) + ")"; + double sizeMb = manifest.packageSizeBytes > 0 + ? (manifest.packageSizeBytes / (1024.0 * 1024.0)) : 0.0; + String sizeText = sizeMb > 0 ? String.format(java.util.Locale.US, "%.2f", sizeMb) : "?"; + String message = activity.getString(R.string.ota_update_message, + manifest.displayVersion(), + currentVersion, + sizeText, + notes); + AlertDialog.Builder builder = new AlertDialog.Builder(activity) + .setTitle(R.string.ota_update_title) + .setMessage(message) + .setCancelable(!manifest.mandatory) + .setPositiveButton(R.string.ota_download_install, (d, w) -> + OtaUpdateService.enqueueDownload(activity, manifest, trustedSource, sourceUrl)); + if (!manifest.mandatory) { + builder.setNegativeButton(R.string.ota_later, (d, w) -> + AppPreferences.setOtaDismissedVersionCode(activity, + manifest.effectiveVersionCode())); + } + AlertDialog dialog = builder.create(); + dialog.setOnDismissListener(d -> sUpdateDialogOpen.set(false)); + dialog.show(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateService.java b/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateService.java new file mode 100644 index 0000000..ae0ba1f --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateService.java @@ -0,0 +1,362 @@ +package com.foxx.androidcast.ota; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.app.AlarmManager; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.os.ResultReceiver; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +import com.foxx.androidcast.AppPreferences; +import com.foxx.androidcast.BuildConfig; +import com.foxx.androidcast.MainActivity; +import com.foxx.androidcast.R; +import com.foxx.androidcast.util.CastBackgroundExecutor; + +import java.io.File; +import java.util.List; + +/** + * Standalone OTA lifecycle: channel/manifest fetch, optional user prompt via broadcast, + * and foreground APK download + install intent. + */ +public final class OtaUpdateService extends Service { + public static final String ACTION_CHECK_ON_LAUNCH = "com.foxx.androidcast.ota.CHECK_ON_LAUNCH"; + public static final String ACTION_CHECK_MANUAL = "com.foxx.androidcast.ota.CHECK_MANUAL"; + public static final String ACTION_CHECK_PERIODIC = "com.foxx.androidcast.ota.CHECK_PERIODIC"; + public static final String ACTION_DOWNLOAD = "com.foxx.androidcast.ota.DOWNLOAD"; + + public static final String EXTRA_MANIFEST_JSON = "manifestJson"; + public static final String EXTRA_RESULT_RECEIVER = "resultReceiver"; + public static final String EXTRA_SOURCE_URL = "sourceUrl"; + public static final String EXTRA_TRUSTED_SOURCE = "trustedSource"; + + public static final String ACTION_UPDATE_AVAILABLE = "com.foxx.androidcast.ota.UPDATE_AVAILABLE"; + + public static final String EXTRA_ERROR = "error"; + public static final String EXTRA_INSTALLED_VERSION_NAME = "installedVersionName"; + public static final String EXTRA_INSTALLED_VERSION_CODE = "installedVersionCode"; + + public static final int RESULT_UP_TO_DATE = 1; + public static final int RESULT_UPDATE_AVAILABLE = 2; + public static final int RESULT_ERROR = 3; + public static final int RESULT_DISMISSED = 4; + + private static final String CHANNEL_OTA = "ota_updates"; + private static final int NOTIFICATION_DOWNLOAD = 2001; + + public static void enqueueCheckOnLaunch(Context context) { + Intent i = new Intent(context, OtaUpdateService.class); + i.setAction(ACTION_CHECK_ON_LAUNCH); + context.startService(i); + } + + public static void enqueueCheckManual(Context context, @Nullable ResultReceiver resultReceiver) { + Intent i = new Intent(context, OtaUpdateService.class); + i.setAction(ACTION_CHECK_MANUAL); + if (resultReceiver != null) { + i.putExtra(EXTRA_RESULT_RECEIVER, resultReceiver); + } + context.startService(i); + } + + public static void enqueueDownload(Context context, OtaManifest manifest) { + enqueueDownload(context, manifest, false, ""); + } + + public static void enqueueDownload(Context context, OtaManifest manifest, boolean trustedSource, + String sourceUrl) { + if (manifest == null) { + return; + } + Intent i = new Intent(context, OtaUpdateService.class); + i.setAction(ACTION_DOWNLOAD); + i.putExtra(EXTRA_MANIFEST_JSON, manifest.toJsonString()); + i.putExtra(EXTRA_TRUSTED_SOURCE, trustedSource); + i.putExtra(EXTRA_SOURCE_URL, sourceUrl != null ? sourceUrl : ""); + ContextCompat.startForegroundService(context, i); + } + + @Override + public void onCreate() { + super.onCreate(); + ensureChannel(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null || intent.getAction() == null) { + stopSelf(startId); + return START_NOT_STICKY; + } + String action = intent.getAction(); + if (ACTION_DOWNLOAD.equals(action)) { + String json = intent.getStringExtra(EXTRA_MANIFEST_JSON); + OtaManifest manifest = OtaManifest.fromJsonString(json); + if (manifest == null || !manifest.isValid()) { + stopSelf(startId); + return START_NOT_STICKY; + } + boolean trustedSource = intent.getBooleanExtra(EXTRA_TRUSTED_SOURCE, false); + String sourceUrl = intent.getStringExtra(EXTRA_SOURCE_URL); + startDownloadJob(manifest, trustedSource, sourceUrl, startId); + return START_NOT_STICKY; + } + CastBackgroundExecutor.execute(() -> { + try { + if (ACTION_CHECK_ON_LAUNCH.equals(action)) { + runCheckOnLaunch(); + } else if (ACTION_CHECK_MANUAL.equals(action)) { + ResultReceiver receiver = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + receiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER, ResultReceiver.class); + } else { + receiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER); + } + runCheckManual(receiver); + } else if (ACTION_CHECK_PERIODIC.equals(action)) { + runCheckPeriodic(); + } + } finally { + stopSelf(startId); + } + }); + return START_NOT_STICKY; + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void runCheckOnLaunch() { + if (!AppPreferences.isOtaCheckOnLaunch(this)) { + return; + } + OtaRuntimeSettings settings = OtaSettingsStore.load(this); + scheduleNextCheck(settings.checkIntervalMs); + if (settings.sourceUrls.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + if (now - AppPreferences.getOtaLastCheckMs(this) < settings.checkIntervalMs) { + return; + } + AppPreferences.setOtaLastCheckMs(this, now); + OtaUpdateChecker.Result result = checkRoundRobin(settings); + if (result.error != null || !result.updateAvailable || result.manifest == null) { + return; + } + if (result.manifest.effectiveVersionCode() == AppPreferences.getOtaDismissedVersionCode(this)) { + return; + } + sendUpdateBroadcast(result.manifest, result.sourceUrl, settings.trusted); + } + + private void runCheckPeriodic() { + OtaRuntimeSettings settings = OtaSettingsStore.load(this); + scheduleNextCheck(settings.checkIntervalMs); + if (!AppPreferences.isOtaCheckOnLaunch(this) || settings.sourceUrls.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + if (now - AppPreferences.getOtaLastCheckMs(this) < settings.checkIntervalMs) { + return; + } + AppPreferences.setOtaLastCheckMs(this, now); + OtaUpdateChecker.Result result = checkRoundRobin(settings); + if (result.error != null || !result.updateAvailable || result.manifest == null) { + return; + } + if (result.manifest.effectiveVersionCode() == AppPreferences.getOtaDismissedVersionCode(this)) { + return; + } + sendUpdateBroadcast(result.manifest, result.sourceUrl, settings.trusted); + } + + private void runCheckManual(@Nullable ResultReceiver receiver) { + OtaRuntimeSettings settings = OtaSettingsStore.load(this); + scheduleNextCheck(settings.checkIntervalMs); + if (settings.sourceUrls.isEmpty()) { + deliver(receiver, RESULT_ERROR, bundleError(getString(R.string.ota_no_url))); + return; + } + OtaUpdateChecker.Result result = checkRoundRobin(settings); + if (result.error != null) { + deliver(receiver, RESULT_ERROR, bundleError(result.error)); + return; + } + if (!result.updateAvailable || result.manifest == null) { + Bundle b = new Bundle(); + b.putString(EXTRA_INSTALLED_VERSION_NAME, result.installedVersionName); + b.putInt(EXTRA_INSTALLED_VERSION_CODE, result.installedVersionCode); + deliver(receiver, RESULT_UP_TO_DATE, b); + return; + } + if (result.manifest.effectiveVersionCode() == AppPreferences.getOtaDismissedVersionCode(this)) { + deliver(receiver, RESULT_DISMISSED, Bundle.EMPTY); + return; + } + Bundle b = new Bundle(); + b.putString(EXTRA_MANIFEST_JSON, result.manifest.toJsonString()); + b.putString(EXTRA_SOURCE_URL, result.sourceUrl); + b.putBoolean(EXTRA_TRUSTED_SOURCE, settings.trusted); + deliver(receiver, RESULT_UPDATE_AVAILABLE, b); + } + + private static void deliver(@Nullable ResultReceiver receiver, int code, Bundle data) { + if (receiver == null) { + return; + } + receiver.send(code, data != null ? data : Bundle.EMPTY); + } + + private Bundle bundleError(String message) { + Bundle b = new Bundle(); + b.putString(EXTRA_ERROR, message); + return b; + } + + private OtaUpdateChecker.Result checkRoundRobin(OtaRuntimeSettings settings) { + List urls = settings.sourceUrls; + if (urls.isEmpty()) { + return OtaUpdateChecker.Result.error("", getString(R.string.ota_no_url)); + } + int start = AppPreferences.getOtaRoundRobinIndex(this); + if (start >= urls.size()) { + start = 0; + } + int next = (start + 1) % urls.size(); + AppPreferences.setOtaRoundRobinIndex(this, next); + OtaUpdateChecker.Result lastError = null; + for (int i = 0; i < urls.size(); i++) { + int idx = (start + i) % urls.size(); + String source = urls.get(idx); + boolean strictVerification = !(settings.trusted && BuildConfig.DEBUG); + OtaUpdateChecker.Result result = OtaUpdateChecker.check(this, source, strictVerification); + if (result.error == null) { + return result; + } + lastError = result; + } + if (lastError != null) { + return lastError; + } + return OtaUpdateChecker.Result.error("", "No OTA sources configured"); + } + + private void sendUpdateBroadcast(OtaManifest manifest, String sourceUrl, boolean trustedSource) { + Intent i = new Intent(ACTION_UPDATE_AVAILABLE); + i.setPackage(getPackageName()); + i.putExtra(EXTRA_MANIFEST_JSON, manifest.toJsonString()); + i.putExtra(EXTRA_SOURCE_URL, sourceUrl != null ? sourceUrl : ""); + i.putExtra(EXTRA_TRUSTED_SOURCE, trustedSource); + sendBroadcast(i); + } + + private void startDownloadJob(OtaManifest manifest, boolean trustedSource, String sourceUrl, int startId) { + new Thread(() -> { + NotificationManager nm = getSystemService(NotificationManager.class); + try { + Notification initial = buildDownloadNotification(0, 0, true); + startForeground(NOTIFICATION_DOWNLOAD, initial); + OtaManifest checkedManifest = manifest; + if (trustedSource && BuildConfig.DEBUG) { + checkedManifest = new OtaManifest( + manifest.major, + manifest.minor, + manifest.build, + manifest.versionCode, + manifest.versionName, + manifest.apkUrl, + manifest.signUrl, + "", + manifest.packageSizeBytes, + manifest.mandatory, + manifest.releaseNotes); + } + File apk = OtaApkInstaller.downloadApk(this, checkedManifest, (downloaded, total) -> { + Notification n = buildDownloadNotification(downloaded, total, total <= 0); + nm.notify(NOTIFICATION_DOWNLOAD, n); + }); + stopForeground(STOP_FOREGROUND_REMOVE); + nm.cancel(NOTIFICATION_DOWNLOAD); + OtaApkInstaller.launchInstall(getApplicationContext(), apk); + } catch (Exception e) { + String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + Notification err = new NotificationCompat.Builder(this, CHANNEL_OTA) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(getString(R.string.ota_download_title)) + .setContentText(getString(R.string.ota_download_failed, msg)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build(); + nm.notify(NOTIFICATION_DOWNLOAD + 1, err); + stopForeground(STOP_FOREGROUND_REMOVE); + nm.cancel(NOTIFICATION_DOWNLOAD); + } finally { + stopSelf(startId); + } + }, "OtaApkDownload").start(); + } + + private Notification buildDownloadNotification(long downloaded, long total, boolean indeterminate) { + NotificationCompat.Builder b = new NotificationCompat.Builder(this, CHANNEL_OTA) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(getString(R.string.ota_download_title)) + .setContentText(getString(R.string.ota_downloading)) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW); + if (indeterminate || total <= 0) { + b.setProgress(0, 0, true); + } else { + int max = 100; + int prog = (int) Math.min(max, downloaded * max / total); + b.setProgress(max, prog, false); + } + return b.build(); + } + + private void ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationChannel ch = new NotificationChannel( + CHANNEL_OTA, + getString(R.string.ota_notification_channel_name), + NotificationManager.IMPORTANCE_LOW); + NotificationManager nm = getSystemService(NotificationManager.class); + nm.createNotificationChannel(ch); + } + + private void scheduleNextCheck(long delayMs) { + AlarmManager alarm = getSystemService(AlarmManager.class); + if (alarm == null) { + return; + } + long triggerAt = System.currentTimeMillis() + Math.max(10_000L, delayMs); + Intent intent = new Intent(this, OtaUpdateService.class); + intent.setAction(ACTION_CHECK_PERIODIC); + PendingIntent pi = PendingIntent.getService( + this, + 3001, + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarm.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi); + } else { + alarm.set(AlarmManager.RTC_WAKEUP, triggerAt, pi); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaVersion.java b/app/src/main/java/com/foxx/androidcast/ota/OtaVersion.java new file mode 100644 index 0000000..4d787fb --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ota/OtaVersion.java @@ -0,0 +1,49 @@ +package com.foxx.androidcast.ota; + +import com.foxx.androidcast.BuildConfig; + +/** major.minor.build packed the same way as Gradle {@code versionCode}. */ +public final class OtaVersion { + public static final int MAX_COMPONENT = 99; + + public final int major; + public final int minor; + public final int build; + + public OtaVersion(int major, int minor, int build) { + this.major = major; + this.minor = minor; + this.build = build; + } + + public static int pack(int major, int minor, int build) { + if (major < 0 || minor < 0 || build < 0 + || major > MAX_COMPONENT || minor > MAX_COMPONENT || build > MAX_COMPONENT) { + throw new IllegalArgumentException("OTA version out of range 0..99"); + } + return major * 10_000 + minor * 100 + build; + } + + public int packed() { + return pack(major, minor, build); + } + + public String displayName() { + return major + "." + minor + "." + build; + } + + public static OtaVersion fromPacked(int packed) { + int major = packed / 10_000; + int minor = (packed / 100) % 100; + int build = packed % 100; + return new OtaVersion(major, minor, build); + } + + public static OtaVersion installed() { + return new OtaVersion(BuildConfig.VERSION_MAJOR, BuildConfig.VERSION_MINOR, BuildConfig.VERSION_BUILD); + } + + public static boolean isUpdateAvailable(int remotePacked, int installedPacked) { + return remotePacked > installedPacked; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index 9d5e71a..f963577 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -855,7 +855,7 @@ public class ReceiverCastService extends Service { } recorder.setResolution(pendingWidth, pendingHeight); recorder.mergeLoss(buildReceiverLossSnapshot()); - CastBackgroundExecutor.execute(() -> recorder.finish(getApplicationContext())); + recorder.finish(getApplicationContext()); } private void attachSurface(Surface surface) { diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index 60f62ed..bcc7665 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -951,8 +951,7 @@ public class ScreenCastService extends Service implements ? activeSettings.getCaptureMode().name() : ""); } recorder.mergeLoss(buildSenderLossSnapshot()); - com.foxx.androidcast.util.CastBackgroundExecutor.execute( - () -> recorder.finish(getApplicationContext())); + recorder.finish(getApplicationContext()); } private void logEncodedCodecSample(String mime, byte[] frameData) { diff --git a/app/src/main/java/com/foxx/androidcast/settings/AppSettingsJson.java b/app/src/main/java/com/foxx/androidcast/settings/AppSettingsJson.java new file mode 100644 index 0000000..8ab853d --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/settings/AppSettingsJson.java @@ -0,0 +1,32 @@ +package com.foxx.androidcast.settings; + +import android.content.Context; + +import org.json.JSONObject; + +import java.io.File; +import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; + +/** Reads app-private {@code files/settings.json}. */ +public final class AppSettingsJson { + public static final String SETTINGS_FILE = "settings.json"; + + private AppSettingsJson() {} + + public static JSONObject readRoot(Context context) { + File f = new File(context.getApplicationContext().getFilesDir(), SETTINGS_FILE); + if (!f.exists()) { + return new JSONObject(); + } + try (FileInputStream fis = new FileInputStream(f)) { + return new JSONObject(new String(fis.readAllBytes(), StandardCharsets.UTF_8)); + } catch (Exception ignored) { + return new JSONObject(); + } + } + + public static JSONObject section(Context context, String name) { + return readRoot(context).optJSONObject(name); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsContext.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsContext.java index be66f10..eba3eca 100644 --- a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsContext.java +++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsContext.java @@ -1,5 +1,7 @@ package com.foxx.androidcast.stats; +import android.content.Context; + import java.util.concurrent.atomic.AtomicReference; /** Active session stats recorder for transport/pump hooks without threading references everywhere. */ @@ -54,4 +56,19 @@ public final class SessionStatsContext { r.flushGeneralIfDue(); } } + + /** + * Best-effort: persist the active session when the JVM is about to die from an uncaught exception. + * Native crashes (SIGSEGV) are not covered. + */ + public static void flushActiveOnUncaughtException(Context context) { + SessionStatsRecorder r = ACTIVE.get(); + if (r == null) { + return; + } + try { + r.finish(context.getApplicationContext(), "crash"); + } catch (Throwable ignored) { + } + } } diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java index 596cede..aee177e 100644 --- a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java +++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java @@ -11,6 +11,8 @@ import com.foxx.androidcast.network.transport.TransportLossStats; import org.json.JSONArray; import org.json.JSONObject; +import java.util.concurrent.atomic.AtomicBoolean; + /** * Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}. */ @@ -43,6 +45,9 @@ public final class SessionStatsRecorder { private final JSONArray samples = new JSONArray(); private long lastSampleMs; + /** Ensures each recorder persists at most once (normal end vs crash hook). */ + private final AtomicBoolean persisted = new AtomicBoolean(false); + /** @param direction {@code send} or {@code recv}. */ public SessionStatsRecorder(String direction, String transport) { this.direction = "recv".equals(direction) ? "recv" : "send"; @@ -236,96 +241,118 @@ public final class SessionStatsRecorder { } } + /** Normal cast end: persist session JSON to disk (synchronous). */ public void finish(Context context) { + finish(context, null); + } + + /** + * Persist session JSON. {@code reason} non-null is recorded under {@code globals.reason} + * (e.g. {@code crash} from an uncaught exception hook). Pass {@code null} for a normal session end. + */ + public void finish(Context context, String reason) { SessionStatsContext.unbind(this); if (!AppPreferences.isGrabSessionStats(context)) { return; } + if (!persisted.compareAndSet(false, true)) { + return; + } journal.flushGeneralFinal(); long endedMs = System.currentTimeMillis(); try { - JSONObject root = new JSONObject(); - root.put("schema_version", 2); - root.put("direction", direction); - root.put("started_at_epoch_ms", startedMs); - root.put("ended_at_epoch_ms", endedMs); - root.put("duration_ms", endedMs - startedMs); - root.put("role", role); - root.put("transport", transport); - root.put("client_count", clientCount); - root.put("disrupt_count", disruptCount); - root.put("idle_timeout_count", idleTimeouts); - root.put("video_codec", videoCodec); - root.put("audio_codec", audioCodec); - root.put("capture_mode", captureMode); - root.put("resolution", resolution); - root.put("network_samples", networkSamples); - - JSONArray journalEvents = journal.copyEvents(); - if (journalEvents.length() > 0) { - root.put(direction, journalEvents); - } - - if (networkSamples > 0) { - root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples)); - root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples))); - } - if (bitrateSamples > 0) { - root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples); - } - if (fpsSamples > 0) { - root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples)); - } - - JSONObject protection = new JSONObject(); - StreamProtectionMode protMode = lossTotals.protectionMode != null - ? lossTotals.protectionMode : StreamProtectionMode.NONE; - protection.put("mode", protMode.name()); - protection.put("fec_enabled", protMode.usesFec()); - protection.put("nack_enabled", protMode.usesNack()); - protection.put("fec_packets_encoded", lossTotals.fecPacketsEncoded); - protection.put("fec_packets_decoded", lossTotals.fecPacketsDecoded); - protection.put("fec_decode_failures", lossTotals.fecDecodeFailures); - protection.put("nack_requests_sent", lossTotals.nackRequestsSent); - protection.put("nack_requests_received", lossTotals.nackRequestsReceived); - protection.put("nack_retransmits", lossTotals.nackRetransmits); - protection.put("fec_stub_packets", lossTotals.fecPacketsStub); - protection.put("nack_stub_requests", lossTotals.nackRequestsStub); - root.put("stream_protection", protection); - - JSONObject udp = new JSONObject(); - synchronized (lossTotals) { - udp.put("datagrams_tx", lossTotals.datagramsSent); - udp.put("datagrams_rx", lossTotals.datagramsReceived); - udp.put("datagrams_invalid", lossTotals.datagramsInvalid); - udp.put("fragments_rx", lossTotals.fragmentsReceived); - udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate); - udp.put("reassembly_ok", lossTotals.reassemblyCompleted); - udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded); - udp.put("wire_dropped", lossTotals.wirePacketsDropped); - udp.put("malformed", lossTotals.malformedPackets); - udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP); - udp.put("send_dropped_audio", lossTotals.sendDroppedAudio); - udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey); - udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors); - udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors); - udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops); - udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames); - udp.put("fec_packets_encoded", lossTotals.fecPacketsEncoded); - udp.put("fec_packets_decoded", lossTotals.fecPacketsDecoded); - udp.put("fec_decode_failures", lossTotals.fecDecodeFailures); - udp.put("nack_requests_sent", lossTotals.nackRequestsSent); - udp.put("nack_requests_received", lossTotals.nackRequestsReceived); - udp.put("nack_retransmits", lossTotals.nackRetransmits); - udp.put("nack_cache_misses", lossTotals.nackCacheMisses); - } - root.put("udp", udp); - if (samples.length() > 0) { - root.put("samples", samples); - } - + JSONObject root = buildSessionJson(endedMs, reason); SessionStatsStore.saveSession(context, startedMs, direction, root); } catch (Exception ignored) { } } + + JSONObject buildSessionJson(long endedMs, String reason) throws Exception { + JSONObject root = new JSONObject(); + root.put("schema_version", 2); + root.put("direction", direction); + root.put("started_at_epoch_ms", startedMs); + root.put("ended_at_epoch_ms", endedMs); + root.put("duration_ms", endedMs - startedMs); + root.put("role", role); + root.put("transport", transport); + root.put("client_count", clientCount); + root.put("disrupt_count", disruptCount); + root.put("idle_timeout_count", idleTimeouts); + root.put("video_codec", videoCodec); + root.put("audio_codec", audioCodec); + root.put("capture_mode", captureMode); + root.put("resolution", resolution); + root.put("network_samples", networkSamples); + + JSONArray journalEvents = journal.copyEvents(); + if (journalEvents.length() > 0) { + root.put(direction, journalEvents); + } + + if (networkSamples > 0) { + root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples)); + root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples))); + } + if (bitrateSamples > 0) { + root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples); + } + if (fpsSamples > 0) { + root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples)); + } + + JSONObject protection = new JSONObject(); + StreamProtectionMode protMode = lossTotals.protectionMode != null + ? lossTotals.protectionMode : StreamProtectionMode.NONE; + protection.put("mode", protMode.name()); + protection.put("fec_enabled", protMode.usesFec()); + protection.put("nack_enabled", protMode.usesNack()); + protection.put("fec_packets_encoded", lossTotals.fecPacketsEncoded); + protection.put("fec_packets_decoded", lossTotals.fecPacketsDecoded); + protection.put("fec_decode_failures", lossTotals.fecDecodeFailures); + protection.put("nack_requests_sent", lossTotals.nackRequestsSent); + protection.put("nack_requests_received", lossTotals.nackRequestsReceived); + protection.put("nack_retransmits", lossTotals.nackRetransmits); + protection.put("fec_stub_packets", lossTotals.fecPacketsStub); + protection.put("nack_stub_requests", lossTotals.nackRequestsStub); + root.put("stream_protection", protection); + + JSONObject udp = new JSONObject(); + synchronized (lossTotals) { + udp.put("datagrams_tx", lossTotals.datagramsSent); + udp.put("datagrams_rx", lossTotals.datagramsReceived); + udp.put("datagrams_invalid", lossTotals.datagramsInvalid); + udp.put("fragments_rx", lossTotals.fragmentsReceived); + udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate); + udp.put("reassembly_ok", lossTotals.reassemblyCompleted); + udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded); + udp.put("wire_dropped", lossTotals.wirePacketsDropped); + udp.put("malformed", lossTotals.malformedPackets); + udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP); + udp.put("send_dropped_audio", lossTotals.sendDroppedAudio); + udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey); + udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors); + udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors); + udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops); + udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames); + udp.put("fec_packets_encoded", lossTotals.fecPacketsEncoded); + udp.put("fec_packets_decoded", lossTotals.fecPacketsDecoded); + udp.put("fec_decode_failures", lossTotals.fecDecodeFailures); + udp.put("nack_requests_sent", lossTotals.nackRequestsSent); + udp.put("nack_requests_received", lossTotals.nackRequestsReceived); + udp.put("nack_retransmits", lossTotals.nackRetransmits); + udp.put("nack_cache_misses", lossTotals.nackCacheMisses); + } + root.put("udp", udp); + if (samples.length() > 0) { + root.put("samples", samples); + } + + if (reason != null && !reason.isEmpty()) { + JSONObject globals = new JSONObject(); + globals.put("reason", reason); + root.put("globals", globals); + } + return root; + } } diff --git a/app/src/main/res/layout/activity_developer_settings.xml b/app/src/main/res/layout/activity_developer_settings.xml index 140a26e..041b234 100644 --- a/app/src/main/res/layout/activity_developer_settings.xml +++ b/app/src/main/res/layout/activity_developer_settings.xml @@ -58,5 +58,55 @@ android:layout_marginTop="8dp" android:text="@string/dev_reload_codecs_json_hint" android:textSize="12sp" /> + + + + + + + + + + + + + +

Default: admin / admin

+

Register (coming soon)

+ + + diff --git a/examples/crash_reporter/backend/views/report_detail.php b/examples/crash_reporter/backend/views/report_detail.php new file mode 100644 index 0000000..3671b03 --- /dev/null +++ b/examples/crash_reporter/backend/views/report_detail.php @@ -0,0 +1,50 @@ + +

Crash report

+

Report ·

+
+

Device

+

+

Android (SDK )

+ +

ABIs:

+ +
+

App

+

+

v ()

+ +

Build:

+ +
+

Timing

+

Generated:

+

Received:

+

Fingerprint:

+
+
+ +
+

Java exception

+

+

Thread:

+
+
+ + +
+

Native crash

+

Signal:

+
+
+ + +
Session context (crash)
+ +
Raw JSON
+

← Back to list

diff --git a/examples/crash_reporter/sample_crash_java.json b/examples/crash_reporter/sample_crash_java.json new file mode 100644 index 0000000..142096e --- /dev/null +++ b/examples/crash_reporter/sample_crash_java.json @@ -0,0 +1,36 @@ +{ + "schema_version": 1, + "report_id": "00000000-0000-4000-8000-000000000001", + "generated_at_epoch_ms": 1710000000000, + "crash_type": "java", + "process": "main", + "fingerprint": "abc123", + "device": { + "manufacturer": "Doogee", + "brand": "DOOGEE", + "model": "Tab G6 Max", + "device": "TabG6Max", + "product": "TabG6Max", + "sdk_int": 34, + "release": "14", + "abis": ["arm64-v8a", "armeabi-v7a"] + }, + "app": { + "package": "com.foxx.androidcast", + "version_name": "0.1.0", + "version_code": 100 + }, + "build": { + "debug": true, + "git_commit": "deadbeef" + }, + "java": { + "thread": "main", + "exception": "java.lang.NullPointerException", + "message": "sample", + "stack_frames": [ + "at com.foxx.androidcast.sender.ScreenCastService.onCreate(ScreenCastService.java:120)", + "at android.app.ActivityThread.handleCreateService(ActivityThread.java:1234)" + ] + } +} diff --git a/examples/ota/mqtt-topics.md b/examples/ota/mqtt-topics.md new file mode 100644 index 0000000..7ba79e2 --- /dev/null +++ b/examples/ota/mqtt-topics.md @@ -0,0 +1,17 @@ +# MQTT OTA topics (retained payloads) + +Publish retained payloads for these topics: + +- `v0/ota/channel/stable.json` -> content of `examples/ota/v0/ota/channel/stable.json` +- `v0/ota/channel/current.json` -> content of `examples/ota/v0/ota/channel/current.json` +- `v0/ota/channel/next.json` -> content of `examples/ota/v0/ota/channel/next.json` +- `v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_manifest.json` +- `v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_sign.json` +- `v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00.otapkg` +- `v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json` +- `v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_sign.json` +- `v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01.otapkg` + +Example source URL in app settings: + +- `mqtt://foxx.org:1883/v0/ota/channel/stable.json` diff --git a/examples/ota/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00.otapkg b/examples/ota/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00.otapkg new file mode 100644 index 0000000..e52c4a8 --- /dev/null +++ b/examples/ota/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00.otapkg @@ -0,0 +1,2 @@ +PLACEHOLDER PACKAGE +Replace this with the real release artifact bytes. diff --git a/examples/ota/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_manifest.json b/examples/ota/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_manifest.json new file mode 100644 index 0000000..07084a3 --- /dev/null +++ b/examples/ota/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_manifest.json @@ -0,0 +1,12 @@ +{ + "schema": "v0", + "major": 0, + "minor": 1, + "build": 0, + "versionName": "0.1.0", + "apkUrl": "https://foxx.org/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00.otapkg", + "signUrl": "https://foxx.org/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_sign.json", + "sizeBytes": 10485760, + "mandatory": false, + "releaseNotes": "Current production baseline." +} diff --git a/examples/ota/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_sign.json b/examples/ota/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_sign.json new file mode 100644 index 0000000..c05575a --- /dev/null +++ b/examples/ota/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_sign.json @@ -0,0 +1,4 @@ +{ + "schema": "v0", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} diff --git a/examples/ota/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01.otapkg b/examples/ota/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01.otapkg new file mode 100644 index 0000000..5600987 --- /dev/null +++ b/examples/ota/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01.otapkg @@ -0,0 +1,2 @@ +PLACEHOLDER PACKAGE +Replace this with the next OTA candidate artifact bytes. diff --git a/examples/ota/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json b/examples/ota/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json new file mode 100644 index 0000000..33167c6 --- /dev/null +++ b/examples/ota/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json @@ -0,0 +1,12 @@ +{ + "schema": "v0", + "major": 0, + "minor": 1, + "build": 1, + "versionName": "0.1.1", + "apkUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01.otapkg", + "signUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_sign.json", + "sizeBytes": 11534336, + "mandatory": false, + "releaseNotes": "Next OTA candidate with service-based updater." +} diff --git a/examples/ota/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_sign.json b/examples/ota/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_sign.json new file mode 100644 index 0000000..295b24d --- /dev/null +++ b/examples/ota/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_sign.json @@ -0,0 +1,4 @@ +{ + "schema": "v0", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +} diff --git a/examples/ota/v0/ota/channel/current.json b/examples/ota/v0/ota/channel/current.json new file mode 100644 index 0000000..12eae24 --- /dev/null +++ b/examples/ota/v0/ota/channel/current.json @@ -0,0 +1,4 @@ +{ + "schema": "v0", + "manifestUrl": "https://foxx.org/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_manifest.json" +} diff --git a/examples/ota/v0/ota/channel/next.json b/examples/ota/v0/ota/channel/next.json new file mode 100644 index 0000000..f64b204 --- /dev/null +++ b/examples/ota/v0/ota/channel/next.json @@ -0,0 +1,4 @@ +{ + "schema": "v0", + "manifestUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json" +} diff --git a/examples/ota/v0/ota/channel/stable.json b/examples/ota/v0/ota/channel/stable.json new file mode 100644 index 0000000..f64b204 --- /dev/null +++ b/examples/ota/v0/ota/channel/stable.json @@ -0,0 +1,4 @@ +{ + "schema": "v0", + "manifestUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json" +} diff --git a/examples/settings.json b/examples/settings.json new file mode 100644 index 0000000..19af3cc --- /dev/null +++ b/examples/settings.json @@ -0,0 +1,19 @@ +{ + "ota": { + "base_url": "https://foxx.org/v0/ota/channel/stable.json", + "base_urls": [ + "https://foxx.org/v0/ota/channel/stable.json", + "https://mirror.foxx.org/v0/ota/channel/stable.json", + "mqtt://foxx.org:8443/ota/v0/channel/stable.json" + ], + "trusted": "true", + "check_interval": "1m" + }, + "crash": { + "enabled": "true", + "upload_url": "https://f0xx.org/app/androidcast_project/crashes/api/upload.php", + "prune_after_upload": "true", + "upload_interval": "5m", + "max_pending": 32 + } +} diff --git a/ndk/CMakeLists.txt b/ndk/CMakeLists.txt index ebb6839..236a81f 100644 --- a/ndk/CMakeLists.txt +++ b/ndk/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(androidcast_codecs SHARED ${NDK_BRIDGE}/jni/libvpx_bridge.c ${NDK_BRIDGE}/jni/opus_bridge.c ${NDK_BRIDGE}/jni/speex_bridge.c + ${NDK_BRIDGE}/jni/crash_hook.c ) target_include_directories(androidcast_codecs PRIVATE @@ -47,4 +48,5 @@ endif() find_library(log-lib log) find_library(android-lib android) -target_link_libraries(androidcast_codecs ${log-lib} ${android-lib}) +find_library(dl-lib dl) +target_link_libraries(androidcast_codecs ${log-lib} ${android-lib} ${dl-lib}) diff --git a/ndk/jni/crash_hook.c b/ndk/jni/crash_hook.c new file mode 100644 index 0000000..9e747bb --- /dev/null +++ b/ndk/jni/crash_hook.c @@ -0,0 +1,122 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOG_TAG "androidcast_crash" +#define REPORT_DIR_MAX 512 +#define MAX_FRAMES 64 + +static char g_report_dir[REPORT_DIR_MAX]; +static volatile sig_atomic_t g_handling; + +typedef struct { + void **frames; + int max; + int count; +} backtrace_state_t; + +static _Unwind_Reason_Code unwind_callback(struct _Unwind_Context *context, void *arg) { + backtrace_state_t *state = (backtrace_state_t *) arg; + uintptr_t pc = _Unwind_GetIP(context); + if (pc == 0) { + return _URC_NO_REASON; + } + if (state->count < state->max) { + state->frames[state->count++] = (void *) pc; + } + return _URC_NO_REASON; +} + +static int capture_backtrace(void **frames, int max) { + backtrace_state_t state = {frames, max, 0}; + _Unwind_Backtrace(unwind_callback, &state); + return state.count; +} + +static void write_native_report(const char *signal_name) { + if (g_report_dir[0] == '\0') { + return; + } + char path[REPORT_DIR_MAX + 64]; + snprintf(path, sizeof(path), "%s/native_%d_%ld.txt", + g_report_dir, (int) getpid(), (long) time(NULL)); + FILE *f = fopen(path, "w"); + if (!f) { + return; + } + fprintf(f, "signal=%s\n", signal_name); + fprintf(f, "pid=%d\n", (int) getpid()); + void *frames[MAX_FRAMES]; + int n = capture_backtrace(frames, MAX_FRAMES); + for (int i = 0; i < n; i++) { + Dl_info info; + memset(&info, 0, sizeof(info)); + if (dladdr(frames[i], &info) && info.dli_fname) { + uintptr_t offset = (uintptr_t) frames[i] - (uintptr_t) info.dli_fbase; + fprintf(f, "#%02d %p %s", i, frames[i], info.dli_fname); + if (info.dli_sname) { + fprintf(f, " %s+0x%lx", info.dli_sname, + (unsigned long) ((uintptr_t) frames[i] - (uintptr_t) info.dli_saddr)); + } else { + fprintf(f, "+0x%lx", (unsigned long) offset); + } + fprintf(f, "\n"); + } else { + fprintf(f, "#%02d %p\n", i, frames[i]); + } + } + fclose(f); +} + +static void crash_signal_handler(int sig) { + if (g_handling) { + _exit(128 + sig); + } + g_handling = 1; + const char *name = "UNKNOWN"; + switch (sig) { + case SIGSEGV: name = "SIGSEGV"; break; + case SIGABRT: name = "SIGABRT"; break; + case SIGBUS: name = "SIGBUS"; break; + case SIGFPE: name = "SIGFPE"; break; + case SIGILL: name = "SIGILL"; break; + } + write_native_report(name); + signal(sig, SIG_DFL); + raise(sig); +} + +static void install_handlers(void) { + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = crash_signal_handler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESETHAND; + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGABRT, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + sigaction(SIGFPE, &sa, NULL); + sigaction(SIGILL, &sa, NULL); +} + +JNIEXPORT void JNICALL +Java_com_foxx_androidcast_crash_CrashNativeBridge_nativeInstallCrashHook( + JNIEnv *env, jclass clazz, jstring reportDir) { + (void) clazz; + const char *dir = (*env)->GetStringUTFChars(env, reportDir, NULL); + if (!dir) { + return; + } + strncpy(g_report_dir, dir, REPORT_DIR_MAX - 1); + g_report_dir[REPORT_DIR_MAX - 1] = '\0'; + (*env)->ReleaseStringUTFChars(env, reportDir, dir); + install_handlers(); + __android_log_print(ANDROID_LOG_INFO, LOG_TAG, "native crash hook installed: %s", g_report_dir); +} diff --git a/scripts/generate-ota-v0.sh b/scripts/generate-ota-v0.sh new file mode 100755 index 0000000..9cf6deb --- /dev/null +++ b/scripts/generate-ota-v0.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Generate v0 OTA artifacts for one APK (stdout = channel stable.json). +# +# Usage: +# ./scripts/generate-ota-v0.sh path/to/app-release.apk https://host[:port] [out-dir] +# +# Writes under out-dir (default: ./ota-publish): +# v0/ota/channel/stable.json +# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB_manifest.json +# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB_sign.json +# (copy APK to …/android_cast_00.MM.mm.BB.otapkg if out-dir set) +set -euo pipefail + +apk="${1:?APK path required}" +host_base="${2:?Base URL required, e.g. https://192.168.1.1:8080}" +out_dir="${3:-ota-publish}" + +if [[ ! -f "$apk" ]]; then + echo "APK not found: $apk" >&2 + exit 1 +fi + +pad2() { printf '%02d' "$1"; } + +major="" minor="" build="" +if command -v aapt >/dev/null 2>&1; then + badging="$(aapt dump badging "$apk" 2>/dev/null || true)" + version_code="$(echo "$badging" | sed -n "s/.*versionCode='\([^']*\)'.*/\1/p" | head -1)" + version_name="$(echo "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)" + if [[ -n "${version_code:-}" ]]; then + major=$((version_code / 10000)) + minor=$(((version_code / 100) % 100)) + build=$((version_code % 100)) + fi +fi + +if [[ -z "${major:-}" ]]; then + echo "Could not read versionCode from APK (install build-tools / aapt)." >&2 + exit 1 +fi + +maj_p="$(pad2 "$major")" +min_p="$(pad2 "$minor")" +bld_p="$(pad2 "$build")" +ver_p="${maj_p}.${min_p}.${bld_p}" +base="${host_base%/}/v0/ota/00/${maj_p}/${ver_p}" +artifact_base="${base}/android_cast_${ver_p}.${bld_p}" +apk_url="${artifact_base}.otapkg" +sign_url="${artifact_base}_sign.json" +manifest_url="${artifact_base}_manifest.json" +sha256="$(sha256sum "$apk" | awk '{print $1}')" + +rel_root="${out_dir}/v0/ota" +rel_dir="${rel_root}/00/${maj_p}/${ver_p}" +mkdir -p "${rel_dir}" "${rel_root}/channel" + +pkg_name="android_cast_${ver_p}.${bld_p}.otapkg" +cp -f "$apk" "${rel_dir}/${pkg_name}" + +cat >"${rel_dir}/android_cast_${ver_p}.${bld_p}_sign.json" <"${rel_dir}/android_cast_${ver_p}.${bld_p}_manifest.json" <"${rel_root}/channel/stable.json" <&2 +echo "Channel: ${host_base%/}/v0/ota/channel/stable.json" >&2 +cat "${rel_root}/channel/stable.json"