mirror of
git://f0xx.org/android_cast
synced 2026-07-29 03:57:50 +03:00
crash handler snapshot, unverified
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.github
|
||||
**/.cxx
|
||||
**/.gradle
|
||||
**/build
|
||||
build/native
|
||||
.log_capture
|
||||
*.apk
|
||||
*.iml
|
||||
.idea
|
||||
local.properties
|
||||
@@ -26,6 +26,27 @@ def buildTimeEpochMs = System.currentTimeMillis()
|
||||
def buildTimeZoneId = java.util.TimeZone.getDefault().getID()
|
||||
def buildTimeDisplay = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", java.util.Locale.US)
|
||||
.format(new java.util.Date(buildTimeEpochMs))
|
||||
def otaMajor = 0
|
||||
def otaMinor = 1
|
||||
def otaBuild = 0
|
||||
def otaChannelUrlDefault = ''
|
||||
def otaManifestUrlDefault = ''
|
||||
try {
|
||||
def lpFile = rootProject.file('local.properties')
|
||||
if (lpFile.exists()) {
|
||||
def lp = new Properties()
|
||||
lp.load(new FileInputStream(lpFile))
|
||||
otaMajor = (lp.getProperty('ota.major', "${otaMajor}") ?: otaMajor) as int
|
||||
otaMinor = (lp.getProperty('ota.minor', "${otaMinor}") ?: otaMinor) as int
|
||||
otaBuild = (lp.getProperty('ota.build', "${otaBuild}") ?: otaBuild) as int
|
||||
otaChannelUrlDefault = lp.getProperty('ota.channel.url', '') ?: ''
|
||||
otaManifestUrlDefault = lp.getProperty('ota.manifest.url', '') ?: ''
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
def otaVersionCode = otaMajor * 10000 + otaMinor * 100 + otaBuild
|
||||
def otaVersionName = "${otaMajor}.${otaMinor}.${otaBuild}"
|
||||
|
||||
try {
|
||||
gitCommitShort = ['git', 'rev-parse', '--short', 'HEAD'].execute(null, rootProject.projectDir).text.trim()
|
||||
gitCommitAuthor = ['git', 'log', '-1', '--format=%an'].execute(null, rootProject.projectDir).text.trim()
|
||||
@@ -40,13 +61,18 @@ android {
|
||||
applicationId "com.foxx.androidcast"
|
||||
minSdk 29
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "0.1.0-poc"
|
||||
versionCode otaVersionCode
|
||||
versionName otaVersionName
|
||||
buildConfigField 'int', 'VERSION_MAJOR', "${otaMajor}"
|
||||
buildConfigField 'int', 'VERSION_MINOR', "${otaMinor}"
|
||||
buildConfigField 'int', 'VERSION_BUILD', "${otaBuild}"
|
||||
buildConfigField 'String', 'GIT_COMMIT_SHORT', "\"${gitCommitShort}\""
|
||||
buildConfigField 'String', 'GIT_COMMIT_AUTHOR', "\"${gitCommitAuthor.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\""
|
||||
buildConfigField 'long', 'BUILD_TIME_EPOCH_MS', "${buildTimeEpochMs}L"
|
||||
buildConfigField 'String', 'BUILD_TIME_ZONE_ID', "\"${buildTimeZoneId.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\""
|
||||
buildConfigField 'String', 'BUILD_TIME_DISPLAY', "\"${buildTimeDisplay.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\""
|
||||
buildConfigField 'String', 'OTA_CHANNEL_URL_DEFAULT', "\"${otaChannelUrlDefault.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\""
|
||||
buildConfigField 'String', 'OTA_MANIFEST_URL_DEFAULT', "\"${otaManifestUrlDefault.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\""
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments "-DANDROID_STL=c++_shared"
|
||||
@@ -95,6 +121,7 @@ dependencies {
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
implementation 'com.google.android.gms:play-services-cronet:18.1.0'
|
||||
implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
@@ -15,6 +16,7 @@
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<application
|
||||
android:name=".AndroidCastApplication"
|
||||
@@ -89,6 +91,16 @@
|
||||
android:theme="@style/Theme.AndroidCast.Fullscreen"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density" />
|
||||
|
||||
<service
|
||||
android:name=".ota.OtaUpdateService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".crash.CrashWatcherService"
|
||||
android:exported="false"
|
||||
android:process=":crashwatcher" />
|
||||
|
||||
<service
|
||||
android:name=".sender.ScreenCastService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.foxx.androidcast;
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
|
||||
import com.foxx.androidcast.crash.CrashReporter;
|
||||
import com.foxx.androidcast.crash.CrashWatcherService;
|
||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||
|
||||
/** Applies saved theme and locale at process start. */
|
||||
@@ -19,5 +21,7 @@ public class AndroidCastApplication extends Application {
|
||||
CastLocaleHelper.applyStoredLocale(this);
|
||||
CastThemeHelper.applyNightMode(this);
|
||||
CastTrayNotifier.syncOnAppStart(this);
|
||||
CrashReporter.install(this);
|
||||
CrashWatcherService.scheduleUploadSweep(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,19 @@ public final class AppPreferences {
|
||||
private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio";
|
||||
private static final String KEY_DEV_RECEIVER_DEBUG_OVERLAY = "dev_receiver_debug_overlay";
|
||||
private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats";
|
||||
private static final String KEY_SEND_ANONYMOUS_CRASH_LOGS = "send_anonymous_crash_logs";
|
||||
private static final String KEY_DEV_SHOW_CODEC_PRIORITY_SCORES = "dev_show_codec_priority_scores";
|
||||
private static final String KEY_RECEIVER_PIP_USER_DEFINED = "receiver_pip_user_defined";
|
||||
private static final String KEY_RECEIVER_PIP_ENABLED = "receiver_pip_enabled";
|
||||
private static final String KEY_OTA_CHANNEL_URL = "ota_channel_url";
|
||||
private static final String KEY_OTA_MANIFEST_URL_LEGACY = "ota_manifest_url";
|
||||
private static final String KEY_OTA_CHECK_ON_LAUNCH = "ota_check_on_launch";
|
||||
private static final String KEY_OTA_LAST_CHECK_MS = "ota_last_check_ms";
|
||||
private static final String KEY_OTA_DISMISSED_VERSION = "ota_dismissed_version_code";
|
||||
private static final String KEY_OTA_ROUND_ROBIN_INDEX = "ota_round_robin_index";
|
||||
|
||||
/** Minimum interval between automatic OTA checks on app launch. */
|
||||
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
|
||||
|
||||
private static final String PREFIX_SENDER = "sender_";
|
||||
private static final String PREFIX_RECEIVER = "receiver_";
|
||||
@@ -115,6 +125,15 @@ public final class AppPreferences {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_GRAB_SESSION_STATS, grab).apply();
|
||||
}
|
||||
|
||||
/** Send anonymous crash reports via the standalone crash watcher process (default on). */
|
||||
public static boolean isSendAnonymousCrashLogs(Context context) {
|
||||
return prefs(context).getBoolean(KEY_SEND_ANONYMOUS_CRASH_LOGS, true);
|
||||
}
|
||||
|
||||
public static void setSendAnonymousCrashLogs(Context context, boolean send) {
|
||||
prefs(context).edit().putBoolean(KEY_SEND_ANONYMOUS_CRASH_LOGS, send).apply();
|
||||
}
|
||||
|
||||
/** Developer: show priority scores in codec settings spinners (default off). */
|
||||
public static boolean isShowCodecPriorityScores(Context context) {
|
||||
return prefs(context).getBoolean(KEY_DEV_SHOW_CODEC_PRIORITY_SCORES, false);
|
||||
@@ -124,6 +143,56 @@ public final class AppPreferences {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_SHOW_CODEC_PRIORITY_SCORES, show).apply();
|
||||
}
|
||||
|
||||
/** v0 channel URL ({@code …/v0/ota/channel/stable.json}) or direct manifest URL. */
|
||||
public static String getOtaChannelUrl(Context context) {
|
||||
SharedPreferences p = prefs(context);
|
||||
String url = p.getString(KEY_OTA_CHANNEL_URL, "").trim();
|
||||
if (url.isEmpty()) {
|
||||
url = p.getString(KEY_OTA_MANIFEST_URL_LEGACY, "").trim();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
public static void setOtaChannelUrl(Context context, String url) {
|
||||
String v = url != null ? url.trim() : "";
|
||||
prefs(context).edit()
|
||||
.putString(KEY_OTA_CHANNEL_URL, v)
|
||||
.remove(KEY_OTA_MANIFEST_URL_LEGACY)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public static boolean isOtaCheckOnLaunch(Context context) {
|
||||
return prefs(context).getBoolean(KEY_OTA_CHECK_ON_LAUNCH, true);
|
||||
}
|
||||
|
||||
public static void setOtaCheckOnLaunch(Context context, boolean check) {
|
||||
prefs(context).edit().putBoolean(KEY_OTA_CHECK_ON_LAUNCH, check).apply();
|
||||
}
|
||||
|
||||
public static long getOtaLastCheckMs(Context context) {
|
||||
return prefs(context).getLong(KEY_OTA_LAST_CHECK_MS, 0L);
|
||||
}
|
||||
|
||||
public static void setOtaLastCheckMs(Context context, long epochMs) {
|
||||
prefs(context).edit().putLong(KEY_OTA_LAST_CHECK_MS, epochMs).apply();
|
||||
}
|
||||
|
||||
public static int getOtaRoundRobinIndex(Context context) {
|
||||
return Math.max(0, prefs(context).getInt(KEY_OTA_ROUND_ROBIN_INDEX, 0));
|
||||
}
|
||||
|
||||
public static void setOtaRoundRobinIndex(Context context, int nextIndex) {
|
||||
prefs(context).edit().putInt(KEY_OTA_ROUND_ROBIN_INDEX, Math.max(0, nextIndex)).apply();
|
||||
}
|
||||
|
||||
public static int getOtaDismissedVersionCode(Context context) {
|
||||
return prefs(context).getInt(KEY_OTA_DISMISSED_VERSION, 0);
|
||||
}
|
||||
|
||||
public static void setOtaDismissedVersionCode(Context context, int versionCode) {
|
||||
prefs(context).edit().putInt(KEY_OTA_DISMISSED_VERSION, versionCode).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver PiP: user toggle when set; otherwise derived from PiP support + notifications permission.
|
||||
*/
|
||||
|
||||
@@ -57,6 +57,7 @@ public class CastSettingsFragment extends Fragment {
|
||||
usernameInput = view.findViewById(R.id.edit_username);
|
||||
pinInput = view.findViewById(R.id.edit_settings_pin);
|
||||
trayIconCheck = view.findViewById(R.id.check_tray_icon);
|
||||
CheckBox crashLogsCheck = view.findViewById(R.id.check_send_crash_logs);
|
||||
themeSpinner = view.findViewById(R.id.spinner_theme);
|
||||
languageSpinner = view.findViewById(R.id.spinner_language);
|
||||
|
||||
@@ -76,6 +77,11 @@ public class CastSettingsFragment extends Fragment {
|
||||
AppPreferences.setShowTrayIcon(requireContext(), checked);
|
||||
CastTrayNotifier.onPreferenceChanged(requireContext());
|
||||
});
|
||||
if (crashLogsCheck != null) {
|
||||
crashLogsCheck.setChecked(AppPreferences.isSendAnonymousCrashLogs(requireContext()));
|
||||
crashLogsCheck.setOnCheckedChangeListener((btn, checked) ->
|
||||
AppPreferences.setSendAnonymousCrashLogs(requireContext(), checked));
|
||||
}
|
||||
themeSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
|
||||
private boolean first = true;
|
||||
|
||||
|
||||
@@ -4,12 +4,16 @@ import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||
import com.foxx.androidcast.ota.OtaUpdateChecker;
|
||||
import com.foxx.androidcast.ota.OtaUpdateCoordinator;
|
||||
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
|
||||
/** Hidden developer settings (opened via version label easter egg). */
|
||||
public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
@@ -39,6 +43,22 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
String summary = CodecPriorityCatalog.reload(this);
|
||||
Toast.makeText(this, summary, Toast.LENGTH_LONG).show();
|
||||
});
|
||||
|
||||
TextView installedVersion = findViewById(R.id.text_ota_installed_version);
|
||||
TextInputEditText manifestUrl = findViewById(R.id.edit_ota_manifest_url);
|
||||
CheckBox otaOnLaunch = findViewById(R.id.check_ota_on_launch);
|
||||
Button checkOta = findViewById(R.id.btn_check_ota);
|
||||
|
||||
refreshOtaUi(installedVersion, manifestUrl, otaOnLaunch);
|
||||
|
||||
otaOnLaunch.setOnCheckedChangeListener((buttonView, isChecked) ->
|
||||
AppPreferences.setOtaCheckOnLaunch(this, isChecked));
|
||||
|
||||
checkOta.setOnClickListener(v -> {
|
||||
String url = manifestUrl.getText() != null ? manifestUrl.getText().toString().trim() : "";
|
||||
AppPreferences.setOtaChannelUrl(this, url);
|
||||
OtaUpdateCoordinator.requestManualCheck(this);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -48,6 +68,34 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
findViewById(R.id.check_receiver_debug_overlay),
|
||||
findViewById(R.id.check_show_codec_priority_scores),
|
||||
findViewById(R.id.check_grab_session_stats));
|
||||
refreshOtaUi(
|
||||
findViewById(R.id.text_ota_installed_version),
|
||||
findViewById(R.id.edit_ota_manifest_url),
|
||||
findViewById(R.id.check_ota_on_launch));
|
||||
}
|
||||
|
||||
private void refreshOtaUi(TextView installedVersion, TextInputEditText manifestUrl, CheckBox otaOnLaunch) {
|
||||
if (installedVersion != null) {
|
||||
installedVersion.setText(getString(R.string.ota_installed_version,
|
||||
OtaUpdateChecker.installedVersionName(this),
|
||||
OtaUpdateChecker.installedVersionCode(this)));
|
||||
}
|
||||
if (manifestUrl != null) {
|
||||
String url = AppPreferences.getOtaChannelUrl(this);
|
||||
if (url.isEmpty() && BuildConfig.OTA_CHANNEL_URL_DEFAULT != null) {
|
||||
url = BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim();
|
||||
}
|
||||
if (url.isEmpty() && BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) {
|
||||
url = BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim();
|
||||
}
|
||||
manifestUrl.setText(url);
|
||||
}
|
||||
if (otaOnLaunch != null) {
|
||||
otaOnLaunch.setOnCheckedChangeListener(null);
|
||||
otaOnLaunch.setChecked(AppPreferences.isOtaCheckOnLaunch(this));
|
||||
otaOnLaunch.setOnCheckedChangeListener((buttonView, isChecked) ->
|
||||
AppPreferences.setOtaCheckOnLaunch(this, isChecked));
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshFromPreferences(CheckBox debugOverlay, CheckBox codecScores, CheckBox sessionStats) {
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
|
||||
import com.foxx.androidcast.ota.OtaManifest;
|
||||
import com.foxx.androidcast.ota.OtaUpdateCoordinator;
|
||||
import com.foxx.androidcast.ota.OtaUpdateService;
|
||||
import com.foxx.androidcast.receiver.ReceiverPlaybackActivity;
|
||||
import com.foxx.androidcast.sender.SenderActivity;
|
||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||
|
||||
public class MainActivity extends DrawerHostActivity {
|
||||
private final BroadcastReceiver otaUpdateReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (OtaUpdateService.ACTION_UPDATE_AVAILABLE.equals(intent.getAction())) {
|
||||
String json = intent.getStringExtra(OtaUpdateService.EXTRA_MANIFEST_JSON);
|
||||
String sourceUrl = intent.getStringExtra(OtaUpdateService.EXTRA_SOURCE_URL);
|
||||
boolean trustedSource = intent.getBooleanExtra(
|
||||
OtaUpdateService.EXTRA_TRUSTED_SOURCE,
|
||||
false);
|
||||
OtaManifest manifest = OtaManifest.fromJsonString(json);
|
||||
if (manifest != null && !isFinishing()) {
|
||||
OtaUpdateCoordinator.presentUpdateDialog(
|
||||
MainActivity.this,
|
||||
manifest,
|
||||
sourceUrl != null ? sourceUrl : "",
|
||||
trustedSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
protected int getContentLayoutId() {
|
||||
return R.layout.activity_main;
|
||||
@@ -32,10 +59,28 @@ public class MainActivity extends DrawerHostActivity {
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
IntentFilter filter = new IntentFilter(OtaUpdateService.ACTION_UPDATE_AVAILABLE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(otaUpdateReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
|
||||
} else {
|
||||
registerReceiver(otaUpdateReceiver, filter);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
unregisterReceiver(otaUpdateReceiver);
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
PermissionHelper.remindIfMissing(this, false);
|
||||
OtaUpdateCoordinator.requestCheckOnLaunch(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/com/foxx/androidcast/ota/OtaApkInstaller.java
Normal file
124
app/src/main/java/com/foxx/androidcast/ota/OtaApkInstaller.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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", ""));
|
||||
}
|
||||
}
|
||||
116
app/src/main/java/com/foxx/androidcast/ota/OtaJsonFetch.java
Normal file
116
app/src/main/java/com/foxx/androidcast/ota/OtaJsonFetch.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
130
app/src/main/java/com/foxx/androidcast/ota/OtaManifest.java
Normal file
130
app/src/main/java/com/foxx/androidcast/ota/OtaManifest.java
Normal 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 "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/src/main/java/com/foxx/androidcast/ota/OtaMqttFetch.java
Normal file
114
app/src/main/java/com/foxx/androidcast/ota/OtaMqttFetch.java
Normal 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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
189
app/src/main/java/com/foxx/androidcast/ota/OtaSettingsStore.java
Normal file
189
app/src/main/java/com/foxx/androidcast/ota/OtaSettingsStore.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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", ""));
|
||||
}
|
||||
}
|
||||
142
app/src/main/java/com/foxx/androidcast/ota/OtaUpdateChecker.java
Normal file
142
app/src/main/java/com/foxx/androidcast/ota/OtaUpdateChecker.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
362
app/src/main/java/com/foxx/androidcast/ota/OtaUpdateService.java
Normal file
362
app/src/main/java/com/foxx/androidcast/ota/OtaUpdateService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
app/src/main/java/com/foxx/androidcast/ota/OtaVersion.java
Normal file
49
app/src/main/java/com/foxx/androidcast/ota/OtaVersion.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -855,7 +855,7 @@ public class ReceiverCastService extends Service {
|
||||
}
|
||||
recorder.setResolution(pendingWidth, pendingHeight);
|
||||
recorder.mergeLoss(buildReceiverLossSnapshot());
|
||||
CastBackgroundExecutor.execute(() -> recorder.finish(getApplicationContext()));
|
||||
recorder.finish(getApplicationContext());
|
||||
}
|
||||
|
||||
private void attachSurface(Surface surface) {
|
||||
|
||||
@@ -951,8 +951,7 @@ public class ScreenCastService extends Service implements
|
||||
? activeSettings.getCaptureMode().name() : "");
|
||||
}
|
||||
recorder.mergeLoss(buildSenderLossSnapshot());
|
||||
com.foxx.androidcast.util.CastBackgroundExecutor.execute(
|
||||
() -> recorder.finish(getApplicationContext()));
|
||||
recorder.finish(getApplicationContext());
|
||||
}
|
||||
|
||||
private void logEncodedCodecSample(String mime, byte[] frameData) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.foxx.androidcast.stats;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** Active session stats recorder for transport/pump hooks without threading references everywhere. */
|
||||
@@ -54,4 +56,19 @@ public final class SessionStatsContext {
|
||||
r.flushGeneralIfDue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: persist the active session when the JVM is about to die from an uncaught exception.
|
||||
* Native crashes (SIGSEGV) are not covered.
|
||||
*/
|
||||
public static void flushActiveOnUncaughtException(Context context) {
|
||||
SessionStatsRecorder r = ACTIVE.get();
|
||||
if (r == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
r.finish(context.getApplicationContext(), "crash");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}.
|
||||
*/
|
||||
@@ -43,6 +45,9 @@ public final class SessionStatsRecorder {
|
||||
private final JSONArray samples = new JSONArray();
|
||||
private long lastSampleMs;
|
||||
|
||||
/** Ensures each recorder persists at most once (normal end vs crash hook). */
|
||||
private final AtomicBoolean persisted = new AtomicBoolean(false);
|
||||
|
||||
/** @param direction {@code send} or {@code recv}. */
|
||||
public SessionStatsRecorder(String direction, String transport) {
|
||||
this.direction = "recv".equals(direction) ? "recv" : "send";
|
||||
@@ -236,96 +241,118 @@ public final class SessionStatsRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
/** Normal cast end: persist session JSON to disk (synchronous). */
|
||||
public void finish(Context context) {
|
||||
finish(context, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist session JSON. {@code reason} non-null is recorded under {@code globals.reason}
|
||||
* (e.g. {@code crash} from an uncaught exception hook). Pass {@code null} for a normal session end.
|
||||
*/
|
||||
public void finish(Context context, String reason) {
|
||||
SessionStatsContext.unbind(this);
|
||||
if (!AppPreferences.isGrabSessionStats(context)) {
|
||||
return;
|
||||
}
|
||||
if (!persisted.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
journal.flushGeneralFinal();
|
||||
long endedMs = System.currentTimeMillis();
|
||||
try {
|
||||
JSONObject root = new JSONObject();
|
||||
root.put("schema_version", 2);
|
||||
root.put("direction", direction);
|
||||
root.put("started_at_epoch_ms", startedMs);
|
||||
root.put("ended_at_epoch_ms", endedMs);
|
||||
root.put("duration_ms", endedMs - startedMs);
|
||||
root.put("role", role);
|
||||
root.put("transport", transport);
|
||||
root.put("client_count", clientCount);
|
||||
root.put("disrupt_count", disruptCount);
|
||||
root.put("idle_timeout_count", idleTimeouts);
|
||||
root.put("video_codec", videoCodec);
|
||||
root.put("audio_codec", audioCodec);
|
||||
root.put("capture_mode", captureMode);
|
||||
root.put("resolution", resolution);
|
||||
root.put("network_samples", networkSamples);
|
||||
|
||||
JSONArray journalEvents = journal.copyEvents();
|
||||
if (journalEvents.length() > 0) {
|
||||
root.put(direction, journalEvents);
|
||||
}
|
||||
|
||||
if (networkSamples > 0) {
|
||||
root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
|
||||
root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples)));
|
||||
}
|
||||
if (bitrateSamples > 0) {
|
||||
root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples);
|
||||
}
|
||||
if (fpsSamples > 0) {
|
||||
root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples));
|
||||
}
|
||||
|
||||
JSONObject protection = new JSONObject();
|
||||
StreamProtectionMode protMode = lossTotals.protectionMode != null
|
||||
? lossTotals.protectionMode : StreamProtectionMode.NONE;
|
||||
protection.put("mode", protMode.name());
|
||||
protection.put("fec_enabled", protMode.usesFec());
|
||||
protection.put("nack_enabled", protMode.usesNack());
|
||||
protection.put("fec_packets_encoded", lossTotals.fecPacketsEncoded);
|
||||
protection.put("fec_packets_decoded", lossTotals.fecPacketsDecoded);
|
||||
protection.put("fec_decode_failures", lossTotals.fecDecodeFailures);
|
||||
protection.put("nack_requests_sent", lossTotals.nackRequestsSent);
|
||||
protection.put("nack_requests_received", lossTotals.nackRequestsReceived);
|
||||
protection.put("nack_retransmits", lossTotals.nackRetransmits);
|
||||
protection.put("fec_stub_packets", lossTotals.fecPacketsStub);
|
||||
protection.put("nack_stub_requests", lossTotals.nackRequestsStub);
|
||||
root.put("stream_protection", protection);
|
||||
|
||||
JSONObject udp = new JSONObject();
|
||||
synchronized (lossTotals) {
|
||||
udp.put("datagrams_tx", lossTotals.datagramsSent);
|
||||
udp.put("datagrams_rx", lossTotals.datagramsReceived);
|
||||
udp.put("datagrams_invalid", lossTotals.datagramsInvalid);
|
||||
udp.put("fragments_rx", lossTotals.fragmentsReceived);
|
||||
udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate);
|
||||
udp.put("reassembly_ok", lossTotals.reassemblyCompleted);
|
||||
udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded);
|
||||
udp.put("wire_dropped", lossTotals.wirePacketsDropped);
|
||||
udp.put("malformed", lossTotals.malformedPackets);
|
||||
udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP);
|
||||
udp.put("send_dropped_audio", lossTotals.sendDroppedAudio);
|
||||
udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey);
|
||||
udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors);
|
||||
udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors);
|
||||
udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
|
||||
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
|
||||
udp.put("fec_packets_encoded", lossTotals.fecPacketsEncoded);
|
||||
udp.put("fec_packets_decoded", lossTotals.fecPacketsDecoded);
|
||||
udp.put("fec_decode_failures", lossTotals.fecDecodeFailures);
|
||||
udp.put("nack_requests_sent", lossTotals.nackRequestsSent);
|
||||
udp.put("nack_requests_received", lossTotals.nackRequestsReceived);
|
||||
udp.put("nack_retransmits", lossTotals.nackRetransmits);
|
||||
udp.put("nack_cache_misses", lossTotals.nackCacheMisses);
|
||||
}
|
||||
root.put("udp", udp);
|
||||
if (samples.length() > 0) {
|
||||
root.put("samples", samples);
|
||||
}
|
||||
|
||||
JSONObject root = buildSessionJson(endedMs, reason);
|
||||
SessionStatsStore.saveSession(context, startedMs, direction, root);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject buildSessionJson(long endedMs, String reason) throws Exception {
|
||||
JSONObject root = new JSONObject();
|
||||
root.put("schema_version", 2);
|
||||
root.put("direction", direction);
|
||||
root.put("started_at_epoch_ms", startedMs);
|
||||
root.put("ended_at_epoch_ms", endedMs);
|
||||
root.put("duration_ms", endedMs - startedMs);
|
||||
root.put("role", role);
|
||||
root.put("transport", transport);
|
||||
root.put("client_count", clientCount);
|
||||
root.put("disrupt_count", disruptCount);
|
||||
root.put("idle_timeout_count", idleTimeouts);
|
||||
root.put("video_codec", videoCodec);
|
||||
root.put("audio_codec", audioCodec);
|
||||
root.put("capture_mode", captureMode);
|
||||
root.put("resolution", resolution);
|
||||
root.put("network_samples", networkSamples);
|
||||
|
||||
JSONArray journalEvents = journal.copyEvents();
|
||||
if (journalEvents.length() > 0) {
|
||||
root.put(direction, journalEvents);
|
||||
}
|
||||
|
||||
if (networkSamples > 0) {
|
||||
root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
|
||||
root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples)));
|
||||
}
|
||||
if (bitrateSamples > 0) {
|
||||
root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples);
|
||||
}
|
||||
if (fpsSamples > 0) {
|
||||
root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples));
|
||||
}
|
||||
|
||||
JSONObject protection = new JSONObject();
|
||||
StreamProtectionMode protMode = lossTotals.protectionMode != null
|
||||
? lossTotals.protectionMode : StreamProtectionMode.NONE;
|
||||
protection.put("mode", protMode.name());
|
||||
protection.put("fec_enabled", protMode.usesFec());
|
||||
protection.put("nack_enabled", protMode.usesNack());
|
||||
protection.put("fec_packets_encoded", lossTotals.fecPacketsEncoded);
|
||||
protection.put("fec_packets_decoded", lossTotals.fecPacketsDecoded);
|
||||
protection.put("fec_decode_failures", lossTotals.fecDecodeFailures);
|
||||
protection.put("nack_requests_sent", lossTotals.nackRequestsSent);
|
||||
protection.put("nack_requests_received", lossTotals.nackRequestsReceived);
|
||||
protection.put("nack_retransmits", lossTotals.nackRetransmits);
|
||||
protection.put("fec_stub_packets", lossTotals.fecPacketsStub);
|
||||
protection.put("nack_stub_requests", lossTotals.nackRequestsStub);
|
||||
root.put("stream_protection", protection);
|
||||
|
||||
JSONObject udp = new JSONObject();
|
||||
synchronized (lossTotals) {
|
||||
udp.put("datagrams_tx", lossTotals.datagramsSent);
|
||||
udp.put("datagrams_rx", lossTotals.datagramsReceived);
|
||||
udp.put("datagrams_invalid", lossTotals.datagramsInvalid);
|
||||
udp.put("fragments_rx", lossTotals.fragmentsReceived);
|
||||
udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate);
|
||||
udp.put("reassembly_ok", lossTotals.reassemblyCompleted);
|
||||
udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded);
|
||||
udp.put("wire_dropped", lossTotals.wirePacketsDropped);
|
||||
udp.put("malformed", lossTotals.malformedPackets);
|
||||
udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP);
|
||||
udp.put("send_dropped_audio", lossTotals.sendDroppedAudio);
|
||||
udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey);
|
||||
udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors);
|
||||
udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors);
|
||||
udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
|
||||
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
|
||||
udp.put("fec_packets_encoded", lossTotals.fecPacketsEncoded);
|
||||
udp.put("fec_packets_decoded", lossTotals.fecPacketsDecoded);
|
||||
udp.put("fec_decode_failures", lossTotals.fecDecodeFailures);
|
||||
udp.put("nack_requests_sent", lossTotals.nackRequestsSent);
|
||||
udp.put("nack_requests_received", lossTotals.nackRequestsReceived);
|
||||
udp.put("nack_retransmits", lossTotals.nackRetransmits);
|
||||
udp.put("nack_cache_misses", lossTotals.nackCacheMisses);
|
||||
}
|
||||
root.put("udp", udp);
|
||||
if (samples.length() > 0) {
|
||||
root.put("samples", samples);
|
||||
}
|
||||
|
||||
if (reason != null && !reason.isEmpty()) {
|
||||
JSONObject globals = new JSONObject();
|
||||
globals.put("reason", reason);
|
||||
root.put("globals", globals);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,5 +58,55 @@
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/dev_reload_codecs_json_hint"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:text="@string/ota_section_title"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_ota_installed_version"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="@string/ota_manifest_url_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/edit_ota_manifest_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/check_ota_on_launch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:checked="true"
|
||||
android:text="@string/ota_check_on_launch" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_check_ota"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/ota_check_now" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/ota_section_hint"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
21
app/src/main/res/layout/dialog_ota_download.xml
Normal file
21
app/src/main/res/layout/dialog_ota_download.xml
Normal 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>
|
||||
@@ -87,6 +87,14 @@
|
||||
android:checked="true"
|
||||
android:text="@string/label_show_tray_icon" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/check_send_crash_logs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:checked="true"
|
||||
android:text="@string/label_send_anonymous_crash_logs" />
|
||||
|
||||
<include
|
||||
android:id="@+id/header_sender"
|
||||
layout="@layout/include_settings_section_header"
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
<string name="settings_section_receiver">Receiver settings</string>
|
||||
<string name="label_username">Username</string>
|
||||
<string name="label_show_tray_icon">Always show icon in system bar</string>
|
||||
<string name="label_send_anonymous_crash_logs">Send anonymous crash logs</string>
|
||||
<string name="pin_anonymous_hint">Leave empty for anonymous cast</string>
|
||||
<string name="label_receiver_display">Resolution</string>
|
||||
<string name="receiver_display_auto_fit">AUTO (fits screen)</string>
|
||||
@@ -216,6 +217,25 @@
|
||||
<string name="session_stats_chart_hint">Tap sessions to toggle overlay. Pinch or scroll wheel to zoom. Drag finger for values.</string>
|
||||
<string name="dev_reload_codecs_json">Reload codecs.json</string>
|
||||
<string name="dev_reload_codecs_json_hint">Loads assets/codecs.json overrides without restarting the app. Reopen the settings drawer to refresh spinners.</string>
|
||||
<string name="ota_section_title">App updates (OTA)</string>
|
||||
<string name="ota_section_hint">v0 layout: https://host/v0/ota/channel/stable.json → manifest → APK. Service checks every 1 minute by default (overridable in private settings.json).</string>
|
||||
<string name="ota_manifest_url_hint">OTA channel URL (v0 stable.json)</string>
|
||||
<string name="ota_check_on_launch">Enable OTA periodic checks (default every 1 minute)</string>
|
||||
<string name="ota_check_now">Check for updates now</string>
|
||||
<string name="ota_installed_version">Installed: %1$s (%2$d)</string>
|
||||
<string name="ota_no_url">No OTA channel URL — set one in developer settings or local.properties (ota.channel.url).</string>
|
||||
<string name="ota_check_failed">Update check failed: %1$s</string>
|
||||
<string name="ota_up_to_date">Up to date (%1$s, %2$d).</string>
|
||||
<string name="ota_dismissed_hint">You dismissed this version — tap Check again to see it.</string>
|
||||
<string name="ota_update_title">Update available</string>
|
||||
<string name="ota_update_message">New OTA version found (%1$s, current version is: %2$s / %3$s MB). Would you like to perform the update?\n\n%4$s</string>
|
||||
<string name="ota_no_release_notes">No release notes.</string>
|
||||
<string name="ota_download_install">Download & install</string>
|
||||
<string name="ota_later">Not now</string>
|
||||
<string name="ota_download_title">Downloading update</string>
|
||||
<string name="ota_downloading">Downloading APK…</string>
|
||||
<string name="ota_download_failed">Download failed: %1$s</string>
|
||||
<string name="ota_notification_channel_name">App updates</string>
|
||||
<string name="codec_priority_score_suffix"> · %1$d</string>
|
||||
<string name="label_audio_codec">Voice codec</string>
|
||||
<string name="codec_libvpx_vp8">libvpx (VP8)</string>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<files-path name="session_stats" path="session_stats/" />
|
||||
<cache-path name="ota_cache" path="ota/" />
|
||||
</paths>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
26
docs/CRASH_REPORTER.md
Normal 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
118
docs/OTA.md
Normal 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 0–99):
|
||||
|
||||
```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`.
|
||||
19
examples/crash_reporter/ROADMAP.md
Normal file
19
examples/crash_reporter/ROADMAP.md
Normal 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)
|
||||
83
examples/crash_reporter/backend/README.md
Normal file
83
examples/crash_reporter/backend/README.md
Normal 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`).
|
||||
19
examples/crash_reporter/backend/config/config.example.php
Normal file
19
examples/crash_reporter/backend/config/config.example.php
Normal 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',
|
||||
];
|
||||
18
examples/crash_reporter/backend/config/config.php
Normal file
18
examples/crash_reporter/backend/config/config.php
Normal 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',
|
||||
];
|
||||
BIN
examples/crash_reporter/backend/data/crashes.sqlite
Normal file
BIN
examples/crash_reporter/backend/data/crashes.sqlite
Normal file
Binary file not shown.
40
examples/crash_reporter/backend/nginx.conf
Normal file
40
examples/crash_reporter/backend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
21
examples/crash_reporter/backend/public/api/upload.php
Normal file
21
examples/crash_reporter/backend/public/api/upload.php
Normal 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);
|
||||
}
|
||||
122
examples/crash_reporter/backend/public/assets/css/app.css
Normal file
122
examples/crash_reporter/backend/public/assets/css/app.css
Normal 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; }
|
||||
6
examples/crash_reporter/backend/public/assets/js/app.js
Normal file
6
examples/crash_reporter/backend/public/assets/js/app.js
Normal 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'));
|
||||
});
|
||||
59
examples/crash_reporter/backend/public/index.php
Normal file
59
examples/crash_reporter/backend/public/index.php
Normal 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';
|
||||
30
examples/crash_reporter/backend/sql/schema.mariadb.sql
Normal file
30
examples/crash_reporter/backend/sql/schema.mariadb.sql
Normal 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');
|
||||
29
examples/crash_reporter/backend/sql/schema.sqlite.sql
Normal file
29
examples/crash_reporter/backend/sql/schema.sqlite.sql
Normal 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
|
||||
52
examples/crash_reporter/backend/src/Auth.php
Normal file
52
examples/crash_reporter/backend/src/Auth.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
39
examples/crash_reporter/backend/src/Database.php
Normal file
39
examples/crash_reporter/backend/src/Database.php
Normal 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;
|
||||
}
|
||||
}
|
||||
57
examples/crash_reporter/backend/src/ReportRepository.php
Normal file
57
examples/crash_reporter/backend/src/ReportRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
examples/crash_reporter/backend/src/bootstrap.php
Normal file
41
examples/crash_reporter/backend/src/bootstrap.php
Normal 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;
|
||||
}
|
||||
82
examples/crash_reporter/backend/views/layout.php
Normal file
82
examples/crash_reporter/backend/views/layout.php
Normal 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&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&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>
|
||||
23
examples/crash_reporter/backend/views/login.php
Normal file
23
examples/crash_reporter/backend/views/login.php
Normal 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>
|
||||
50
examples/crash_reporter/backend/views/report_detail.php
Normal file
50
examples/crash_reporter/backend/views/report_detail.php
Normal 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>
|
||||
36
examples/crash_reporter/sample_crash_java.json
Normal file
36
examples/crash_reporter/sample_crash_java.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
examples/ota/mqtt-topics.md
Normal file
17
examples/ota/mqtt-topics.md
Normal 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`
|
||||
@@ -0,0 +1,2 @@
|
||||
PLACEHOLDER PACKAGE
|
||||
Replace this with the real release artifact bytes.
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema": "v0",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
PLACEHOLDER PACKAGE
|
||||
Replace this with the next OTA candidate artifact bytes.
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema": "v0",
|
||||
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
}
|
||||
4
examples/ota/v0/ota/channel/current.json
Normal file
4
examples/ota/v0/ota/channel/current.json
Normal 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"
|
||||
}
|
||||
4
examples/ota/v0/ota/channel/next.json
Normal file
4
examples/ota/v0/ota/channel/next.json
Normal 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"
|
||||
}
|
||||
4
examples/ota/v0/ota/channel/stable.json
Normal file
4
examples/ota/v0/ota/channel/stable.json
Normal 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
19
examples/settings.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ add_library(androidcast_codecs SHARED
|
||||
${NDK_BRIDGE}/jni/libvpx_bridge.c
|
||||
${NDK_BRIDGE}/jni/opus_bridge.c
|
||||
${NDK_BRIDGE}/jni/speex_bridge.c
|
||||
${NDK_BRIDGE}/jni/crash_hook.c
|
||||
)
|
||||
|
||||
target_include_directories(androidcast_codecs PRIVATE
|
||||
@@ -47,4 +48,5 @@ endif()
|
||||
|
||||
find_library(log-lib log)
|
||||
find_library(android-lib android)
|
||||
target_link_libraries(androidcast_codecs ${log-lib} ${android-lib})
|
||||
find_library(dl-lib dl)
|
||||
target_link_libraries(androidcast_codecs ${log-lib} ${android-lib} ${dl-lib})
|
||||
|
||||
122
ndk/jni/crash_hook.c
Normal file
122
ndk/jni/crash_hook.c
Normal 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
90
scripts/generate-ota-v0.sh
Executable 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"
|
||||
Reference in New Issue
Block a user