1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 07:20:00 +03:00

crash handler snapshot, unverified

This commit is contained in:
Anton Afanasyeu
2026-05-20 14:31:55 +02:00
parent a4107797fa
commit d420c3e94a
77 changed files with 3932 additions and 87 deletions

View File

@@ -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'
}

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
@@ -15,6 +16,7 @@
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".AndroidCastApplication"
@@ -89,6 +91,16 @@
android:theme="@style/Theme.AndroidCast.Fullscreen"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density" />
<service
android:name=".ota.OtaUpdateService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".crash.CrashWatcherService"
android:exported="false"
android:process=":crashwatcher" />
<service
android:name=".sender.ScreenCastService"
android:exported="false"

View File

@@ -3,6 +3,8 @@ package com.foxx.androidcast;
import android.app.Application;
import android.content.Context;
import com.foxx.androidcast.crash.CrashReporter;
import com.foxx.androidcast.crash.CrashWatcherService;
import com.foxx.androidcast.media.CodecPriorityCatalog;
/** Applies saved theme and locale at process start. */
@@ -19,5 +21,7 @@ public class AndroidCastApplication extends Application {
CastLocaleHelper.applyStoredLocale(this);
CastThemeHelper.applyNightMode(this);
CastTrayNotifier.syncOnAppStart(this);
CrashReporter.install(this);
CrashWatcherService.scheduleUploadSweep(this);
}
}

View File

@@ -20,9 +20,19 @@ public final class AppPreferences {
private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio";
private static final String KEY_DEV_RECEIVER_DEBUG_OVERLAY = "dev_receiver_debug_overlay";
private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats";
private static final String KEY_SEND_ANONYMOUS_CRASH_LOGS = "send_anonymous_crash_logs";
private static final String KEY_DEV_SHOW_CODEC_PRIORITY_SCORES = "dev_show_codec_priority_scores";
private static final String KEY_RECEIVER_PIP_USER_DEFINED = "receiver_pip_user_defined";
private static final String KEY_RECEIVER_PIP_ENABLED = "receiver_pip_enabled";
private static final String KEY_OTA_CHANNEL_URL = "ota_channel_url";
private static final String KEY_OTA_MANIFEST_URL_LEGACY = "ota_manifest_url";
private static final String KEY_OTA_CHECK_ON_LAUNCH = "ota_check_on_launch";
private static final String KEY_OTA_LAST_CHECK_MS = "ota_last_check_ms";
private static final String KEY_OTA_DISMISSED_VERSION = "ota_dismissed_version_code";
private static final String KEY_OTA_ROUND_ROBIN_INDEX = "ota_round_robin_index";
/** Minimum interval between automatic OTA checks on app launch. */
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
private static final String PREFIX_SENDER = "sender_";
private static final String PREFIX_RECEIVER = "receiver_";
@@ -115,6 +125,15 @@ public final class AppPreferences {
prefs(context).edit().putBoolean(KEY_DEV_GRAB_SESSION_STATS, grab).apply();
}
/** Send anonymous crash reports via the standalone crash watcher process (default on). */
public static boolean isSendAnonymousCrashLogs(Context context) {
return prefs(context).getBoolean(KEY_SEND_ANONYMOUS_CRASH_LOGS, true);
}
public static void setSendAnonymousCrashLogs(Context context, boolean send) {
prefs(context).edit().putBoolean(KEY_SEND_ANONYMOUS_CRASH_LOGS, send).apply();
}
/** Developer: show priority scores in codec settings spinners (default off). */
public static boolean isShowCodecPriorityScores(Context context) {
return prefs(context).getBoolean(KEY_DEV_SHOW_CODEC_PRIORITY_SCORES, false);
@@ -124,6 +143,56 @@ public final class AppPreferences {
prefs(context).edit().putBoolean(KEY_DEV_SHOW_CODEC_PRIORITY_SCORES, show).apply();
}
/** v0 channel URL ({@code …/v0/ota/channel/stable.json}) or direct manifest URL. */
public static String getOtaChannelUrl(Context context) {
SharedPreferences p = prefs(context);
String url = p.getString(KEY_OTA_CHANNEL_URL, "").trim();
if (url.isEmpty()) {
url = p.getString(KEY_OTA_MANIFEST_URL_LEGACY, "").trim();
}
return url;
}
public static void setOtaChannelUrl(Context context, String url) {
String v = url != null ? url.trim() : "";
prefs(context).edit()
.putString(KEY_OTA_CHANNEL_URL, v)
.remove(KEY_OTA_MANIFEST_URL_LEGACY)
.apply();
}
public static boolean isOtaCheckOnLaunch(Context context) {
return prefs(context).getBoolean(KEY_OTA_CHECK_ON_LAUNCH, true);
}
public static void setOtaCheckOnLaunch(Context context, boolean check) {
prefs(context).edit().putBoolean(KEY_OTA_CHECK_ON_LAUNCH, check).apply();
}
public static long getOtaLastCheckMs(Context context) {
return prefs(context).getLong(KEY_OTA_LAST_CHECK_MS, 0L);
}
public static void setOtaLastCheckMs(Context context, long epochMs) {
prefs(context).edit().putLong(KEY_OTA_LAST_CHECK_MS, epochMs).apply();
}
public static int getOtaRoundRobinIndex(Context context) {
return Math.max(0, prefs(context).getInt(KEY_OTA_ROUND_ROBIN_INDEX, 0));
}
public static void setOtaRoundRobinIndex(Context context, int nextIndex) {
prefs(context).edit().putInt(KEY_OTA_ROUND_ROBIN_INDEX, Math.max(0, nextIndex)).apply();
}
public static int getOtaDismissedVersionCode(Context context) {
return prefs(context).getInt(KEY_OTA_DISMISSED_VERSION, 0);
}
public static void setOtaDismissedVersionCode(Context context, int versionCode) {
prefs(context).edit().putInt(KEY_OTA_DISMISSED_VERSION, versionCode).apply();
}
/**
* Receiver PiP: user toggle when set; otherwise derived from PiP support + notifications permission.
*/

View File

@@ -57,6 +57,7 @@ public class CastSettingsFragment extends Fragment {
usernameInput = view.findViewById(R.id.edit_username);
pinInput = view.findViewById(R.id.edit_settings_pin);
trayIconCheck = view.findViewById(R.id.check_tray_icon);
CheckBox crashLogsCheck = view.findViewById(R.id.check_send_crash_logs);
themeSpinner = view.findViewById(R.id.spinner_theme);
languageSpinner = view.findViewById(R.id.spinner_language);
@@ -76,6 +77,11 @@ public class CastSettingsFragment extends Fragment {
AppPreferences.setShowTrayIcon(requireContext(), checked);
CastTrayNotifier.onPreferenceChanged(requireContext());
});
if (crashLogsCheck != null) {
crashLogsCheck.setChecked(AppPreferences.isSendAnonymousCrashLogs(requireContext()));
crashLogsCheck.setOnCheckedChangeListener((btn, checked) ->
AppPreferences.setSendAnonymousCrashLogs(requireContext(), checked));
}
themeSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
private boolean first = true;

View File

@@ -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) {

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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 "";
}
}
}

