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

tray bar rework

This commit is contained in:
Anton Afanasyeu
2026-06-15 16:31:16 +02:00
parent 066e21a3f2
commit 26ef8f5399
10 changed files with 333 additions and 225 deletions

View File

@@ -14,6 +14,7 @@ package com.foxx.androidcast;
import android.app.Application; import android.app.Application;
import android.content.Context; import android.content.Context;
import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.commercial.CommercialFeatures; import com.foxx.androidcast.commercial.CommercialFeatures;
import com.foxx.androidcast.crash.CrashReporter; import com.foxx.androidcast.crash.CrashReporter;
import com.foxx.androidcast.dev.DevAdbWifiKeeper; import com.foxx.androidcast.dev.DevAdbWifiKeeper;
@@ -46,5 +47,6 @@ public class AndroidCastApplication extends Application {
CommercialFeatures.refreshFromPreferences(this); CommercialFeatures.refreshFromPreferences(this);
RemoteAccessCoordinator.onBootIfNeeded(this); RemoteAccessCoordinator.onBootIfNeeded(this);
DevAdbWifiKeeper.ensureStarted(this); DevAdbWifiKeeper.ensureStarted(this);
CastTrayNotifier.ensureTrayService(this);
} }
} }

View File

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

View File

@@ -1,16 +1,5 @@
package com.foxx.androidcast; 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 <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* class CastTrayNotifier
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (3 commits, 156 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 292d7f278b9726120e113e803105641b313de93e2efa129dd3af428d1aa68033
**********************************************************************/
import android.app.ForegroundServiceStartNotAllowedException; import android.app.ForegroundServiceStartNotAllowedException;
import android.app.Notification; import android.app.Notification;
import android.app.NotificationChannel; import android.app.NotificationChannel;
@@ -24,50 +13,69 @@ import android.util.Log;
import androidx.core.app.NotificationCompat; 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 { public final class CastTrayNotifier {
private static final String TAG = "CastTrayNotifier"; private static final String TAG = "CastTrayNotifier";
public static final String CHANNEL_TRAY = "cast_tray"; public static final String CHANNEL_TRAY = "cast_tray";
public static final int ID_TRAY = 3; 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() {} 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) { public static void refresh(Context context) {
Context app = context.getApplicationContext(); Context app = context.getApplicationContext();
if (!AppPreferences.isShowTrayIcon(app)) { cancelLegacyNotifications(app);
if (!shouldMaintainTray(app)) {
stopTrayService(app);
hide(app); hide(app);
return; return;
} }
ensureTrayService(app); 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) { public static void syncWhenForeground(Context context) {
if (AppPreferences.isShowTrayIcon(context)) { if (shouldMaintainTray(context)) {
ensureTrayService(context); ensureTrayService(context);
} }
} }
/** User toggled the preference — show or hide immediately. */
public static void onPreferenceChanged(Context context) { public static void onPreferenceChanged(Context context) {
Context app = context.getApplicationContext(); refresh(context);
if (AppPreferences.isShowTrayIcon(app)) { }
ensureTrayService(app);
} else { public static boolean shouldMaintainTray(Context context) {
stopTrayService(app); if (AppPreferences.isShowTrayIcon(context)) {
hide(app); 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) { public static void ensureTrayService(Context context) {
Context app = context.getApplicationContext(); Context app = context.getApplicationContext();
if (!AppPreferences.isShowTrayIcon(app)) { if (!shouldMaintainTray(app)) {
return; return;
} }
Intent intent = new Intent(app, TrayForegroundService.class); Intent intent = new Intent(app, TrayForegroundService.class);
@@ -92,34 +100,60 @@ public final class CastTrayNotifier {
context.getApplicationContext().stopService(new Intent(context, TrayForegroundService.class)); 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) { public static void startForegroundTray(Service service) {
if (!AppPreferences.isShowTrayIcon(service)) { if (!shouldMaintainTray(service)) {
service.stopSelf(); service.stopSelf();
return; return;
} }
createChannel(service); createChannel(service);
boolean idle = !CastActiveState.isSenderCasting() && !CastActiveState.isReceiverListening(); Notification n = buildNotification(service);
Notification n = buildNotification(service, idle);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 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 { } else {
service.startForeground(ID_TRAY, n); service.startForeground(ID_TRAY, n);
} }
} }
private static void show(Context context, boolean idle) { /** Other foreground services reuse the same tray notification id. */
if (AppPreferences.isShowTrayIcon(context)) { public static void startForeground(Service service, int fgsType) {
ensureTrayService(context); createChannel(service);
return; 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) { public static Notification buildNotification(Context context) {
String body = idle createChannel(context);
? context.getString(R.string.tray_cast_idle) boolean casting = CastActiveState.isSenderCasting() || CastActiveState.isReceiverListening();
: context.getString(R.string.tray_cast_active); 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( PendingIntent pi = PendingIntent.getActivity(
context, 0, CastActivityTracker.resumeIntent(context), context, 0, CastActivityTracker.resumeIntent(context),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
@@ -128,9 +162,10 @@ public final class CastTrayNotifier {
PendingIntent deletePi = PendingIntent.getBroadcast( PendingIntent deletePi = PendingIntent.getBroadcast(
context, 1, deleted, context, 1, deleted,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); 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)) .setContentTitle(context.getString(R.string.app_name))
.setContentText(body) .setContentText(body.summary)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pi) .setContentIntent(pi)
.setDeleteIntent(deletePi) .setDeleteIntent(deletePi)
@@ -142,8 +177,31 @@ public final class CastTrayNotifier {
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE);
.build(); 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) { private static void hide(Context context) {

View File

@@ -1,19 +1,10 @@
package com.foxx.androidcast; 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 <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* class TrayForegroundService
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 48 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 5697518674c876a08eb78c08ca151686a079c96d2d3dc238c32666d83b5e88b8
**********************************************************************/
import android.app.Service; import android.app.Service;
import android.content.Intent; import android.content.Intent;
import android.os.Handler;
import android.os.IBinder; import android.os.IBinder;
import android.os.Looper;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@@ -22,15 +13,31 @@ import androidx.annotation.Nullable;
* (non-FGS status notifications are dismissible on Android 13+). * (non-FGS status notifications are dismissible on Android 13+).
*/ */
public class TrayForegroundService extends Service { 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 @Override
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
CastTrayNotifier.startForegroundTray(this); CastTrayNotifier.startForegroundTray(this);
handler.postDelayed(heartbeat, HEARTBEAT_MS);
} }
@Override @Override
public int onStartCommand(Intent intent, int flags, int startId) { public int onStartCommand(Intent intent, int flags, int startId) {
if (!AppPreferences.isShowTrayIcon(this)) { if (!CastTrayNotifier.shouldMaintainTray(this)) {
stopSelf(); stopSelf();
return START_NOT_STICKY; return START_NOT_STICKY;
} }
@@ -40,6 +47,7 @@ public class TrayForegroundService extends Service {
@Override @Override
public void onDestroy() { public void onDestroy() {
handler.removeCallbacks(heartbeat);
stopForeground(STOP_FOREGROUND_REMOVE); stopForeground(STOP_FOREGROUND_REMOVE);
super.onDestroy(); super.onDestroy();
} }

View File

@@ -25,6 +25,6 @@ public class TrayNotificationReceiver extends BroadcastReceiver {
if (!AppPreferences.isShowTrayIcon(context)) { if (!AppPreferences.isShowTrayIcon(context)) {
return; return;
} }
CastTrayNotifier.ensureTrayService(context); CastTrayNotifier.refresh(context);
} }
} }

View File

@@ -1,28 +1,19 @@
package com.foxx.androidcast.dev; 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.ClipData;
import android.content.ClipboardManager; import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.net.ConnectivityManager; import android.net.ConnectivityManager;
import android.net.Network; import android.net.Network;
import android.net.NetworkCapabilities; import android.net.NetworkCapabilities;
import android.net.NetworkRequest; import android.net.NetworkRequest;
import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.foxx.androidcast.AppPreferences; import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.BuildConfig; import com.foxx.androidcast.BuildConfig;
import com.foxx.androidcast.DeveloperSettingsActivity; import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.R;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; 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. */ /** Debug-only: re-enable adb wifi on network changes and publish connect info. */
public final class DevAdbWifiKeeper { 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 -> { private static final ExecutorService WORKER = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "DevAdbWifiKeeper"); Thread t = new Thread(r, "DevAdbWifiKeeper");
t.setDaemon(true); t.setDaemon(true);
@@ -146,7 +134,6 @@ public final class DevAdbWifiKeeper {
} }
private void start() { private void start() {
ensureNotificationChannel();
loadCachedJsonIntoHttpServer(); loadCachedJsonIntoHttpServer();
httpServer.start(); httpServer.start();
registerNetworkCallback(); registerNetworkCallback();
@@ -172,11 +159,7 @@ public final class DevAdbWifiKeeper {
private void shutdown() { private void shutdown() {
unregisterNetworkCallback(); unregisterNetworkCallback();
httpServer.stop(); httpServer.stop();
NotificationManager nm = CastTrayNotifier.refresh(appContext);
(NotificationManager) appContext.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm != null) {
nm.cancel(NOTIFICATION_ID);
}
} }
private void registerNetworkCallback() { private void registerNetworkCallback() {
@@ -224,7 +207,7 @@ public final class DevAdbWifiKeeper {
Log.d(DevAdbConnectInfo.LOG_TAG, "refresh reason=" + reason Log.d(DevAdbConnectInfo.LOG_TAG, "refresh reason=" + reason
+ " secure=" + info.secureSettingsGranted + " secure=" + info.secureSettingsGranted
+ " connect=" + info.connect); + " connect=" + info.connect);
MAIN.post(() -> updateNotification(info)); MAIN.post(() -> CastTrayNotifier.refresh(appContext));
} }
private void writeJsonFile(String json) { private void writeJsonFile(String json) {
@@ -239,53 +222,4 @@ public final class DevAdbWifiKeeper {
Log.w(DevAdbConnectInfo.LOG_TAG, "write adb_connect.json failed", e); 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);
}
} }

View File

@@ -1,9 +1,6 @@
package com.foxx.androidcast.remoteaccess; package com.foxx.androidcast.remoteaccess;
import android.app.AlarmManager; import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.app.Service; import android.app.Service;
import android.content.Context; import android.content.Context;
@@ -13,12 +10,9 @@ import android.os.IBinder;
import android.os.SystemClock; import android.os.SystemClock;
import android.util.Log; import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.foxx.androidcast.AppPreferences; import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.DeveloperSettingsActivity; import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.DeviceInfo; import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.R;
import com.foxx.androidcast.remoteaccess.control.RemoteAccessControlClient; import com.foxx.androidcast.remoteaccess.control.RemoteAccessControlClient;
import com.foxx.androidcast.vpn.VpnServiceClient; 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 ACTION_DISABLE_ONCE = "com.foxx.androidcast.remoteaccess.DISABLE_ONCE";
public static final String EXTRA_MODE = "mode"; 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 MIN_POLL_MS = 60_000;
private static final int MAX_POLL_MS = 420_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)) { if (ACTION_STOP.equals(action)) {
cancelPoll(this); cancelPoll(this);
RemoteAccessTunnelHelper.tearDownAll(this); RemoteAccessTunnelHelper.tearDownAll(this);
stopForeground(true); stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf(); stopSelf();
CastTrayNotifier.refresh(this);
return START_NOT_STICKY; return START_NOT_STICKY;
} }
if (ACTION_DISABLE_ONCE.equals(action)) { if (ACTION_DISABLE_ONCE.equals(action)) {
@@ -118,8 +111,11 @@ public final class RemoteAccessService extends Service {
stop(this); stop(this);
return START_NOT_STICKY; return START_NOT_STICKY;
} }
ensureChannel(); int fgsType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
startForeground(NOTIF_ID, buildNotification(mode)); ? android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
: 0;
CastTrayNotifier.startForeground(this, fgsType);
CastTrayNotifier.ensureTrayService(this);
loadOrCreateSessionRandom(); loadOrCreateSessionRandom();
checkSessionExpiry(); checkSessionExpiry();
final RemoteAccessMode pollMode = mode; final RemoteAccessMode pollMode = mode;
@@ -309,22 +305,7 @@ public final class RemoteAccessService extends Service {
} }
private void updateNotificationConnected(String sessionId, String tunnel) { private void updateNotificationConnected(String sessionId, String tunnel) {
NotificationManager nm = getSystemService(NotificationManager.class); CastTrayNotifier.refresh(this);
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());
} }
private void scheduleNextPoll() { private void scheduleNextPoll() {
@@ -355,33 +336,6 @@ public final class RemoteAccessService extends Service {
am.cancel(pi); 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 { private static JSONObject buildDeviceMeta(Context context) throws Exception {
JSONObject device = new JSONObject(); JSONObject device = new JSONObject();
device.put("name", AppPreferences.getUsername(context)); device.put("name", AppPreferences.getUsername(context));

View File

@@ -3,6 +3,8 @@ package com.foxx.androidcast.remoteaccess;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import com.foxx.androidcast.CastTrayNotifier;
/** /**
* Last remote-access / VPN tunnel event for developer network self-test. * 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 : "") .putString(KEY_SESSION_ID, sessionId != null ? sessionId : "")
.putLong(KEY_UPDATED_MS, System.currentTimeMillis()) .putLong(KEY_UPDATED_MS, System.currentTimeMillis())
.apply(); .apply();
CastTrayNotifier.refresh(context.getApplicationContext());
} }
public static Snapshot load(Context context) { public static Snapshot load(Context context) {
@@ -92,7 +95,7 @@ public final class RemoteAccessStatusStore {
public final String sessionId; public final String sessionId;
public final long updatedMs; public final long updatedMs;
Snapshot( public Snapshot(
String type, String type,
String state, String state,
String method, String method,

View File

@@ -1,22 +1,15 @@
package com.foxx.androidcast.vpn; 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.Context;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.pm.ServiceInfo;
import android.net.VpnService; import android.net.VpnService;
import android.os.Build; import android.os.Build;
import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor;
import android.util.Log; import android.util.Log;
import androidx.core.app.NotificationCompat; import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.DeveloperSettingsActivity;
import com.foxx.androidcast.R;
import com.foxx.androidcast.remoteaccess.WireGuardQuickConfig; import com.foxx.androidcast.remoteaccess.WireGuardQuickConfig;
import java.net.InetAddress; 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 ACTION_DOWN = "com.foxx.androidcast.vpn.VPN_DOWN";
public static final String EXTRA_WG_CONFIG = "wg_config"; 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; private ParcelFileDescriptor tun;
public static boolean startWithConfig(Context context, String wgConfig) { 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; String action = intent != null ? intent.getAction() : null;
if (ACTION_DOWN.equals(action)) { if (ACTION_DOWN.equals(action)) {
tearDown(); tearDown();
stopForeground(true); stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf(); stopSelf();
CastTrayNotifier.refresh(this);
return START_NOT_STICKY; return START_NOT_STICKY;
} }
if (ACTION_UP.equals(action)) { if (ACTION_UP.equals(action)) {
String cfg = intent.getStringExtra(EXTRA_WG_CONFIG); String cfg = intent.getStringExtra(EXTRA_WG_CONFIG);
ensureChannel(); int fgsType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
startForeground(NOTIF_ID, buildNotification("WireGuard remote access")); ? ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
: 0;
CastTrayNotifier.startForeground(this, fgsType);
CastTrayNotifier.ensureTrayService(this);
establishTun(cfg); establishTun(cfg);
return START_STICKY; return START_STICKY;
} }
@@ -102,6 +96,7 @@ public final class RemoteAccessVpnService extends VpnService {
b.setMtu(1280); b.setMtu(1280);
tun = b.establish(); tun = b.establish();
Log.i(TAG, "VPN interface established"); Log.i(TAG, "VPN interface established");
CastTrayNotifier.refresh(this);
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "VPN establish failed", 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 @Override
public void onDestroy() { public void onDestroy() {
tearDown(); tearDown();

View File

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