From 26ef8f539922ddd978cfad911ce0e5808828bfca Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Mon, 15 Jun 2026 16:31:16 +0200 Subject: [PATCH] tray bar rework --- .../androidcast/AndroidCastApplication.java | 2 + .../com/foxx/androidcast/CastTrayContent.java | 92 +++++++++++ .../foxx/androidcast/CastTrayNotifier.java | 148 ++++++++++++------ .../androidcast/TrayForegroundService.java | 32 ++-- .../androidcast/TrayNotificationReceiver.java | 2 +- .../androidcast/dev/DevAdbWifiKeeper.java | 72 +-------- .../remoteaccess/RemoteAccessService.java | 64 ++------ .../remoteaccess/RemoteAccessStatusStore.java | 5 +- .../vpn/RemoteAccessVpnService.java | 52 ++---- .../foxx/androidcast/CastTrayContentTest.java | 89 +++++++++++ 10 files changed, 333 insertions(+), 225 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/CastTrayContent.java create mode 100644 app/src/test/java/com/foxx/androidcast/CastTrayContentTest.java diff --git a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java index 536e64e..0e28bab 100644 --- a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java +++ b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java @@ -14,6 +14,7 @@ package com.foxx.androidcast; import android.app.Application; import android.content.Context; +import com.foxx.androidcast.CastTrayNotifier; import com.foxx.androidcast.commercial.CommercialFeatures; import com.foxx.androidcast.crash.CrashReporter; import com.foxx.androidcast.dev.DevAdbWifiKeeper; @@ -46,5 +47,6 @@ public class AndroidCastApplication extends Application { CommercialFeatures.refreshFromPreferences(this); RemoteAccessCoordinator.onBootIfNeeded(this); DevAdbWifiKeeper.ensureStarted(this); + CastTrayNotifier.ensureTrayService(this); } } diff --git a/app/src/main/java/com/foxx/androidcast/CastTrayContent.java b/app/src/main/java/com/foxx/androidcast/CastTrayContent.java new file mode 100644 index 0000000..4299903 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/CastTrayContent.java @@ -0,0 +1,92 @@ +package com.foxx.androidcast; + +import com.foxx.androidcast.remoteaccess.RemoteAccessMode; +import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore; + +import java.util.ArrayList; +import java.util.List; + +/** Pure text assembly for the single persistent tray notification. */ +public final class CastTrayContent { + public static final class Body { + public final String summary; + public final String bigText; + + public Body(String summary, String bigText) { + this.summary = summary != null ? summary : ""; + this.bigText = bigText != null ? bigText : ""; + } + } + + private CastTrayContent() {} + + public static Body build( + String idleTapToOpen, + String castActive, + String adbWaiting, + String adbLineFormat, + String raPollFormat, + String raConnected, + String raSessionFormat, + boolean casting, + boolean adbEnabled, + String adbConnectLine, + RemoteAccessMode raMode, + RemoteAccessStatusStore.Snapshot raSnap) { + if (casting) { + return new Body(castActive, castActive); + } + List lines = new ArrayList<>(); + String raLine = remoteAccessLine(raPollFormat, raConnected, raSessionFormat, raMode, raSnap); + if (raLine != null && !raLine.isEmpty()) { + lines.add(raLine); + } + if (adbEnabled) { + String adbLine = adbConnectLine == null || adbConnectLine.isEmpty() + ? adbWaiting + : String.format(adbLineFormat, adbConnectLine); + lines.add(adbLine); + } + if (!lines.isEmpty()) { + String joined = joinLines(lines); + return new Body(lines.get(0), joined); + } + return new Body(idleTapToOpen, idleTapToOpen); + } + + static String remoteAccessLine( + String pollFormat, + String connected, + String sessionFormat, + RemoteAccessMode mode, + RemoteAccessStatusStore.Snapshot snap) { + if (mode == null || !mode.isActive()) { + return null; + } + if (snap != null && RemoteAccessStatusStore.STATE_SUCCESS.equals(snap.state)) { + if (snap.sessionId != null && !snap.sessionId.isEmpty()) { + String line = String.format(sessionFormat, snap.sessionId); + if (mode == RemoteAccessMode.RSSH) { + return line + " (RSSH)"; + } + return line; + } + if (mode == RemoteAccessMode.RSSH) { + return connected + " (RSSH)"; + } + return connected; + } + return String.format(pollFormat, mode.name()); + } + + private static String joinLines(List lines) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines.size(); i++) { + if (i > 0) { + sb.append('\n'); + } + sb.append(lines.get(i)); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java b/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java index f399171..66e546d 100644 --- a/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java +++ b/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java @@ -1,16 +1,5 @@ package com.foxx.androidcast; -/********************************************************************* - * CastTrayNotifier.java - * Created at: Sun 17 May 2026 18:17:14 +0200 - * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu - * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 - * class CastTrayNotifier - * Contributors: - * - Anton Afanasyeu (3 commits, 156 lines) - * - Cursor Agent (project assistant) - * Digest: SHA256 292d7f278b9726120e113e803105641b313de93e2efa129dd3af428d1aa68033 -**********************************************************************/ import android.app.ForegroundServiceStartNotAllowedException; import android.app.Notification; import android.app.NotificationChannel; @@ -24,50 +13,69 @@ import android.util.Log; import androidx.core.app.NotificationCompat; +import com.foxx.androidcast.dev.DevAdbConnectInfo; +import com.foxx.androidcast.dev.DevAdbWifiKeeper; +import com.foxx.androidcast.remoteaccess.RemoteAccessMode; +import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore; + /** - * Persistent status-bar icon while cast services run (when enabled in settings). + * Single persistent status-bar notification for Android Cast (prod + dev modes). */ public final class CastTrayNotifier { private static final String TAG = "CastTrayNotifier"; public static final String CHANNEL_TRAY = "cast_tray"; public static final int ID_TRAY = 3; + /** Legacy per-feature notification ids — cancelled on every refresh. */ + public static final int LEGACY_DEV_ADB_ID = 50391; + public static final int LEGACY_RA_ID = 0x7a00; + public static final int LEGACY_RA_VPN_ID = 0x7a01; + private CastTrayNotifier() {} - /** Sync tray with cast service lifecycle (call from foreground services). */ + /** Sync tray content with app / dev service state. */ public static void refresh(Context context) { Context app = context.getApplicationContext(); - if (!AppPreferences.isShowTrayIcon(app)) { + cancelLegacyNotifications(app); + if (!shouldMaintainTray(app)) { + stopTrayService(app); hide(app); return; } ensureTrayService(app); + publish(app); } - /** - * Start idle tray when an activity is foreground (API 31+ blocks FGS from - * {@link android.app.Application#onCreate()}). - */ public static void syncWhenForeground(Context context) { - if (AppPreferences.isShowTrayIcon(context)) { + if (shouldMaintainTray(context)) { ensureTrayService(context); } } - /** User toggled the preference — show or hide immediately. */ public static void onPreferenceChanged(Context context) { - Context app = context.getApplicationContext(); - if (AppPreferences.isShowTrayIcon(app)) { - ensureTrayService(app); - } else { - stopTrayService(app); - hide(app); + refresh(context); + } + + public static boolean shouldMaintainTray(Context context) { + if (AppPreferences.isShowTrayIcon(context)) { + return true; } + return hasDevTrayContent(context); + } + + static boolean hasDevTrayContent(Context context) { + if (!BuildConfig.DEBUG) { + return false; + } + if (AppPreferences.isDevAdbWifiKeeperEnabled(context)) { + return true; + } + return AppPreferences.getDevRemoteAccessMode(context).isActive(); } public static void ensureTrayService(Context context) { Context app = context.getApplicationContext(); - if (!AppPreferences.isShowTrayIcon(app)) { + if (!shouldMaintainTray(app)) { return; } Intent intent = new Intent(app, TrayForegroundService.class); @@ -92,34 +100,60 @@ public final class CastTrayNotifier { context.getApplicationContext().stopService(new Intent(context, TrayForegroundService.class)); } - /** Foreground tray — not swipe-dismissible on stock Android. */ + /** Foreground tray owned by {@link TrayForegroundService}. */ public static void startForegroundTray(Service service) { - if (!AppPreferences.isShowTrayIcon(service)) { + if (!shouldMaintainTray(service)) { service.stopSelf(); return; } createChannel(service); - boolean idle = !CastActiveState.isSenderCasting() && !CastActiveState.isReceiverListening(); - Notification n = buildNotification(service, idle); + Notification n = buildNotification(service); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - service.startForeground(ID_TRAY, n, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); + service.startForeground( + ID_TRAY, + n, + android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); } else { service.startForeground(ID_TRAY, n); } } - private static void show(Context context, boolean idle) { - if (AppPreferences.isShowTrayIcon(context)) { - ensureTrayService(context); - return; + /** Other foreground services reuse the same tray notification id. */ + public static void startForeground(Service service, int fgsType) { + createChannel(service); + Notification n = buildNotification(service); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && fgsType != 0) { + service.startForeground(ID_TRAY, n, fgsType); + } else { + service.startForeground(ID_TRAY, n); } - hide(context); } - private static Notification buildNotification(Context context, boolean idle) { - String body = idle - ? context.getString(R.string.tray_cast_idle) - : context.getString(R.string.tray_cast_active); + public static Notification buildNotification(Context context) { + createChannel(context); + boolean casting = CastActiveState.isSenderCasting() || CastActiveState.isReceiverListening(); + boolean adbEnabled = BuildConfig.DEBUG && AppPreferences.isDevAdbWifiKeeperEnabled(context); + RemoteAccessMode raMode = BuildConfig.DEBUG + ? AppPreferences.getDevRemoteAccessMode(context) + : RemoteAccessMode.DISABLED; + RemoteAccessStatusStore.Snapshot raSnap = RemoteAccessStatusStore.load(context); + DevAdbConnectInfo adbInfo = adbEnabled ? DevAdbWifiKeeper.getLastInfo(context) : null; + String adbLine = adbInfo != null ? adbInfo.primaryConnectLine() : ""; + + CastTrayContent.Body body = CastTrayContent.build( + context.getString(R.string.tray_cast_idle), + context.getString(R.string.tray_cast_active), + context.getString(R.string.dev_adb_wifi_notification_waiting), + context.getString(R.string.dev_adb_wifi_notification_line), + context.getString(R.string.dev_remote_access_notification_poll), + context.getString(R.string.dev_remote_access_notification_connected), + context.getString(R.string.dev_remote_access_notification_session), + casting, + adbEnabled, + adbLine, + raMode, + raSnap); + PendingIntent pi = PendingIntent.getActivity( context, 0, CastActivityTracker.resumeIntent(context), PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); @@ -128,9 +162,10 @@ public final class CastTrayNotifier { PendingIntent deletePi = PendingIntent.getBroadcast( context, 1, deleted, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - return new NotificationCompat.Builder(context, CHANNEL_TRAY) + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_TRAY) .setContentTitle(context.getString(R.string.app_name)) - .setContentText(body) + .setContentText(body.summary) .setSmallIcon(R.drawable.ic_notification) .setContentIntent(pi) .setDeleteIntent(deletePi) @@ -142,8 +177,31 @@ public final class CastTrayNotifier { .setPriority(NotificationCompat.PRIORITY_LOW) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .build(); + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE); + if (!body.bigText.equals(body.summary)) { + builder.setStyle(new NotificationCompat.BigTextStyle().bigText(body.bigText)); + } + return builder.build(); + } + + static void publish(Context context) { + if (!shouldMaintainTray(context)) { + return; + } + NotificationManager nm = context.getSystemService(NotificationManager.class); + if (nm != null) { + nm.notify(ID_TRAY, buildNotification(context)); + } + } + + static void cancelLegacyNotifications(Context context) { + NotificationManager nm = context.getSystemService(NotificationManager.class); + if (nm == null) { + return; + } + nm.cancel(LEGACY_DEV_ADB_ID); + nm.cancel(LEGACY_RA_ID); + nm.cancel(LEGACY_RA_VPN_ID); } private static void hide(Context context) { diff --git a/app/src/main/java/com/foxx/androidcast/TrayForegroundService.java b/app/src/main/java/com/foxx/androidcast/TrayForegroundService.java index 3f8952d..65a60c3 100644 --- a/app/src/main/java/com/foxx/androidcast/TrayForegroundService.java +++ b/app/src/main/java/com/foxx/androidcast/TrayForegroundService.java @@ -1,19 +1,10 @@ package com.foxx.androidcast; -/********************************************************************* - * TrayForegroundService.java - * Created at: Sun 17 May 2026 22:49:49 +0200 - * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu - * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 - * class TrayForegroundService - * Contributors: - * - Anton Afanasyeu (2 commits, 48 lines) - * - Cursor Agent (project assistant) - * Digest: SHA256 5697518674c876a08eb78c08ca151686a079c96d2d3dc238c32666d83b5e88b8 -**********************************************************************/ import android.app.Service; import android.content.Intent; +import android.os.Handler; import android.os.IBinder; +import android.os.Looper; import androidx.annotation.Nullable; @@ -22,15 +13,31 @@ import androidx.annotation.Nullable; * (non-FGS status notifications are dismissible on Android 13+). */ public class TrayForegroundService extends Service { + private static final long HEARTBEAT_MS = 90_000L; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable heartbeat = new Runnable() { + @Override + public void run() { + if (!CastTrayNotifier.shouldMaintainTray(TrayForegroundService.this)) { + stopSelf(); + return; + } + CastTrayNotifier.startForegroundTray(TrayForegroundService.this); + handler.postDelayed(this, HEARTBEAT_MS); + } + }; + @Override public void onCreate() { super.onCreate(); CastTrayNotifier.startForegroundTray(this); + handler.postDelayed(heartbeat, HEARTBEAT_MS); } @Override public int onStartCommand(Intent intent, int flags, int startId) { - if (!AppPreferences.isShowTrayIcon(this)) { + if (!CastTrayNotifier.shouldMaintainTray(this)) { stopSelf(); return START_NOT_STICKY; } @@ -40,6 +47,7 @@ public class TrayForegroundService extends Service { @Override public void onDestroy() { + handler.removeCallbacks(heartbeat); stopForeground(STOP_FOREGROUND_REMOVE); super.onDestroy(); } diff --git a/app/src/main/java/com/foxx/androidcast/TrayNotificationReceiver.java b/app/src/main/java/com/foxx/androidcast/TrayNotificationReceiver.java index 33b4ee6..7ecd6e9 100644 --- a/app/src/main/java/com/foxx/androidcast/TrayNotificationReceiver.java +++ b/app/src/main/java/com/foxx/androidcast/TrayNotificationReceiver.java @@ -25,6 +25,6 @@ public class TrayNotificationReceiver extends BroadcastReceiver { if (!AppPreferences.isShowTrayIcon(context)) { return; } - CastTrayNotifier.ensureTrayService(context); + CastTrayNotifier.refresh(context); } } diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiKeeper.java b/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiKeeper.java index d6e5054..488f773 100644 --- a/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiKeeper.java +++ b/app/src/main/java/com/foxx/androidcast/dev/DevAdbWifiKeeper.java @@ -1,28 +1,19 @@ package com.foxx.androidcast.dev; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; -import android.content.Intent; import android.net.ConnectivityManager; import android.net.Network; import android.net.NetworkCapabilities; import android.net.NetworkRequest; -import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.Log; -import androidx.core.app.NotificationCompat; - import com.foxx.androidcast.AppPreferences; import com.foxx.androidcast.BuildConfig; -import com.foxx.androidcast.DeveloperSettingsActivity; -import com.foxx.androidcast.R; +import com.foxx.androidcast.CastTrayNotifier; import java.io.File; import java.io.FileOutputStream; @@ -33,9 +24,6 @@ import java.util.concurrent.Executors; /** Debug-only: re-enable adb wifi on network changes and publish connect info. */ public final class DevAdbWifiKeeper { - private static final String CHANNEL_ID = "dev_adb_wifi"; - private static final int NOTIFICATION_ID = 50391; - private static final ExecutorService WORKER = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "DevAdbWifiKeeper"); t.setDaemon(true); @@ -146,7 +134,6 @@ public final class DevAdbWifiKeeper { } private void start() { - ensureNotificationChannel(); loadCachedJsonIntoHttpServer(); httpServer.start(); registerNetworkCallback(); @@ -172,11 +159,7 @@ public final class DevAdbWifiKeeper { private void shutdown() { unregisterNetworkCallback(); httpServer.stop(); - NotificationManager nm = - (NotificationManager) appContext.getSystemService(Context.NOTIFICATION_SERVICE); - if (nm != null) { - nm.cancel(NOTIFICATION_ID); - } + CastTrayNotifier.refresh(appContext); } private void registerNetworkCallback() { @@ -224,7 +207,7 @@ public final class DevAdbWifiKeeper { Log.d(DevAdbConnectInfo.LOG_TAG, "refresh reason=" + reason + " secure=" + info.secureSettingsGranted + " connect=" + info.connect); - MAIN.post(() -> updateNotification(info)); + MAIN.post(() -> CastTrayNotifier.refresh(appContext)); } private void writeJsonFile(String json) { @@ -239,53 +222,4 @@ public final class DevAdbWifiKeeper { Log.w(DevAdbConnectInfo.LOG_TAG, "write adb_connect.json failed", e); } } - - private void ensureNotificationChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return; - } - NotificationManager nm = - (NotificationManager) appContext.getSystemService(Context.NOTIFICATION_SERVICE); - if (nm == null) { - return; - } - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, - appContext.getString(R.string.dev_adb_wifi_notification_channel), - NotificationManager.IMPORTANCE_LOW); - channel.setDescription(appContext.getString(R.string.dev_adb_wifi_notification_channel_desc)); - nm.createNotificationChannel(channel); - } - - private void updateNotification(DevAdbConnectInfo info) { - if (!AppPreferences.isDevAdbWifiKeeperEnabled(appContext)) { - return; - } - NotificationManager nm = - (NotificationManager) appContext.getSystemService(Context.NOTIFICATION_SERVICE); - if (nm == null) { - return; - } - String line = info.primaryConnectLine(); - String text = line.isEmpty() - ? appContext.getString(R.string.dev_adb_wifi_notification_waiting) - : appContext.getString(R.string.dev_adb_wifi_notification_line, line); - Intent openDev = new Intent(appContext, DeveloperSettingsActivity.class); - openDev.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - PendingIntent content = PendingIntent.getActivity( - appContext, - 0, - openDev, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - Notification notification = new NotificationCompat.Builder(appContext, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher_foreground) - .setContentTitle(appContext.getString(R.string.dev_adb_wifi_notification_title)) - .setContentText(text) - .setStyle(new NotificationCompat.BigTextStyle().bigText(text)) - .setContentIntent(content) - .setOngoing(true) - .setOnlyAlertOnce(true) - .build(); - nm.notify(NOTIFICATION_ID, notification); - } } diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java index 4e4e920..98cdc45 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java @@ -1,9 +1,6 @@ package com.foxx.androidcast.remoteaccess; import android.app.AlarmManager; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; @@ -13,12 +10,9 @@ import android.os.IBinder; import android.os.SystemClock; import android.util.Log; -import androidx.core.app.NotificationCompat; - import com.foxx.androidcast.AppPreferences; -import com.foxx.androidcast.DeveloperSettingsActivity; +import com.foxx.androidcast.CastTrayNotifier; import com.foxx.androidcast.DeviceInfo; -import com.foxx.androidcast.R; import com.foxx.androidcast.remoteaccess.control.RemoteAccessControlClient; import com.foxx.androidcast.vpn.VpnServiceClient; @@ -44,8 +38,6 @@ public final class RemoteAccessService extends Service { public static final String ACTION_DISABLE_ONCE = "com.foxx.androidcast.remoteaccess.DISABLE_ONCE"; public static final String EXTRA_MODE = "mode"; - private static final int NOTIF_ID = 0x7a00; - private static final String CHANNEL_ID = "remote_access"; private static final int MIN_POLL_MS = 60_000; private static final int MAX_POLL_MS = 420_000; @@ -102,8 +94,9 @@ public final class RemoteAccessService extends Service { if (ACTION_STOP.equals(action)) { cancelPoll(this); RemoteAccessTunnelHelper.tearDownAll(this); - stopForeground(true); + stopForeground(STOP_FOREGROUND_REMOVE); stopSelf(); + CastTrayNotifier.refresh(this); return START_NOT_STICKY; } if (ACTION_DISABLE_ONCE.equals(action)) { @@ -118,8 +111,11 @@ public final class RemoteAccessService extends Service { stop(this); return START_NOT_STICKY; } - ensureChannel(); - startForeground(NOTIF_ID, buildNotification(mode)); + int fgsType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + : 0; + CastTrayNotifier.startForeground(this, fgsType); + CastTrayNotifier.ensureTrayService(this); loadOrCreateSessionRandom(); checkSessionExpiry(); final RemoteAccessMode pollMode = mode; @@ -309,22 +305,7 @@ public final class RemoteAccessService extends Service { } private void updateNotificationConnected(String sessionId, String tunnel) { - NotificationManager nm = getSystemService(NotificationManager.class); - if (nm == null) { - return; - } - String text = sessionId.isEmpty() - ? getString(R.string.dev_remote_access_notification_connected) - : getString(R.string.dev_remote_access_notification_session, sessionId); - if ("ssh_reverse".equals(tunnel)) { - text = text + " (RSSH)"; - } - nm.notify(NOTIF_ID, new NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(getString(R.string.dev_remote_access_notification_title)) - .setContentText(text) - .setOngoing(true) - .build()); + CastTrayNotifier.refresh(this); } private void scheduleNextPoll() { @@ -355,33 +336,6 @@ public final class RemoteAccessService extends Service { am.cancel(pi); } - private Notification buildNotification(RemoteAccessMode mode) { - Intent open = new Intent(this, DeveloperSettingsActivity.class); - PendingIntent pi = PendingIntent.getActivity(this, 0, open, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - return new NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(getString(R.string.dev_remote_access_notification_title)) - .setContentText(getString(R.string.dev_remote_access_notification_poll, mode.name())) - .setContentIntent(pi) - .setOngoing(true) - .build(); - } - - private void ensureChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return; - } - NotificationManager nm = getSystemService(NotificationManager.class); - if (nm == null) { - return; - } - NotificationChannel ch = new NotificationChannel(CHANNEL_ID, - getString(R.string.dev_remote_access_notification_channel), - NotificationManager.IMPORTANCE_LOW); - nm.createNotificationChannel(ch); - } - private static JSONObject buildDeviceMeta(Context context) throws Exception { JSONObject device = new JSONObject(); device.put("name", AppPreferences.getUsername(context)); diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessStatusStore.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessStatusStore.java index 0d2670e..733158e 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessStatusStore.java +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessStatusStore.java @@ -3,6 +3,8 @@ package com.foxx.androidcast.remoteaccess; import android.content.Context; import android.content.SharedPreferences; +import com.foxx.androidcast.CastTrayNotifier; + /** * Last remote-access / VPN tunnel event for developer network self-test. */ @@ -63,6 +65,7 @@ public final class RemoteAccessStatusStore { .putString(KEY_SESSION_ID, sessionId != null ? sessionId : "") .putLong(KEY_UPDATED_MS, System.currentTimeMillis()) .apply(); + CastTrayNotifier.refresh(context.getApplicationContext()); } public static Snapshot load(Context context) { @@ -92,7 +95,7 @@ public final class RemoteAccessStatusStore { public final String sessionId; public final long updatedMs; - Snapshot( + public Snapshot( String type, String state, String method, diff --git a/app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java b/app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java index f538f79..de85694 100644 --- a/app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java +++ b/app/src/main/java/com/foxx/androidcast/vpn/RemoteAccessVpnService.java @@ -1,22 +1,15 @@ package com.foxx.androidcast.vpn; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; import android.net.VpnService; import android.os.Build; import android.os.ParcelFileDescriptor; import android.util.Log; -import androidx.core.app.NotificationCompat; - -import com.foxx.androidcast.DeveloperSettingsActivity; -import com.foxx.androidcast.R; +import com.foxx.androidcast.CastTrayNotifier; import com.foxx.androidcast.remoteaccess.WireGuardQuickConfig; import java.net.InetAddress; @@ -28,9 +21,6 @@ public final class RemoteAccessVpnService extends VpnService { public static final String ACTION_DOWN = "com.foxx.androidcast.vpn.VPN_DOWN"; public static final String EXTRA_WG_CONFIG = "wg_config"; - private static final int NOTIF_ID = 0x7a01; - private static final String CHANNEL_ID = "remote_access_vpn"; - private ParcelFileDescriptor tun; public static boolean startWithConfig(Context context, String wgConfig) { @@ -61,14 +51,18 @@ public final class RemoteAccessVpnService extends VpnService { String action = intent != null ? intent.getAction() : null; if (ACTION_DOWN.equals(action)) { tearDown(); - stopForeground(true); + stopForeground(STOP_FOREGROUND_REMOVE); stopSelf(); + CastTrayNotifier.refresh(this); return START_NOT_STICKY; } if (ACTION_UP.equals(action)) { String cfg = intent.getStringExtra(EXTRA_WG_CONFIG); - ensureChannel(); - startForeground(NOTIF_ID, buildNotification("WireGuard remote access")); + int fgsType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + : 0; + CastTrayNotifier.startForeground(this, fgsType); + CastTrayNotifier.ensureTrayService(this); establishTun(cfg); return START_STICKY; } @@ -102,6 +96,7 @@ public final class RemoteAccessVpnService extends VpnService { b.setMtu(1280); tun = b.establish(); Log.i(TAG, "VPN interface established"); + CastTrayNotifier.refresh(this); } catch (Exception e) { Log.e(TAG, "VPN establish failed", e); } @@ -117,33 +112,6 @@ public final class RemoteAccessVpnService extends VpnService { } } - private Notification buildNotification(String text) { - Intent open = new Intent(this, DeveloperSettingsActivity.class); - PendingIntent pi = PendingIntent.getActivity(this, 0, open, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - return new NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(getString(R.string.dev_remote_access_notification_title)) - .setContentText(text) - .setContentIntent(pi) - .setOngoing(true) - .build(); - } - - private void ensureChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return; - } - NotificationManager nm = getSystemService(NotificationManager.class); - if (nm == null) { - return; - } - NotificationChannel ch = new NotificationChannel(CHANNEL_ID, - getString(R.string.dev_remote_access_notification_channel), - NotificationManager.IMPORTANCE_LOW); - nm.createNotificationChannel(ch); - } - @Override public void onDestroy() { tearDown(); diff --git a/app/src/test/java/com/foxx/androidcast/CastTrayContentTest.java b/app/src/test/java/com/foxx/androidcast/CastTrayContentTest.java new file mode 100644 index 0000000..3ebcebe --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/CastTrayContentTest.java @@ -0,0 +1,89 @@ +package com.foxx.androidcast; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.foxx.androidcast.remoteaccess.RemoteAccessMode; +import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore; + +import org.junit.Test; + +public class CastTrayContentTest { + + @Test + public void prodIdle_showsTapToOpen() { + CastTrayContent.Body body = CastTrayContent.build( + "Tap to open", + "Cast active", + "Waiting for Wi‑Fi", + "adb connect %1$s", + "Polling BE (%1$s)", + "Connected", + "Session %1$s", + false, + false, + "", + RemoteAccessMode.DISABLED, + null); + assertEquals("Tap to open", body.summary); + assertEquals("Tap to open", body.bigText); + } + + @Test + public void casting_overridesDevLines() { + CastTrayContent.Body body = CastTrayContent.build( + "Tap to open", + "Cast active", + "Waiting", + "adb %1$s", + "Poll %1$s", + "Connected", + "Session %1$s", + true, + true, + "192.168.1.1:5555", + RemoteAccessMode.RSSH, + snapshot(RemoteAccessStatusStore.STATE_WAIT, "")); + assertEquals("Cast active", body.summary); + } + + @Test + public void devMode_combinesRaAndAdbLines() { + RemoteAccessStatusStore.Snapshot snap = + snapshot(RemoteAccessStatusStore.STATE_WAIT, ""); + CastTrayContent.Body body = CastTrayContent.build( + "Tap to open", + "Cast active", + "Waiting for Wi‑Fi", + "adb connect %1$s", + "Polling BE (%1$s)", + "Connected", + "Session %1$s", + false, + true, + "10.0.0.5:5555", + RemoteAccessMode.RSSH, + snap); + assertEquals("Polling BE (RSSH)", body.summary); + assertTrue(body.bigText.contains("Polling BE (RSSH)")); + assertTrue(body.bigText.contains("adb connect 10.0.0.5:5555")); + } + + @Test + public void remoteAccess_connectedSession_appendsRsshTag() { + RemoteAccessStatusStore.Snapshot snap = + snapshot(RemoteAccessStatusStore.STATE_SUCCESS, "abc-123"); + String line = CastTrayContent.remoteAccessLine( + "Polling BE (%1$s)", + "Connected", + "Connected · session %1$s", + RemoteAccessMode.RSSH, + snap); + assertEquals("Connected · session abc-123 (RSSH)", line); + } + + private static RemoteAccessStatusStore.Snapshot snapshot(String state, String sessionId) { + return new RemoteAccessStatusStore.Snapshot( + "rssh", state, "", "", "", "", sessionId, 0L); + } +}