View File

@@ -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<File> 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<File> 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<File> pending = listPending(filesDir);
while (pending.size() > maxFiles) {
pending.get(0).delete();
pending = listPending(filesDir);
}
}
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

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

View File

@@ -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<File> 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;
}
}

View File

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

View File

@@ -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", ""));
}
}

View File

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

View File

@@ -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 "{}";
}
}
}

View File

@@ -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<byte[]> payload = new AtomicReference<>();
AtomicReference<Throwable> 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) {
}
}
}
}

View File

@@ -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<String> sourceUrls;
public final long checkIntervalMs;
public final boolean trusted;
OtaRuntimeSettings(List<String> sourceUrls, long checkIntervalMs, boolean trusted) {
this.sourceUrls = Collections.unmodifiableList(new ArrayList<>(sourceUrls));
this.checkIntervalMs = Math.max(10_000L, checkIntervalMs);
this.trusted = trusted;
}
}

View File

@@ -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<String, Boolean> 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<String> 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<String, Boolean> 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);
}
}

View File

@@ -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", ""));
}
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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<String> 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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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) {
}
}
}

View File

@@ -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;
}
}

View File

@@ -58,5 +58,55 @@
android:layout_marginTop="8dp"
android:text="@string/dev_reload_codecs_json_hint"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:text="@string/ota_section_title"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_ota_installed_version"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="13sp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/ota_manifest_url_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_ota_manifest_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<CheckBox
android:id="@+id/check_ota_on_launch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:checked="true"
android:text="@string/ota_check_on_launch" />
<Button
android:id="@+id/btn_check_ota"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/ota_check_now" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/ota_section_hint"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/text_ota_download_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/ota_downloading" />
<ProgressBar
android:id="@+id/progress_ota_download"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:indeterminate="true" />
</LinearLayout>

View File

@@ -87,6 +87,14 @@
android:checked="true"
android:text="@string/label_show_tray_icon" />
<CheckBox
android:id="@+id/check_send_crash_logs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:checked="true"
android:text="@string/label_send_anonymous_crash_logs" />
<include
android:id="@+id/header_sender"
layout="@layout/include_settings_section_header"

View File

