diff --git a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java index 5e56a22..f994290 100644 --- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java +++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java @@ -35,11 +35,16 @@ import com.foxx.androidcast.receiver.av.ReceiverAudioPreset; import com.foxx.androidcast.receiver.av.ReceiverAvRuntime; import com.foxx.androidcast.receiver.av.ReceiverVideoPreset; import com.foxx.androidcast.sender.SenderResolutionUiMode; +import com.foxx.androidcast.dev.DevDiagnosticsController; import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity; +import com.google.android.material.tabs.TabLayout; import com.google.android.material.textfield.TextInputEditText; /** Hidden developer settings (opened via app-info area easter egg). */ public class DeveloperSettingsActivity extends AppCompatActivity { + private DevDiagnosticsController diagnosticsController; + private android.view.View settingsPanel; + private android.view.View diagnosticsPanel; private Spinner videoPresetSpinner; private Spinner audioPresetSpinner; private SeekBar intensitySeek; @@ -57,6 +62,11 @@ public class DeveloperSettingsActivity extends AppCompatActivity { setContentView(R.layout.activity_developer_settings); setTitle(R.string.developer_settings_title); + settingsPanel = findViewById(R.id.dev_panel_settings); + diagnosticsPanel = findViewById(R.id.dev_panel_diagnostics); + diagnosticsController = new DevDiagnosticsController(this); + bindDeveloperTabs(); + CheckBox debugOverlay = findViewById(R.id.check_receiver_debug_overlay); CheckBox codecScores = findViewById(R.id.check_show_codec_priority_scores); CheckBox sessionStats = findViewById(R.id.check_grab_session_stats); @@ -94,6 +104,14 @@ public class DeveloperSettingsActivity extends AppCompatActivity { bindCommercialSection(); } + @Override + protected void onDestroy() { + if (diagnosticsController != null) { + diagnosticsController.onDestroy(); + } + super.onDestroy(); + } + @Override protected void onPause() { saveAvPresets(); @@ -378,6 +396,36 @@ public class DeveloperSettingsActivity extends AppCompatActivity { void write(Context context, boolean value); } + private void bindDeveloperTabs() { + TabLayout tabs = findViewById(R.id.dev_settings_tabs); + if (tabs == null) { + return; + } + tabs.addTab(tabs.newTab().setText(R.string.dev_tab_settings)); + tabs.addTab(tabs.newTab().setText(R.string.dev_tab_diagnostics)); + tabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { + @Override + public void onTabSelected(TabLayout.Tab tab) { + boolean diagnostics = tab.getPosition() == 1; + settingsPanel.setVisibility(diagnostics ? android.view.View.GONE : android.view.View.VISIBLE); + diagnosticsPanel.setVisibility(diagnostics ? android.view.View.VISIBLE : android.view.View.GONE); + if (diagnostics) { + diagnosticsController.onDiagnosticsTabShown(); + } + } + + @Override + public void onTabUnselected(TabLayout.Tab tab) {} + + @Override + public void onTabReselected(TabLayout.Tab tab) { + if (tab.getPosition() == 1) { + diagnosticsController.onDiagnosticsTabShown(); + } + } + }); + } + private void bindDeveloperCheckbox(CheckBox box, boolean checked, BoolPrefWriter writer) { if (box == null) { return; diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsController.java b/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsController.java new file mode 100644 index 0000000..f47bc33 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsController.java @@ -0,0 +1,173 @@ +package com.foxx.androidcast.dev; + +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +import com.foxx.androidcast.R; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Runs async diagnostics and binds results to the Diagnostics tab cards. */ +public final class DevDiagnosticsController { + public enum Section { + HW(R.id.dev_diag_hw, R.string.dev_diag_hw_title), + CAMERA(R.id.dev_diag_camera, R.string.dev_diag_camera_title), + AUDIO(R.id.dev_diag_audio, R.string.dev_diag_audio_title), + NETWORK(R.id.dev_diag_network, R.string.dev_diag_network_title), + CASTING(R.id.dev_diag_casting, R.string.dev_diag_casting_title); + + final int includeId; + final int titleRes; + + Section(int includeId, int titleRes) { + this.includeId = includeId; + this.titleRes = titleRes; + } + } + + private final AppCompatActivity activity; + private final View diagnosticsRoot; + private final Map cards = new LinkedHashMap<>(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Handler main = new Handler(Looper.getMainLooper()); + private final AtomicBoolean cancel = new AtomicBoolean(false); + private final AtomicBoolean running = new AtomicBoolean(false); + + private DevDiagnosticsReport lastReport; + + public DevDiagnosticsController(AppCompatActivity activity) { + this.activity = activity; + this.diagnosticsRoot = activity.findViewById(R.id.dev_panel_diagnostics); + for (Section s : Section.values()) { + View cardRoot = diagnosticsRoot.findViewById(s.includeId); + TextView title = cardRoot.findViewById(R.id.dev_diag_card_title); + TextView body = cardRoot.findViewById(R.id.dev_diag_card_body); + ProgressBar spinner = cardRoot.findViewById(R.id.dev_diag_card_spinner); + title.setText(activity.getString(s.titleRes)); + body.setText(R.string.dev_diag_pending); + cards.put(s, new CardViews(activity, body, spinner)); + } + Button stop = diagnosticsRoot.findViewById(R.id.btn_dev_diag_stop); + stop.setOnClickListener(v -> stopTests()); + } + + public void onDiagnosticsTabShown() { + if (!running.get()) { + startTests(); + } + } + + public void onDestroy() { + cancel.set(true); + executor.shutdownNow(); + } + + public void stopTests() { + cancel.set(true); + } + + /** Last completed report (for future BE upload). */ + public DevDiagnosticsReport getLastReport() { + return lastReport; + } + + private void startTests() { + cancel.set(false); + running.set(true); + for (CardViews c : cards.values()) { + c.showRunning(); + } + executor.execute(this::runSuite); + } + + private void runSuite() { + Map sectionText = new LinkedHashMap<>(); + try { + runSection(Section.HW, () -> + DevDiagnosticsProbe.probeHardwareAcceleration(activity), sectionText); + runSection(Section.CAMERA, () -> + DevDiagnosticsProbe.probeCameraCapture(activity), sectionText); + runSection(Section.AUDIO, () -> + DevDiagnosticsProbe.probeAudioCapture(activity), sectionText); + runSection(Section.NETWORK, () -> + DevDiagnosticsProbe.probeNetwork(activity, cancel), sectionText); + runSection(Section.CASTING, () -> + DevDiagnosticsProbe.probeCastingExtras(activity), sectionText); + } finally { + if (!cancel.get()) { + lastReport = new DevDiagnosticsReport(System.currentTimeMillis(), sectionText); + } + main.post(() -> { + running.set(false); + if (cancel.get()) { + for (CardViews c : cards.values()) { + c.showStopped(); + } + } + }); + } + } + + private void runSection(Section section, Probe probe, Map out) { + if (cancel.get()) { + main.post(() -> cards.get(section).showStopped()); + return; + } + main.post(() -> cards.get(section).showRunning()); + String text; + try { + text = probe.run(); + } catch (Exception e) { + text = "Error: " + e.getMessage(); + } + if (cancel.get()) { + main.post(() -> cards.get(section).showStopped()); + return; + } + out.put(section.name().toLowerCase(Locale.ROOT), text); + String finalText = text; + main.post(() -> cards.get(section).showResult(finalText)); + } + + private interface Probe { + String run(); + } + + private static final class CardViews { + private final AppCompatActivity activity; + private final TextView body; + private final ProgressBar spinner; + + CardViews(AppCompatActivity activity, TextView body, ProgressBar spinner) { + this.activity = activity; + this.body = body; + this.spinner = spinner; + } + + void showRunning() { + spinner.setVisibility(View.VISIBLE); + body.setText(activity.getString(R.string.dev_diag_running)); + } + + void showResult(String text) { + spinner.setVisibility(View.GONE); + body.setText(text); + } + + void showStopped() { + spinner.setVisibility(View.GONE); + body.setText(activity.getString(R.string.dev_diag_stopped)); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsProbe.java b/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsProbe.java new file mode 100644 index 0000000..11b486c --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsProbe.java @@ -0,0 +1,317 @@ +package com.foxx.androidcast.dev; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaFormat; +import android.net.ConnectivityManager; +import android.net.NetworkCapabilities; +import android.net.Uri; +import android.os.Build; +import android.util.Size; + +import androidx.core.content.ContextCompat; + +import com.foxx.androidcast.AppPreferences; +import com.foxx.androidcast.BuildConfig; +import com.foxx.androidcast.PlatformCastSupport; +import com.foxx.androidcast.crash.CrashSettingsStore; +import com.foxx.androidcast.display.WiredDisplayMonitor; +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; +import com.foxx.androidcast.sender.CodecCatalog; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Read-only device probes for developer diagnostics (no user interaction). */ +public final class DevDiagnosticsProbe { + private static final int HTTP_TIMEOUT_MS = 6_000; + + private DevDiagnosticsProbe() {} + + public static String probeHardwareAcceleration(Context context) { + CodecCatalog.invalidateCapabilities(); + StringBuilder sb = new StringBuilder(); + sb.append("API ").append(Build.VERSION.SDK_INT).append('\n'); + sb.append("libvpx JNI: ").append(NativeCodecBridge.isLibvpxAvailable() ? "yes" : "no").append('\n'); + sb.append('\n').append("Video encoders:\n"); + appendVideoCodecLines(sb, true); + sb.append('\n').append("Video decoders:\n"); + appendVideoCodecLines(sb, false); + sb.append('\n').append("MIME summary (catalog):\n"); + sb.append(" advertised enc: ").append(join(CodecCatalog.localEncoderMimes())).append('\n'); + sb.append(" advertised dec: ").append(join(CodecCatalog.localDecoderMimes())); + return sb.toString().trim(); + } + + public static String probeCameraCapture(Context context) { + StringBuilder sb = new StringBuilder(); + sb.append("Camera2 capture modes (cast settings):\n"); + List modes = new ArrayList<>(); + modes.add(context.getString(com.foxx.androidcast.R.string.capture_full_screen)); + if (PlatformCastSupport.isCaptureUserChoiceSupported()) { + modes.add(context.getString(com.foxx.androidcast.R.string.capture_user_choice)); + } else { + modes.add(context.getString(com.foxx.androidcast.R.string.capture_user_choice_unavailable, + Build.VERSION.SDK_INT)); + } + if (PlatformCastSupport.isCameraCaptureModeSupported()) { + modes.add(context.getString(com.foxx.androidcast.R.string.capture_camera_rear)); + if (PlatformCastSupport.hasFrontCamera(context)) { + modes.add(context.getString(com.foxx.androidcast.R.string.capture_camera_front)); + } + } else { + modes.add(context.getString(com.foxx.androidcast.R.string.capture_camera_unavailable)); + } + modes.add(context.getString(com.foxx.androidcast.R.string.capture_calibration_test)); + for (String m : modes) { + sb.append(" • ").append(m).append('\n'); + } + sb.append('\n').append("Physical cameras:\n"); + appendCameraDevices(context, sb); + return sb.toString().trim(); + } + + public static String probeAudioCapture(Context context) { + StringBuilder sb = new StringBuilder(); + boolean api = PlatformCastSupport.isAudioCaptureSupported(); + sb.append("Playback capture (MediaProjection): ") + .append(api ? "supported (API 29+)" : "unavailable").append('\n'); + boolean micPerm = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + sb.append("RECORD_AUDIO granted: ").append(micPerm ? "yes" : "no").append('\n'); + sb.append('\n').append("Runtime modes (AudioCapture):\n"); + if (!api) { + sb.append(" • (none — API < 29)\n"); + } else { + sb.append(" • playback\n"); + sb.append(" • playback+mic (when mic available)\n"); + sb.append(" • mic (calibration / no projection)\n"); + } + sb.append('\n').append("Voice codecs (settings):\n"); + for (CodecCatalog.AudioCodecChoice c : CodecCatalog.audioCodecChoicesForUi()) { + sb.append(" • ").append(c.codec.name()) + .append(c.selectable ? "" : " (N/A)") + .append('\n'); + } + return sb.toString().trim(); + } + + public static String probeNetwork(Context context, AtomicBoolean cancel) { + StringBuilder sb = new StringBuilder(); + sb.append(connectivitySummary(context)).append('\n'); + String crashUrl = resolveCrashUploadUrl(context); + sb.append('\n').append(probeHttp("Crash upload", crashUrl, cancel)).append('\n'); + String otaUrl = resolveOtaUrl(context); + if (!otaUrl.isEmpty()) { + sb.append('\n').append(probeHttp("OTA channel", otaUrl, cancel)); + } + return sb.toString().trim(); + } + + public static String probeCastingExtras(Context context) { + StringBuilder sb = new StringBuilder(); + PackageManager pm = context.getPackageManager(); + sb.append("Wi‑Fi Direct (OS): ") + .append(pm.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT) ? "yes" : "no") + .append('\n'); + sb.append("Miracast / Wi‑Fi Display: ") + .append(miracastSummary(pm)) + .append(" (in-app cast uses MediaProjection, not WFD)\n"); + WiredDisplayMonitor monitor = WiredDisplayMonitor.getInstance(context); + monitor.ensureRegistered(); + sb.append('\n').append("HDMI / external display:\n ") + .append(monitor.getSummaryLine().replace("\n", "\n ")) + .append('\n'); + sb.append('\n').append("MediaProjection screen capture: ") + .append(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? "available" : "N/A") + .append('\n'); + sb.append("App/screen picker (API 34+): ") + .append(PlatformCastSupport.isCaptureUserChoiceSupported() ? "yes" : "no") + .append('\n'); + sb.append("Google Cast resolver: ") + .append(resolveCastIntent(pm) ? "activity found" : "none") + .append('\n'); + sb.append("Nearby / Share resolver: ") + .append(resolveNearbyShare(pm) ? "activity found" : "none"); + return sb.toString().trim(); + } + + private static void appendVideoCodecLines(StringBuilder sb, boolean encoder) { + List lines = new ArrayList<>(); + MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + for (MediaCodecInfo info : list.getCodecInfos()) { + if (info.isEncoder() != encoder) { + continue; + } + boolean hw = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && info.isHardwareAccelerated(); + for (String type : info.getSupportedTypes()) { + if (type == null || !type.startsWith("video/")) { + continue; + } + lines.add(String.format(Locale.US, " %s [%s] %s", type, hw ? "HW" : "SW", info.getName())); + } + } + if (lines.isEmpty()) { + sb.append(" (none)\n"); + return; + } + for (String line : lines) { + sb.append(line).append('\n'); + } + } + + private static void appendCameraDevices(Context context, StringBuilder sb) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + sb.append(" (Camera2 modes need API 29+)\n"); + return; + } + try { + CameraManager mgr = context.getSystemService(CameraManager.class); + if (mgr == null) { + sb.append(" (no CameraManager)\n"); + return; + } + for (String id : mgr.getCameraIdList()) { + CameraCharacteristics ch = mgr.getCameraCharacteristics(id); + Integer facing = ch.get(CameraCharacteristics.LENS_FACING); + String facingStr = facing == null ? "?" : + facing == CameraCharacteristics.LENS_FACING_FRONT ? "front" : + facing == CameraCharacteristics.LENS_FACING_BACK ? "back" : "other"; + StreamConfigurationMap map = ch.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + int outputs = 0; + if (map != null) { + Size[] sizes = map.getOutputSizes(android.graphics.ImageFormat.YUV_420_888); + outputs = sizes != null ? sizes.length : 0; + } + sb.append(" • id=").append(id) + .append(" ").append(facingStr) + .append(", YUV outputs=").append(outputs) + .append('\n'); + } + } catch (CameraAccessException | SecurityException e) { + sb.append(" Error: ").append(e.getMessage()).append('\n'); + } + } + + private static String connectivitySummary(Context context) { + ConnectivityManager cm = context.getSystemService(ConnectivityManager.class); + if (cm == null) { + return "Connectivity: unknown"; + } + android.net.Network net = cm.getActiveNetwork(); + if (net == null) { + return "Connectivity: no active network"; + } + NetworkCapabilities caps = cm.getNetworkCapabilities(net); + if (caps == null) { + return "Connectivity: active network (no caps)"; + } + List parts = new ArrayList<>(); + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + parts.add("Wi‑Fi"); + } + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + parts.add("cellular"); + } + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { + parts.add("ethernet"); + } + if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + parts.add("internet"); + } + return "Connectivity: " + (parts.isEmpty() ? "other" : String.join(", ", parts)); + } + + private static String probeHttp(String label, String url, AtomicBoolean cancel) { + if (cancel != null && cancel.get()) { + return label + ": stopped"; + } + if (url == null || url.trim().isEmpty()) { + return label + ": (no URL configured)"; + } + String trimmed = url.trim(); + long t0 = System.currentTimeMillis(); + try { + String host = Uri.parse(trimmed).getHost(); + if (host != null && !host.isEmpty()) { + InetAddress addr = InetAddress.getByName(host); + long dnsMs = System.currentTimeMillis() - t0; + if (cancel != null && cancel.get()) { + return label + ": stopped"; + } + HttpURLConnection conn = (HttpURLConnection) new URL(trimmed).openConnection(); + conn.setConnectTimeout(HTTP_TIMEOUT_MS); + conn.setReadTimeout(HTTP_TIMEOUT_MS); + conn.setRequestMethod("HEAD"); + conn.setInstanceFollowRedirects(true); + int code = conn.getResponseCode(); + long totalMs = System.currentTimeMillis() - t0; + conn.disconnect(); + return label + ": HTTP " + code + ", DNS " + dnsMs + " ms, total " + totalMs + " ms" + + " (" + addr.getHostAddress() + ")"; + } + return label + ": invalid URL"; + } catch (IOException e) { + return label + ": fail — " + e.getClass().getSimpleName() + ": " + e.getMessage() + + " (" + (System.currentTimeMillis() - t0) + " ms)"; + } + } + + private static String resolveCrashUploadUrl(Context context) { + return CrashSettingsStore.load(context).uploadUrl; + } + + private static String resolveOtaUrl(Context context) { + String url = AppPreferences.getOtaChannelUrl(context); + if (url.isEmpty() && BuildConfig.OTA_CHANNEL_URL_DEFAULT != null) { + url = BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim(); + } + return url; + } + + private static String miracastSummary(PackageManager pm) { + if (pm.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT)) { + return "likely via OS Wi‑Fi Display stack"; + } + return "not reported"; + } + + private static boolean resolveCastIntent(PackageManager pm) { + Intent i = new Intent("android.settings.CAST_SETTINGS"); + return i.resolveActivity(pm) != null; + } + + private static boolean resolveNearbyShare(PackageManager pm) { + Intent i = new Intent(Intent.ACTION_SEND); + i.setType("video/*"); + return i.resolveActivity(pm) != null; + } + + private static String join(List list) { + if (list == null || list.isEmpty()) { + return "(none)"; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(list.get(i)); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsReport.java b/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsReport.java new file mode 100644 index 0000000..d510088 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsReport.java @@ -0,0 +1,60 @@ +package com.foxx.androidcast.dev; + +import android.os.Build; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * In-memory diagnostics snapshot for developer settings. + * {@link #toJson()} is ready for a future BE upload endpoint (not wired yet). + */ +public final class DevDiagnosticsReport { + public final long collectedAtEpochMs; + public final String deviceModel; + public final int sdkInt; + public final Map sections; + + public DevDiagnosticsReport(long collectedAtEpochMs, Map sections) { + this.collectedAtEpochMs = collectedAtEpochMs; + this.deviceModel = Build.MANUFACTURER + " " + Build.MODEL; + this.sdkInt = Build.VERSION.SDK_INT; + this.sections = new LinkedHashMap<>(sections); + } + + public JSONObject toJson() { + JSONObject root = new JSONObject(); + try { + root.put("schema_version", 1); + root.put("kind", "dev_diagnostics"); + root.put("collected_at_ms", collectedAtEpochMs); + root.put("device_model", deviceModel); + root.put("sdk_int", sdkInt); + JSONObject body = new JSONObject(); + for (Map.Entry e : sections.entrySet()) { + body.put(e.getKey(), e.getValue()); + } + root.put("sections", body); + root.put("sections_array", sectionsToArray()); + } catch (Exception ignored) { + } + return root; + } + + private JSONArray sectionsToArray() { + JSONArray arr = new JSONArray(); + for (Map.Entry e : sections.entrySet()) { + JSONObject item = new JSONObject(); + try { + item.put("id", e.getKey()); + item.put("text", e.getValue()); + arr.put(item); + } catch (Exception ignored) { + } + } + return arr; + } +} diff --git a/app/src/main/res/layout/activity_dev_diagnostics_panel.xml b/app/src/main/res/layout/activity_dev_diagnostics_panel.xml new file mode 100644 index 0000000..f58165a --- /dev/null +++ b/app/src/main/res/layout/activity_dev_diagnostics_panel.xml @@ -0,0 +1,57 @@ + + + + + +