1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 07:39:15 +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

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.github
**/.cxx
**/.gradle
**/build
build/native
.log_capture
*.apk
*.iml
.idea
local.properties

View File

@@ -26,6 +26,27 @@ def buildTimeEpochMs = System.currentTimeMillis()
def buildTimeZoneId = java.util.TimeZone.getDefault().getID() def buildTimeZoneId = java.util.TimeZone.getDefault().getID()
def buildTimeDisplay = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", java.util.Locale.US) def buildTimeDisplay = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", java.util.Locale.US)
.format(new java.util.Date(buildTimeEpochMs)) .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 { try {
gitCommitShort = ['git', 'rev-parse', '--short', 'HEAD'].execute(null, rootProject.projectDir).text.trim() gitCommitShort = ['git', 'rev-parse', '--short', 'HEAD'].execute(null, rootProject.projectDir).text.trim()
gitCommitAuthor = ['git', 'log', '-1', '--format=%an'].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" applicationId "com.foxx.androidcast"
minSdk 29 minSdk 29
targetSdk 34 targetSdk 34
versionCode 1 versionCode otaVersionCode
versionName "0.1.0-poc" 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_SHORT', "\"${gitCommitShort}\""
buildConfigField 'String', 'GIT_COMMIT_AUTHOR', "\"${gitCommitAuthor.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" buildConfigField 'String', 'GIT_COMMIT_AUTHOR', "\"${gitCommitAuthor.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\""
buildConfigField 'long', 'BUILD_TIME_EPOCH_MS', "${buildTimeEpochMs}L" buildConfigField 'long', 'BUILD_TIME_EPOCH_MS', "${buildTimeEpochMs}L"
buildConfigField 'String', 'BUILD_TIME_ZONE_ID', "\"${buildTimeZoneId.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" buildConfigField 'String', 'BUILD_TIME_ZONE_ID', "\"${buildTimeZoneId.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\""
buildConfigField 'String', 'BUILD_TIME_DISPLAY', "\"${buildTimeDisplay.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 { externalNativeBuild {
cmake { cmake {
arguments "-DANDROID_STL=c++_shared" arguments "-DANDROID_STL=c++_shared"
@@ -95,6 +121,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.2.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'com.google.android.gms:play-services-cronet:18.1.0' 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' testImplementation 'junit:junit:4.13.2'
} }

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <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_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_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-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_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application <application
android:name=".AndroidCastApplication" android:name=".AndroidCastApplication"
@@ -89,6 +91,16 @@
android:theme="@style/Theme.AndroidCast.Fullscreen" android:theme="@style/Theme.AndroidCast.Fullscreen"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density" /> 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 <service
android:name=".sender.ScreenCastService" android:name=".sender.ScreenCastService"
android:exported="false" android:exported="false"

View File

@@ -3,6 +3,8 @@ package com.foxx.androidcast;
import android.app.Application; import android.app.Application;
import android.content.Context; import android.content.Context;
import com.foxx.androidcast.crash.CrashReporter;
import com.foxx.androidcast.crash.CrashWatcherService;
import com.foxx.androidcast.media.CodecPriorityCatalog; import com.foxx.androidcast.media.CodecPriorityCatalog;
/** Applies saved theme and locale at process start. */ /** Applies saved theme and locale at process start. */
@@ -19,5 +21,7 @@ public class AndroidCastApplication extends Application {
CastLocaleHelper.applyStoredLocale(this); CastLocaleHelper.applyStoredLocale(this);
CastThemeHelper.applyNightMode(this); CastThemeHelper.applyNightMode(this);
CastTrayNotifier.syncOnAppStart(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_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_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_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_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_USER_DEFINED = "receiver_pip_user_defined";
private static final String KEY_RECEIVER_PIP_ENABLED = "receiver_pip_enabled"; 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_SENDER = "sender_";
private static final String PREFIX_RECEIVER = "receiver_"; 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(); 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). */ /** Developer: show priority scores in codec settings spinners (default off). */
public static boolean isShowCodecPriorityScores(Context context) { public static boolean isShowCodecPriorityScores(Context context) {
return prefs(context).getBoolean(KEY_DEV_SHOW_CODEC_PRIORITY_SCORES, false); 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(); 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. * 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); usernameInput = view.findViewById(R.id.edit_username);
pinInput = view.findViewById(R.id.edit_settings_pin); pinInput = view.findViewById(R.id.edit_settings_pin);
trayIconCheck = view.findViewById(R.id.check_tray_icon); 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); themeSpinner = view.findViewById(R.id.spinner_theme);
languageSpinner = view.findViewById(R.id.spinner_language); languageSpinner = view.findViewById(R.id.spinner_language);
@@ -76,6 +77,11 @@ public class CastSettingsFragment extends Fragment {
AppPreferences.setShowTrayIcon(requireContext(), checked); AppPreferences.setShowTrayIcon(requireContext(), checked);
CastTrayNotifier.onPreferenceChanged(requireContext()); 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() { themeSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
private boolean first = true; private boolean first = true;

View File

@@ -4,12 +4,16 @@ import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.widget.Button; import android.widget.Button;
import android.widget.CheckBox; import android.widget.CheckBox;
import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.media.CodecPriorityCatalog; 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.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
import com.google.android.material.textfield.TextInputEditText;
/** Hidden developer settings (opened via version label easter egg). */ /** Hidden developer settings (opened via version label easter egg). */
public class DeveloperSettingsActivity extends AppCompatActivity { public class DeveloperSettingsActivity extends AppCompatActivity {
@@ -39,6 +43,22 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
String summary = CodecPriorityCatalog.reload(this); String summary = CodecPriorityCatalog.reload(this);
Toast.makeText(this, summary, Toast.LENGTH_LONG).show(); 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 @Override
@@ -48,6 +68,34 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
findViewById(R.id.check_receiver_debug_overlay), findViewById(R.id.check_receiver_debug_overlay),
findViewById(R.id.check_show_codec_priority_scores), findViewById(R.id.check_show_codec_priority_scores),
findViewById(R.id.check_grab_session_stats)); 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) { private void refreshFromPreferences(CheckBox debugOverlay, CheckBox codecScores, CheckBox sessionStats) {

View File

@@ -1,14 +1,41 @@
package com.foxx.androidcast; package com.foxx.androidcast;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.os.Bundle; 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.receiver.ReceiverPlaybackActivity;
import com.foxx.androidcast.sender.SenderActivity; import com.foxx.androidcast.sender.SenderActivity;
import com.foxx.androidcast.stats.SessionStatsStore; import com.foxx.androidcast.stats.SessionStatsStore;
public class MainActivity extends DrawerHostActivity { 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 @Override
protected int getContentLayoutId() { protected int getContentLayoutId() {
return R.layout.activity_main; 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 @Override
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
PermissionHelper.remindIfMissing(this, false); PermissionHelper.remindIfMissing(this, false);
OtaUpdateCoordinator.requestCheckOnLaunch(this);
} }
@Override @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.setResolution(pendingWidth, pendingHeight);
recorder.mergeLoss(buildReceiverLossSnapshot()); recorder.mergeLoss(buildReceiverLossSnapshot());
CastBackgroundExecutor.execute(() -> recorder.finish(getApplicationContext())); recorder.finish(getApplicationContext());
} }
private void attachSurface(Surface surface) { private void attachSurface(Surface surface) {

View File

@@ -951,8 +951,7 @@ public class ScreenCastService extends Service implements
? activeSettings.getCaptureMode().name() : ""); ? activeSettings.getCaptureMode().name() : "");
} }
recorder.mergeLoss(buildSenderLossSnapshot()); recorder.mergeLoss(buildSenderLossSnapshot());
com.foxx.androidcast.util.CastBackgroundExecutor.execute( recorder.finish(getApplicationContext());
() -> recorder.finish(getApplicationContext()));
} }
private void logEncodedCodecSample(String mime, byte[] frameData) { 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; package com.foxx.androidcast.stats;
import android.content.Context;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
/** Active session stats recorder for transport/pump hooks without threading references everywhere. */ /** Active session stats recorder for transport/pump hooks without threading references everywhere. */
@@ -54,4 +56,19 @@ public final class SessionStatsContext {
r.flushGeneralIfDue(); 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.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}. * Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}.
*/ */
@@ -43,6 +45,9 @@ public final class SessionStatsRecorder {
private final JSONArray samples = new JSONArray(); private final JSONArray samples = new JSONArray();
private long lastSampleMs; 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}. */ /** @param direction {@code send} or {@code recv}. */
public SessionStatsRecorder(String direction, String transport) { public SessionStatsRecorder(String direction, String transport) {
this.direction = "recv".equals(direction) ? "recv" : "send"; 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) { 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); SessionStatsContext.unbind(this);
if (!AppPreferences.isGrabSessionStats(context)) { if (!AppPreferences.isGrabSessionStats(context)) {
return; return;
} }
if (!persisted.compareAndSet(false, true)) {
return;
}
journal.flushGeneralFinal(); journal.flushGeneralFinal();
long endedMs = System.currentTimeMillis(); long endedMs = System.currentTimeMillis();
try { try {
JSONObject root = new JSONObject(); JSONObject root = buildSessionJson(endedMs, reason);
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);
}
SessionStatsStore.saveSession(context, startedMs, direction, root); SessionStatsStore.saveSession(context, startedMs, direction, root);
} catch (Exception ignored) { } 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:layout_marginTop="8dp"
android:text="@string/dev_reload_codecs_json_hint" android:text="@string/dev_reload_codecs_json_hint"
android:textSize="12sp" /> 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> </LinearLayout>
</ScrollView> </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:checked="true"
android:text="@string/label_show_tray_icon" /> 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 <include
android:id="@+id/header_sender" android:id="@+id/header_sender"
layout="@layout/include_settings_section_header" layout="@layout/include_settings_section_header"

View File

@@ -128,6 +128,7 @@
<string name="settings_section_receiver">Receiver settings</string> <string name="settings_section_receiver">Receiver settings</string>
<string name="label_username">Username</string> <string name="label_username">Username</string>
<string name="label_show_tray_icon">Always show icon in system bar</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="pin_anonymous_hint">Leave empty for anonymous cast</string>
<string name="label_receiver_display">Resolution</string> <string name="label_receiver_display">Resolution</string>
<string name="receiver_display_auto_fit">AUTO (fits screen)</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="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">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="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="codec_priority_score_suffix"> · %1$d</string>
<string name="label_audio_codec">Voice codec</string> <string name="label_audio_codec">Voice codec</string>
<string name="codec_libvpx_vp8">libvpx (VP8)</string> <string name="codec_libvpx_vp8">libvpx (VP8)</string>

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<files-path name="session_stats" path="session_stats/" /> <files-path name="session_stats" path="session_stats/" />
<cache-path name="ota_cache" path="ota/" />
</paths> </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));
}
}