@@ -128,6 +128,7 @@
<string name="settings_section_receiver">Receiver settings</string>
<string name="label_username">Username</string>
<string name="label_show_tray_icon">Always show icon in system bar</string>
<string name="label_send_anonymous_crash_logs">Send anonymous crash logs</string>
<string name="pin_anonymous_hint">Leave empty for anonymous cast</string>
<string name="label_receiver_display">Resolution</string>
<string name="receiver_display_auto_fit">AUTO (fits screen)</string>
@@ -216,6 +217,25 @@
<string name="session_stats_chart_hint">Tap sessions to toggle overlay. Pinch or scroll wheel to zoom. Drag finger for values.</string>
<string name="dev_reload_codecs_json">Reload codecs.json</string>
<string name="dev_reload_codecs_json_hint">Loads assets/codecs.json overrides without restarting the app. Reopen the settings drawer to refresh spinners.</string>
<string name="ota_section_title">App updates (OTA)</string>
<string name="ota_section_hint">v0 layout: https://host/v0/ota/channel/stable.json → manifest → APK. Service checks every 1 minute by default (overridable in private settings.json).</string>
<string name="ota_manifest_url_hint">OTA channel URL (v0 stable.json)</string>
<string name="ota_check_on_launch">Enable OTA periodic checks (default every 1 minute)</string>
<string name="ota_check_now">Check for updates now</string>
<string name="ota_installed_version">Installed: %1$s (%2$d)</string>
<string name="ota_no_url">No OTA channel URL — set one in developer settings or local.properties (ota.channel.url).</string>
<string name="ota_check_failed">Update check failed: %1$s</string>
<string name="ota_up_to_date">Up to date (%1$s, %2$d).</string>
<string name="ota_dismissed_hint">You dismissed this version — tap Check again to see it.</string>
<string name="ota_update_title">Update available</string>
<string name="ota_update_message">New OTA version found (%1$s, current version is: %2$s / %3$s MB). Would you like to perform the update?\n\n%4$s</string>
<string name="ota_no_release_notes">No release notes.</string>
<string name="ota_download_install">Download &amp; install</string>
<string name="ota_later">Not now</string>
<string name="ota_download_title">Downloading update</string>
<string name="ota_downloading">Downloading APK…</string>
<string name="ota_download_failed">Download failed: %1$s</string>
<string name="ota_notification_channel_name">App updates</string>
<string name="codec_priority_score_suffix"> · %1$d</string>
<string name="label_audio_codec">Voice codec</string>
<string name="codec_libvpx_vp8">libvpx (VP8)</string>

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="session_stats" path="session_stats/" />
<cache-path name="ota_cache" path="ota/" />
</paths>

View File

@@ -0,0 +1,23 @@
package com.foxx.androidcast.ota;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
public class OtaApkInstallerTest {
@Test
public void sha256Hex_knownVector() throws Exception {
File f = File.createTempFile("ota-test", ".bin");
try (FileOutputStream fos = new FileOutputStream(f)) {
fos.write("hello".getBytes(StandardCharsets.UTF_8));
}
assertEquals(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
OtaApkInstaller.sha256Hex(f));
f.delete();
}
}

View File

@@ -0,0 +1,33 @@
package com.foxx.androidcast.ota;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class OtaManifestTest {
@Test
public void effectiveVersionCode_prefersTriple() {
OtaManifest m = new OtaManifest(0, 1, 5, 999, "0.1.5",
"https://example.com/x.otapkg", "", "", 0L, false, "");
assertEquals(105, m.effectiveVersionCode());
assertEquals("0.1.5", m.displayVersion());
assertTrue(m.isValid());
}
@Test
public void effectiveVersionCode_legacyVersionCode() {
OtaManifest m = new OtaManifest(-1, -1, -1, 42, "legacy",
"https://example.com/x.apk", "", "abc", 0L, false, "");
assertEquals(42, m.effectiveVersionCode());
assertTrue(m.isValid());
}
@Test
public void isValid_requiresApkUrlAndVersion() {
assertFalse(new OtaManifest(0, 0, 0, 0, "", "", "", "", 0L, false, "").isValid());
assertTrue(new OtaManifest(0, 0, 1, 0, "", "https://x/a.apk", "", "", 0L, false, "")
.isValid());
}
}

View File

@@ -0,0 +1,26 @@
package com.foxx.androidcast.ota;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class OtaVersionTest {
@Test
public void pack_unpack_roundTrip() {
assertEquals(105, OtaVersion.pack(0, 1, 5));
OtaVersion v = OtaVersion.fromPacked(105);
assertEquals(0, v.major);
assertEquals(1, v.minor);
assertEquals(5, v.build);
assertEquals("0.1.5", v.displayName());
}
@Test
public void isUpdateAvailable_comparesPacked() {
assertTrue(OtaVersion.isUpdateAvailable(105, 100));
assertFalse(OtaVersion.isUpdateAvailable(100, 105));
assertFalse(OtaVersion.isUpdateAvailable(100, 100));
}
}