26
docs/CRASH_REPORTER.md Normal file
View File

@@ -0,0 +1,26 @@
# Anonymous crash reporter
## Android
- **Main process:** `CrashReporter.install()` — Java uncaught exceptions → JSON under `files/crash_reports/pending/`, session stats flushed with `globals.reason: "crash"`.
- **Watcher process:** `CrashWatcherService` in `:crashwatcher` — reads `settings.json``crash`, uploads pending reports, ingests native stub files.
- **Native:** `crash_hook.c` (unwind + `dladdr`) writes stubs to `crash_reports/native/`; watcher converts to schema v1 JSON.
- **User toggle:** Settings drawer → **Send anonymous crash logs** (`AppPreferences.send_anonymous_crash_logs`, default on).
Deploy tuning via app-private `files/settings.json` (see `examples/settings.json`):
```json
"crash": {
"enabled": "true",
"upload_url": "https://f0xx.org/app/androidcast_project/crashes/api/upload.php",
"prune_after_upload": "true",
"upload_interval": "5m",
"max_pending": 32
}
```
## Backend
PHP console and ingest API: `examples/crash_reporter/backend/` — see [README](../examples/crash_reporter/backend/README.md).
Roadmap: `examples/crash_reporter/ROADMAP.md`.

118
docs/OTA.md Normal file
View File

@@ -0,0 +1,118 @@
# Over-the-air (OTA) updates — deployment schema **v0**
The app checks a **stable channel URL**, follows it to a per-build manifest, then downloads the APK. Version comparison uses **`major.minor.build`** (packed into Android `versionCode` at build time). Deploy never computes `versionCode` by hand.
The client runs checks and downloads in **`OtaUpdateService`** (a standalone `Service`, not embedded in cast/receiver services). Launch checks enqueue work there; APK download uses a **foreground service** (`dataSync`) with a progress notification.
MQTT is fully supported for OTA sources using retained topic payloads (`mqtt://...` / `mqtts://...`). The URI path is treated as the exact topic to subscribe.
## URL layout
```text
https://host/v0/ota/channel/stable.json
https://host/v0/ota/00/00.{major}/00.{major}.{minor}/android_cast_00.{major}.{minor}.{build}.otapkg
https://host/v0/ota/00/00.{major}/00.{major}.{minor}/android_cast_00.{major}.{minor}.{build}_sign.json
https://host/v0/ota/00/00.{major}/00.{major}.{minor}/android_cast_00.{major}.{minor}.{build}_manifest.json
```
- **`v0`** — deployment schema version (default channel).
- **`00`** after `ota/` — OTA payload format revision within v0.
- Components are **zero-padded to 2 digits** in paths and filenames (`00.01.05`).
## Versioning
In `app/build.gradle` (overridable via `local.properties`):
```properties
ota.major=0
ota.minor=1
ota.build=0
```
Packed rule (each part 099):
```text
versionCode = major * 10000 + minor * 100 + build
```
Example: `0.1.5``versionCode` **105**.
## JSON files
**`v0/ota/channel/stable.json`**
```json
{
"schema": "v0",
"manifestUrl": "https://host/v0/ota/00/00.01/00.01.05/android_cast_00.01.05_manifest.json"
}
```
**`*_manifest.json`**
```json
{
"schema": "v0",
"major": 0,
"minor": 1,
"build": 5,
"versionName": "0.1.5",
"apkUrl": "https://host/v0/ota/00/00.01/00.01.05/android_cast_00.01.05.otapkg",
"signUrl": "https://host/v0/ota/00/00.01/00.01.05/android_cast_00.01.05_sign.json",
"mandatory": false,
"releaseNotes": ""
}
```
**`*_sign.json`**
```json
{
"schema": "v0",
"sha256": "…"
}
```
The app loads `sha256` from `sign.json` when the manifest omits it.
## MQTT transport
Use the same JSON payload shapes as HTTP, but publish them as **retained** messages on topics that match your URI paths:
- `mqtt://host:1883/v0/ota/channel/stable.json` -> retained payload is `stable.json` content
- `mqtt://host:1883/v0/ota/00/..._manifest.json` -> retained payload is manifest JSON
- `mqtt://host:1883/v0/ota/00/..._sign.json` -> retained payload is sign JSON
- `mqtt://host:1883/v0/ota/00/...otapkg` -> retained payload is raw APK/otapkg bytes
When `ota.trusted=true` and build is debug, strict checksum/sign enforcement is relaxed for that source. Release builds always remain strict.
## Publish from a release APK
```bash
chmod +x scripts/generate-ota-v0.sh
./scripts/generate-ota-v0.sh app/build/outputs/apk/release/app-release.apk https://your-host:port ./ota-publish
```
Upload the contents of `ota-publish/` to your web root so `https://your-host/v0/ota/...` resolves.
## App configuration
**`local.properties`** (not committed):
```properties
ota.channel.url=https://your-host/v0/ota/channel/stable.json
ota.major=0
ota.minor=1
ota.build=0
```
Or set the channel URL under **Developer settings → App updates (OTA)**.
Legacy `ota.manifest.url` still works as a direct manifest URL fallback.
## Checklist
1. Bump `ota.major` / `ota.minor` / `ota.build` (or Gradle defaults).
2. Build signed release APK (`versionCode` is derived automatically).
3. Run `generate-ota-v0.sh` and upload `v0/ota/**`.
4. Confirm `stable.json` points at the new `*_manifest.json`.

View File

@@ -0,0 +1,19 @@
# Crash reporter roadmap
## Done (v0)
- Android `:crashwatcher` process uploads pending JSON
- Anonymous schema v1 (device, app, java/native stacks, fingerprint)
- PHP receiver + SQLite default + MariaDB option
- Admin console (login, reports list, grouping by fingerprint)
- `settings.json` crash section overrides
## Next
- [ ] User registration + email verification
- [ ] RBAC editor UI (delegate roles per module)
- [ ] Symbolication (ndk-stack / retrace) for native/Java stacks
- [ ] MQTT ingest endpoint (parallel to HTTP POST)
- [ ] Rate limiting + API keys per app build
- [ ] Export reports (CSV/JSON bundle)
- [ ] Alerting (webhook when new fingerprint appears)

View File

@@ -0,0 +1,83 @@
# Android Cast — crash reporter backend (PHP)
Anonymous crash JSON receiver and web console for
`https://f0xx.org/app/androidcast_project/crashes/`
## Requirements
- PHP 8.1+ with **PDO** (`pdo_sqlite` for default, `pdo_mysql` for MariaDB)
- `password_hash` / `session` (standard PHP build)
## Quick start (SQLite, development)
```bash
cd examples/crash_reporter/backend
cp config/config.example.php config/config.php
# For `php -S`, set 'base_path' => '' in config.php
mkdir -p data storage
sqlite3 data/crashes.sqlite < sql/schema.sqlite.sql
php -S 127.0.0.1:8080 -t public
```
Open http://127.0.0.1:8080/ — login **admin** / **admin**.
Upload test payload:
```bash
curl -X POST http://127.0.0.1:8080/api/upload.php \
-H 'Content-Type: application/json' \
-d @../sample_crash_java.json
```
## Production (nginx + PHP-FPM)
1. Copy `backend/` to `/var/www/androidcast_crashes/`
2. Point nginx `root` to `.../public` (see `nginx.conf`)
3. Choose DB:
- **SQLite (default):** run `sql/schema.sqlite.sql`, set `DB_DRIVER=sqlite` in config
- **MariaDB:** create DB, run `sql/schema.mariadb.sql`, set `DB_DRIVER=mysql`
4. `chown -R www-data:www-data data storage`
5. TLS terminate at nginx; restrict `/api/upload.php` by firewall if needed
## Android app configuration
In app-private `files/settings.json` (see `examples/settings.json`):
```json
"crash": {
"enabled": "true",
"upload_url": "https://f0xx.org/app/androidcast_project/crashes/api/upload.php",
"prune_after_upload": "true",
"upload_interval": "5m"
}
```
Enable **Send anonymous crash logs** in the app settings drawer.
## Report JSON schema (v1)
See `sample_crash_java.json`. Key fields:
| Field | Purpose |
|-------|---------|
| `schema_version` | Always `1` for now |
| `report_id` | UUID from device |
| `generated_at_epoch_ms` | Device timestamp |
| `crash_type` | `java` or `native` |
| `fingerprint` | Grouping hash (top frames) |
| `device` | Manufacturer, model, SDK, ABIs |
| `app` | Package, version |
| `java` | Exception + `stack_frames[]` |
| `native` | Signal + `backtrace[]` |
Backend stores full JSON in `reports.payload_json` for faithful replay in the UI.
## Default accounts
| User | Password | Role |
|------|----------|------|
| admin | admin | root (all permissions) |
Change the admin password in production (update `users.password_hash`).

View File

@@ -0,0 +1,19 @@
<?php
return [
'app_name' => 'Android Cast Crashes',
// Production: '/app/androidcast_project/crashes' — Local php -S: use '' (empty string)
'base_path' => '/app/androidcast_project/crashes',
'db' => [
'driver' => 'sqlite', // sqlite | mysql
'sqlite_path' => __DIR__ . '/../data/crashes.sqlite',
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'androidcast_crashes',
'username' => 'androidcast',
'password' => 'change-me',
'charset' => 'utf8mb4',
],
],
'session_name' => 'ac_crash_sess',
];

View File

@@ -0,0 +1,18 @@
<?php
return [
'app_name' => 'Android Cast Crashes',
'base_path' => '/app/androidcast_project/crashes',
'db' => [
'driver' => 'sqlite', // sqlite | mysql
'sqlite_path' => __DIR__ . '/../data/crashes.sqlite',
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'androidcast_crashes',
'username' => 'androidcast',
'password' => 'change-me',
'charset' => 'utf8mb4',
],
],
'session_name' => 'ac_crash_sess',
];

Binary file not shown.

View File

@@ -0,0 +1,40 @@
# Example nginx site for https://f0xx.org/app/androidcast_project/crashes/
# Adjust paths and upstream socket to your host.
server {
listen 443 ssl http2;
server_name f0xx.org;
# ssl_certificate /etc/letsencrypt/live/f0xx.org/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/f0xx.org/privkey.pem;
root /var/www/androidcast_crashes/public;
index index.php;
location /app/androidcast_project/crashes/ {
alias /var/www/androidcast_crashes/public/;
try_files $uri $uri/ /app/androidcast_project/crashes/index.php?$query_string;
}
location ~ ^/app/androidcast_project/crashes/api/upload\.php$ {
alias /var/www/androidcast_crashes/public/api/upload.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/androidcast_crashes/public/api/upload.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
client_max_body_size 4m;
}
location ~ ^/app/androidcast_project/crashes/.+\.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
location ~ ^/app/androidcast_project/crashes/ {
try_files $uri /app/androidcast_project/crashes/index.php?$query_string;
}
location ~ /\. {
deny all;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
json_out(['ok' => false, 'error' => 'empty body'], 400);
}
$data = json_decode($raw, true);
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid json'], 400);
}
if ((int) ($data['schema_version'] ?? 0) !== 1) {
json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
}
try {
ReportRepository::insert($data);
json_out(['ok' => true, 'report_id' => $data['report_id'] ?? null]);
} catch (Throwable $e) {
json_out(['ok' => false, 'error' => 'store failed'], 500);
}

View File

@@ -0,0 +1,122 @@
:root {
--bg: #0f1419;
--surface: #1a2332;
--border: #2d3a4f;
--text: #e7ecf3;
--muted: #8b9cb3;
--accent: #3d8bfd;
--accent2: #5eead4;
--danger: #f87171;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.shell { display: flex; min-height: calc(100vh - 40px); }
.shell.single .main-pane { max-width: 1100px; margin: 0 auto; padding: 24px; }
.nav-pane {
width: 56px;
background: var(--surface);
border-right: 1px solid var(--border);
transition: width .2s ease;
overflow: hidden;
position: relative;
}
.nav-pane.open, .nav-pane:hover { width: 220px; }
.nav-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: ew-resize;
background: var(--border);
}
.nav-pane ul { list-style: none; margin: 48px 0 0; padding: 0 12px; }
.nav-pane li { margin: 8px 0; }
.nav-pane a {
display: block;
padding: 10px 12px;
border-radius: 8px;
color: var(--text);
white-space: nowrap;
}
.nav-pane a.active, .nav-pane a:hover {
background: rgba(61, 139, 253, .2);
text-decoration: none;
}
.nav-user {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
font-size: 12px;
color: var(--muted);
}
.main-pane { flex: 1; padding: 24px 28px; }
.bottom-bar {
height: 40px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
font-size: 12px;
color: var(--muted);
}
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
width: min(400px, 92vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.login-card input {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
.login-card button {
padding: 12px;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
.btn {
display: inline-block;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--border);
margin-left: 8px;
}
.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
.data-table th, .data-table td { border-bottom: 1px solid var(--border); padding: 10px 8px; text-align: left; }
.badge { background: var(--accent2); color: #042; padding: 2px 8px; border-radius: 999px; font-weight: 700; }
.stack { background: #0a0e14; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; }
.exc { color: var(--danger); font-weight: 600; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
.muted { color: var(--muted); }
.alert { background: rgba(248,113,113,.15); color: var(--danger); padding: 10px; border-radius: 8px; }

View File

@@ -0,0 +1,6 @@
document.addEventListener('DOMContentLoaded', () => {
const nav = document.getElementById('nav-pane');
const handle = document.getElementById('nav-handle');
if (!nav || !handle) return;
handle.addEventListener('click', () => nav.classList.toggle('open'));
});

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/bootstrap.php';
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$base = Auth::basePath();
if ($base !== '' && str_starts_with($uri, $base)) {
$uri = substr($uri, strlen($base)) ?: '/';
}
$route = rtrim($uri, '/') ?: '/';
if ($route === '/api/upload.php' || str_ends_with($route, '/api/upload.php')) {
require __DIR__ . '/api/upload.php';
exit;
}
if ($route === '/logout') {
Auth::logout();
header('Location: ' . $base . '/login');
exit;
}
if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$user = trim($_POST['username'] ?? '');
$pass = $_POST['password'] ?? '';
if (Auth::login($user, $pass)) {
header('Location: ' . $base . '/');
exit;
}
$loginError = 'Invalid credentials';
require __DIR__ . '/../views/login.php';
exit;
}
if ($route === '/login') {
require __DIR__ . '/../views/login.php';
exit;
}
Auth::check();
$grouped = isset($_GET['group']) && $_GET['group'] === '1';
$view = $_GET['view'] ?? 'home';
if ($view === 'report' && isset($_GET['id'])) {
$report = ReportRepository::getById((int) $_GET['id']);
if (!$report) {
http_response_code(404);
echo 'Not found';
exit;
}
$pageTitle = 'Report';
require __DIR__ . '/../views/layout.php';
exit;
}
$pageTitle = $view === 'home' ? 'Home' : 'Crash reports';
$reports = $view === 'reports' ? ReportRepository::listReports($grouped) : [];
require __DIR__ . '/../views/layout.php';

View File

@@ -0,0 +1,30 @@
CREATE DATABASE IF NOT EXISTS androidcast_crashes
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE androidcast_crashes;
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('root','admin','viewer') NOT NULL DEFAULT 'viewer',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
report_id CHAR(36) NOT NULL UNIQUE,
fingerprint CHAR(64) NOT NULL,
crash_type ENUM('java','native') NOT NULL,
generated_at_ms BIGINT NOT NULL,
received_at_ms BIGINT NOT NULL,
device_model VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
payload_json JSON NOT NULL,
KEY idx_reports_generated (generated_at_ms DESC),
KEY idx_reports_received (received_at_ms DESC),
KEY idx_reports_fingerprint (fingerprint)
) ENGINE=InnoDB;
INSERT IGNORE INTO users (id, username, password_hash, role)
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');

View File

@@ -0,0 +1,29 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id TEXT NOT NULL UNIQUE,
fingerprint TEXT NOT NULL,
crash_type TEXT NOT NULL,
generated_at_ms INTEGER NOT NULL,
received_at_ms INTEGER NOT NULL,
device_model TEXT,
app_version TEXT,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reports_generated ON reports(generated_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_reports_fingerprint ON reports(fingerprint);
INSERT OR IGNORE INTO users (id, username, password_hash, role)
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');
-- password: admin

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
final class Auth {
public static function user(): ?array {
return $_SESSION['user'] ?? null;
}
public static function check(): void {
if (!self::user()) {
header('Location: ' . self::basePath() . '/login');
exit;
}
}
public static function can(string $action): bool {
$u = self::user();
if (!$u) {
return false;
}
$role = $u['role'] ?? 'viewer';
if ($role === 'root' || $role === 'admin') {
return true;
}
return $action === 'view';
}
public static function login(string $username, string $password): bool {
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1');
$stmt->execute([$username]);
$row = $stmt->fetch();
if (!$row || !password_verify($password, $row['password_hash'])) {
return false;
}
$_SESSION['user'] = [
'id' => (int) $row['id'],
'username' => $row['username'],
'role' => $row['role'],
];
return true;
}
public static function logout(): void {
unset($_SESSION['user']);
}
public static function basePath(): string {
$bp = cfg('base_path', '');
return rtrim($bp, '/');
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
final class Database {
private static ?PDO $pdo = null;
public static function pdo(): PDO {
if (self::$pdo !== null) {
return self::$pdo;
}
$driver = cfg('db.driver', 'sqlite');
if ($driver === 'mysql') {
$m = cfg('db.mysql', []);
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$m['host'] ?? '127.0.0.1',
(int) ($m['port'] ?? 3306),
$m['database'] ?? 'androidcast_crashes',
$m['charset'] ?? 'utf8mb4'
);
self::$pdo = new PDO($dsn, $m['username'] ?? '', $m['password'] ?? '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
return self::$pdo;
}
$path = cfg('db.sqlite_path');
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
self::$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
self::$pdo->exec('PRAGMA foreign_keys = ON');
return self::$pdo;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
final class ReportRepository {
public static function insert(array $payload): void {
$pdo = Database::pdo();
$device = $payload['device'] ?? [];
$app = $payload['app'] ?? [];
$stmt = $pdo->prepare(
'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, device_model, app_version, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$payload['report_id'] ?? uniqid('rpt_', true),
$payload['fingerprint'] ?? 'unknown',
$payload['crash_type'] ?? 'java',
(int) ($payload['generated_at_epoch_ms'] ?? 0),
(int) round(microtime(true) * 1000),
$device['model'] ?? null,
$app['version_name'] ?? null,
json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
]);
}
public static function listReports(bool $grouped): array {
$pdo = Database::pdo();
if ($grouped) {
$sql = 'SELECT fingerprint, crash_type,
MIN(device_model) AS device_model,
MAX(app_version) AS app_version,
COUNT(*) AS cnt,
MAX(generated_at_ms) AS last_generated_ms,
MAX(received_at_ms) AS last_received_ms
FROM reports
GROUP BY fingerprint, crash_type
ORDER BY cnt DESC, last_generated_ms DESC';
return $pdo->query($sql)->fetchAll();
}
$sql = 'SELECT id, report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, device_model, app_version
FROM reports
ORDER BY generated_at_ms DESC, received_at_ms DESC
LIMIT 500';
return $pdo->query($sql)->fetchAll();
}
public static function getById(int $id): ?array {
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT * FROM reports WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
$row['payload'] = json_decode($row['payload_json'], true);
return $row;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
$configPath = __DIR__ . '/../config/config.php';
if (!is_file($configPath)) {
$configPath = __DIR__ . '/../config/config.example.php';
}
$config = require $configPath;
session_name($config['session_name'] ?? 'ac_crash_sess');
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/ReportRepository.php';
function cfg(string $key, $default = null) {
global $config;
$parts = explode('.', $key);
$v = $config;
foreach ($parts as $p) {
if (!is_array($v) || !array_key_exists($p, $v)) {
return $default;
}
$v = $v[$p];
}
return $v;
}
function h(?string $s): string {
return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function json_out(array $data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($pageTitle ?? 'Console') ?> — <?= h(cfg('app_name')) ?></title>
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
<script src="https://code.jquery.com/jquery-3.7.1.min.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.js" defer></script>
</head>
<body>
<header class="top-menu" hidden aria-hidden="true"></header>
<div class="shell">
<nav class="nav-pane" id="nav-pane">
<div class="nav-handle" id="nav-handle" title="Navigation"></div>
<ul>
<li><a href="<?= h(Auth::basePath()) ?>/?view=home" class="<?= ($view ?? '') === 'home' ? 'active' : '' ?>">Home</a></li>
<li><a href="<?= h(Auth::basePath()) ?>/?view=reports" class="<?= ($view ?? '') === 'reports' ? 'active' : '' ?>">Reports</a></li>
</ul>
<div class="nav-user">
<span><?= h(Auth::user()['username'] ?? '') ?></span>
<a href="<?= h(Auth::basePath()) ?>/logout">Logout</a>
</div>
</nav>
<main class="main-pane">
<?php if (($view ?? 'home') === 'home'): ?>
<h1>Crash console</h1>
<p>Anonymous Android Cast crash ingest is active. Use <strong>Reports</strong> to analyze grouped fingerprints.</p>
<ul>
<li>Upload API: <code><?= h(Auth::basePath()) ?>/api/upload.php</code></li>
<li>Schema: <code>schema_version: 1</code></li>
</ul>
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
<?php require __DIR__ . '/report_detail.php'; ?>
<?php elseif (($view ?? '') === 'reports'): ?>
<div class="toolbar">
<h1>Crash reports</h1>
<div class="toolbar-actions">
<a class="btn <?= empty($grouped) ? 'active' : '' ?>" href="<?= h(Auth::basePath()) ?>/?view=reports">By time</a>
<a class="btn <?= !empty($grouped) ? 'active' : '' ?>" href="<?= h(Auth::basePath()) ?>/?view=reports&amp;group=1">Grouped</a>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<?php if (!empty($grouped)): ?>
<th>Count</th><th>Fingerprint</th><th>Type</th><th>Last generated</th><th>Last received</th>
<?php else: ?>
<th>Generated</th><th>Received</th><th>Type</th><th>Device</th><th>App</th><th></th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($reports as $r): ?>
<tr>
<?php if (!empty($grouped)): ?>
<td><span class="badge"><?= (int) $r['cnt'] ?></span></td>
<td><code class="fp"><?= h(substr($r['fingerprint'], 0, 12)) ?>…</code></td>
<td><?= h($r['crash_type']) ?></td>
<td><?= h(date('Y-m-d H:i:s', (int)($r['last_generated_ms']/1000))) ?></td>
<td><?= h(date('Y-m-d H:i:s', (int)($r['last_received_ms']/1000))) ?></td>
<?php else: ?>
<td><?= h(date('Y-m-d H:i:s', (int)($r['generated_at_ms']/1000))) ?></td>
<td><?= h(date('Y-m-d H:i:s', (int)($r['received_at_ms']/1000))) ?></td>
<td><?= h($r['crash_type']) ?></td>
<td><?= h($r['device_model'] ?? '') ?></td>
<td><?= h($r['app_version'] ?? '') ?></td>
<td><a href="<?= h(Auth::basePath()) ?>/?view=report&amp;id=<?= (int)$r['id'] ?>">Open</a></td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php if (empty($reports)): ?>
<tr><td colspan="6" class="muted">No reports yet.</td></tr>
<?php endif; ?>
</tbody>
</table>
<?php endif; ?>
</main>
</div>
<footer class="bottom-bar">Android Cast crash reporter — placeholder footer</footer>
</body>
</html>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login <?= h(cfg('app_name')) ?></title>
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
</head>
<body class="login-page">
<form class="login-card" method="post" action="<?= h(Auth::basePath()) ?>/login">
<h1><?= h(cfg('app_name')) ?></h1>
<p class="muted">Sign in to view anonymous crash reports</p>
<?php if (!empty($loginError)): ?>
<div class="alert"><?= h($loginError) ?></div>
<?php endif; ?>
<label>Username<input name="username" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
<p class="hint">Default: admin / admin</p>
<p class="hint"><a href="#">Register</a> (coming soon)</p>
</form>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<?php
$p = $report['payload'] ?? [];
$device = $p['device'] ?? [];
$app = $p['app'] ?? [];
$pageTitle = 'Report ' . ($report['report_id'] ?? '');
$view = 'reports';
?>
<h1>Crash report</h1>
<p class="muted">Report <?= h($report['report_id'] ?? '') ?> · <?= h($p['crash_type'] ?? '') ?></p>
<div class="cards">
<div class="card"><h3>Device</h3>
<p><?= h(trim(($device['manufacturer'] ?? '') . ' ' . ($device['model'] ?? ''))) ?></p>
<p>Android <?= h($device['release'] ?? '') ?> (SDK <?= (int)($device['sdk_int'] ?? 0) ?>)</p>
<?php if (!empty($device['abis'])): ?>
<p class="muted">ABIs: <?= h(implode(', ', (array)$device['abis'])) ?></p>
<?php endif; ?>
</div>
<div class="card"><h3>App</h3>
<p><?= h($app['package'] ?? '') ?></p>
<p>v<?= h($app['version_name'] ?? '') ?> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
<?php if (!empty($p['build'])): $b = $p['build']; ?>
<p class="muted">Build: <?= h($b['git_commit'] ?? $b['git'] ?? '') ?></p>
<?php endif; ?>
</div>
<div class="card"><h3>Timing</h3>
<p>Generated: <?= h(date('c', (int)(($p['generated_at_epoch_ms'] ?? 0)/1000))) ?></p>
<p>Received: <?= h(date('c', (int)(($report['received_at_ms'] ?? 0)/1000))) ?></p>
<p class="muted">Fingerprint: <code><?= h($p['fingerprint'] ?? $report['fingerprint'] ?? '') ?></code></p>
</div>
</div>
<?php if (!empty($p['java'])): $j = $p['java']; ?>
<section class="stack-block">
<h2>Java exception</h2>
<p class="exc"><?= h($j['exception'] ?? '') ?><?= !empty($j['message']) ? ': ' . h($j['message']) : '' ?></p>
<p class="muted">Thread: <?= h($j['thread'] ?? '') ?></p>
<pre class="stack"><?php foreach ($j['stack_frames'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
</section>
<?php endif; ?>
<?php if (!empty($p['native'])): $n = $p['native']; ?>
<section class="stack-block">
<h2>Native crash</h2>
<p class="exc">Signal: <?= h($n['signal'] ?? '') ?></p>
<pre class="stack"><?php foreach ($n['backtrace'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
</section>
<?php endif; ?>
<?php if (!empty($p['session_context'])): ?>
<details><summary>Session context (crash)</summary><pre class="stack"><?= h(json_encode($p['session_context'], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) ?></pre></details>
<?php endif; ?>
<details><summary>Raw JSON</summary><pre class="raw-json stack"><?= h(json_encode($p, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) ?></pre></details>
<p><a href="<?= h(Auth::basePath()) ?>/?view=reports">← Back to list</a></p>

View File

@@ -0,0 +1,36 @@
{
"schema_version": 1,
"report_id": "00000000-0000-4000-8000-000000000001",
"generated_at_epoch_ms": 1710000000000,
"crash_type": "java",
"process": "main",
"fingerprint": "abc123",
"device": {
"manufacturer": "Doogee",
"brand": "DOOGEE",
"model": "Tab G6 Max",
"device": "TabG6Max",
"product": "TabG6Max",
"sdk_int": 34,
"release": "14",
"abis": ["arm64-v8a", "armeabi-v7a"]
},
"app": {
"package": "com.foxx.androidcast",
"version_name": "0.1.0",
"version_code": 100
},
"build": {
"debug": true,
"git_commit": "deadbeef"
},
"java": {
"thread": "main",
"exception": "java.lang.NullPointerException",
"message": "sample",
"stack_frames": [
"at com.foxx.androidcast.sender.ScreenCastService.onCreate(ScreenCastService.java:120)",
"at android.app.ActivityThread.handleCreateService(ActivityThread.java:1234)"
]
}
}

View File

@@ -0,0 +1,17 @@
# MQTT OTA topics (retained payloads)
Publish retained payloads for these topics:
- `v0/ota/channel/stable.json` -> content of `examples/ota/v0/ota/channel/stable.json`
- `v0/ota/channel/current.json` -> content of `examples/ota/v0/ota/channel/current.json`
- `v0/ota/channel/next.json` -> content of `examples/ota/v0/ota/channel/next.json`
- `v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_manifest.json`
- `v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_sign.json`
- `v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00.otapkg`
- `v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json`
- `v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_sign.json`
- `v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01.otapkg`
Example source URL in app settings:
- `mqtt://foxx.org:1883/v0/ota/channel/stable.json`

View File

@@ -0,0 +1,2 @@
PLACEHOLDER PACKAGE
Replace this with the real release artifact bytes.

View File

@@ -0,0 +1,12 @@
{
"schema": "v0",
"major": 0,
"minor": 1,
"build": 0,
"versionName": "0.1.0",
"apkUrl": "https://foxx.org/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00.otapkg",
"signUrl": "https://foxx.org/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_sign.json",
"sizeBytes": 10485760,
"mandatory": false,
"releaseNotes": "Current production baseline."
}

View File

@@ -0,0 +1,4 @@
{
"schema": "v0",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}

View File

@@ -0,0 +1,2 @@
PLACEHOLDER PACKAGE
Replace this with the next OTA candidate artifact bytes.

View File

@@ -0,0 +1,12 @@
{
"schema": "v0",
"major": 0,
"minor": 1,
"build": 1,
"versionName": "0.1.1",
"apkUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01.otapkg",
"signUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_sign.json",
"sizeBytes": 11534336,
"mandatory": false,
"releaseNotes": "Next OTA candidate with service-based updater."
}

View File

@@ -0,0 +1,4 @@
{
"schema": "v0",
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}

View File

@@ -0,0 +1,4 @@
{
"schema": "v0",
"manifestUrl": "https://foxx.org/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_manifest.json"
}

View File

@@ -0,0 +1,4 @@
{
"schema": "v0",
"manifestUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json"
}

View File

@@ -0,0 +1,4 @@
{
"schema": "v0",
"manifestUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json"
}

19
examples/settings.json Normal file
View File

@@ -0,0 +1,19 @@
{
"ota": {
"base_url": "https://foxx.org/v0/ota/channel/stable.json",
"base_urls": [
"https://foxx.org/v0/ota/channel/stable.json",
"https://mirror.foxx.org/v0/ota/channel/stable.json",
"mqtt://foxx.org:8443/ota/v0/channel/stable.json"
],
"trusted": "true",
"check_interval": "1m"
},
"crash": {
"enabled": "true",
"upload_url": "https://f0xx.org/app/androidcast_project/crashes/api/upload.php",
"prune_after_upload": "true",
"upload_interval": "5m",
"max_pending": 32
}
}

View File

@@ -12,6 +12,7 @@ add_library(androidcast_codecs SHARED
${NDK_BRIDGE}/jni/libvpx_bridge.c ${NDK_BRIDGE}/jni/libvpx_bridge.c
${NDK_BRIDGE}/jni/opus_bridge.c ${NDK_BRIDGE}/jni/opus_bridge.c
${NDK_BRIDGE}/jni/speex_bridge.c ${NDK_BRIDGE}/jni/speex_bridge.c
${NDK_BRIDGE}/jni/crash_hook.c
) )
target_include_directories(androidcast_codecs PRIVATE target_include_directories(androidcast_codecs PRIVATE
@@ -47,4 +48,5 @@ endif()
find_library(log-lib log) find_library(log-lib log)
find_library(android-lib android) find_library(android-lib android)
target_link_libraries(androidcast_codecs ${log-lib} ${android-lib}) find_library(dl-lib dl)
target_link_libraries(androidcast_codecs ${log-lib} ${android-lib} ${dl-lib})

122
ndk/jni/crash_hook.c Normal file
View File

@@ -0,0 +1,122 @@
#include <jni.h>
#include <android/log.h>
#include <dlfcn.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <unwind.h>
#define LOG_TAG "androidcast_crash"
#define REPORT_DIR_MAX 512
#define MAX_FRAMES 64
static char g_report_dir[REPORT_DIR_MAX];
static volatile sig_atomic_t g_handling;
typedef struct {
void **frames;
int max;
int count;
} backtrace_state_t;
static _Unwind_Reason_Code unwind_callback(struct _Unwind_Context *context, void *arg) {
backtrace_state_t *state = (backtrace_state_t *) arg;
uintptr_t pc = _Unwind_GetIP(context);
if (pc == 0) {
return _URC_NO_REASON;
}
if (state->count < state->max) {
state->frames[state->count++] = (void *) pc;
}
return _URC_NO_REASON;
}
static int capture_backtrace(void **frames, int max) {
backtrace_state_t state = {frames, max, 0};
_Unwind_Backtrace(unwind_callback, &state);
return state.count;
}
static void write_native_report(const char *signal_name) {
if (g_report_dir[0] == '\0') {
return;
}
char path[REPORT_DIR_MAX + 64];
snprintf(path, sizeof(path), "%s/native_%d_%ld.txt",
g_report_dir, (int) getpid(), (long) time(NULL));
FILE *f = fopen(path, "w");
if (!f) {
return;
}
fprintf(f, "signal=%s\n", signal_name);
fprintf(f, "pid=%d\n", (int) getpid());
void *frames[MAX_FRAMES];
int n = capture_backtrace(frames, MAX_FRAMES);
for (int i = 0; i < n; i++) {
Dl_info info;
memset(&info, 0, sizeof(info));
if (dladdr(frames[i], &info) && info.dli_fname) {
uintptr_t offset = (uintptr_t) frames[i] - (uintptr_t) info.dli_fbase;
fprintf(f, "#%02d %p %s", i, frames[i], info.dli_fname);
if (info.dli_sname) {
fprintf(f, " %s+0x%lx", info.dli_sname,
(unsigned long) ((uintptr_t) frames[i] - (uintptr_t) info.dli_saddr));
} else {
fprintf(f, "+0x%lx", (unsigned long) offset);
}
fprintf(f, "\n");
} else {
fprintf(f, "#%02d %p\n", i, frames[i]);
}
}
fclose(f);
}
static void crash_signal_handler(int sig) {
if (g_handling) {
_exit(128 + sig);
}
g_handling = 1;
const char *name = "UNKNOWN";
switch (sig) {
case SIGSEGV: name = "SIGSEGV"; break;
case SIGABRT: name = "SIGABRT"; break;
case SIGBUS: name = "SIGBUS"; break;
case SIGFPE: name = "SIGFPE"; break;
case SIGILL: name = "SIGILL"; break;
}
write_native_report(name);
signal(sig, SIG_DFL);
raise(sig);
}
static void install_handlers(void) {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = crash_signal_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESETHAND;
sigaction(SIGSEGV, &sa, NULL);
sigaction(SIGABRT, &sa, NULL);
sigaction(SIGBUS, &sa, NULL);
sigaction(SIGFPE, &sa, NULL);
sigaction(SIGILL, &sa, NULL);
}
JNIEXPORT void JNICALL
Java_com_foxx_androidcast_crash_CrashNativeBridge_nativeInstallCrashHook(
JNIEnv *env, jclass clazz, jstring reportDir) {
(void) clazz;
const char *dir = (*env)->GetStringUTFChars(env, reportDir, NULL);
if (!dir) {
return;
}
strncpy(g_report_dir, dir, REPORT_DIR_MAX - 1);
g_report_dir[REPORT_DIR_MAX - 1] = '\0';
(*env)->ReleaseStringUTFChars(env, reportDir, dir);
install_handlers();
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "native crash hook installed: %s", g_report_dir);
}

90
scripts/generate-ota-v0.sh Executable file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Generate v0 OTA artifacts for one APK (stdout = channel stable.json).
#
# Usage:
# ./scripts/generate-ota-v0.sh path/to/app-release.apk https://host[:port] [out-dir]
#
# Writes under out-dir (default: ./ota-publish):
# v0/ota/channel/stable.json
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB_manifest.json
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB_sign.json
# (copy APK to …/android_cast_00.MM.mm.BB.otapkg if out-dir set)
set -euo pipefail
apk="${1:?APK path required}"
host_base="${2:?Base URL required, e.g. https://192.168.1.1:8080}"
out_dir="${3:-ota-publish}"
if [[ ! -f "$apk" ]]; then
echo "APK not found: $apk" >&2
exit 1
fi
pad2() { printf '%02d' "$1"; }
major="" minor="" build=""
if command -v aapt >/dev/null 2>&1; then
badging="$(aapt dump badging "$apk" 2>/dev/null || true)"
version_code="$(echo "$badging" | sed -n "s/.*versionCode='\([^']*\)'.*/\1/p" | head -1)"
version_name="$(echo "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)"
if [[ -n "${version_code:-}" ]]; then
major=$((version_code / 10000))
minor=$(((version_code / 100) % 100))
build=$((version_code % 100))
fi
fi
if [[ -z "${major:-}" ]]; then
echo "Could not read versionCode from APK (install build-tools / aapt)." >&2
exit 1
fi
maj_p="$(pad2 "$major")"
min_p="$(pad2 "$minor")"
bld_p="$(pad2 "$build")"
ver_p="${maj_p}.${min_p}.${bld_p}"
base="${host_base%/}/v0/ota/00/${maj_p}/${ver_p}"
artifact_base="${base}/android_cast_${ver_p}.${bld_p}"
apk_url="${artifact_base}.otapkg"
sign_url="${artifact_base}_sign.json"
manifest_url="${artifact_base}_manifest.json"
sha256="$(sha256sum "$apk" | awk '{print $1}')"
rel_root="${out_dir}/v0/ota"
rel_dir="${rel_root}/00/${maj_p}/${ver_p}"
mkdir -p "${rel_dir}" "${rel_root}/channel"
pkg_name="android_cast_${ver_p}.${bld_p}.otapkg"
cp -f "$apk" "${rel_dir}/${pkg_name}"
cat >"${rel_dir}/android_cast_${ver_p}.${bld_p}_sign.json" <<EOF
{
"schema": "v0",
"sha256": "${sha256}"
}
EOF
cat >"${rel_dir}/android_cast_${ver_p}.${bld_p}_manifest.json" <<EOF
{
"schema": "v0",
"major": ${major},
"minor": ${minor},
"build": ${build},
"versionName": "${version_name:-${major}.${minor}.${build}}",
"apkUrl": "${apk_url}",
"signUrl": "${sign_url}",
"mandatory": false,
"releaseNotes": ""
}
EOF
cat >"${rel_root}/channel/stable.json" <<EOF
{
"schema": "v0",
"manifestUrl": "${manifest_url}"
}
EOF
echo "Published v0 OTA under ${out_dir}/v0/ota" >&2
echo "Channel: ${host_base%/}/v0/ota/channel/stable.json" >&2
cat "${rel_root}/channel/stable.json"