Rename Mew to Ditto across entire codebase

- Update all branding: titles, meta tags, OG tags, manifest
- Rename MewLogo component to DittoLogo
- Update Capacitor config: appId to pub.ditto.app, hostname to ditto.pub
- Migrate Android Java package from com.mew.app to pub.ditto.app
- Rename MewNotificationPlugin to DittoNotificationPlugin
- Update all localStorage keys from mew: to ditto: prefix
- Update Nostr encrypted settings d-tag from mew-metadata to ditto-metadata
- Update all page titles, zap comments, and UI strings
- Rename ic_stat_mew.xml drawable to ic_stat_ditto.xml
- Update build script references (mew.apk -> ditto.apk)
- Update all domain references (mew.shakespeare.wtf -> ditto.pub)
This commit is contained in:
Alex Gleason
2026-02-23 11:50:00 -06:00
parent 0d48221476
commit 1f035f4a6e
44 changed files with 130 additions and 151 deletions
@@ -0,0 +1,54 @@
package pub.ditto.app;
import android.content.SharedPreferences;
import android.content.Context;
import android.util.Log;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONArray;
/**
* Capacitor plugin that allows the JS layer to configure the native
* notification polling service with the user's pubkey and relay URLs.
*/
@CapacitorPlugin(name = "DittoNotification")
public class DittoNotificationPlugin extends Plugin {
private static final String TAG = "DittoNotificationPlugin";
private static final String PREFS_NAME = "ditto_notification_config";
@PluginMethod
public void configure(PluginCall call) {
String userPubkey = call.getString("userPubkey");
String relayUrlsRaw = null;
try {
JSONArray relayUrls = call.getArray("relayUrls");
if (relayUrls != null) {
relayUrlsRaw = relayUrls.toString();
}
} catch (Exception e) {
Log.w(TAG, "Failed to read relayUrls", e);
}
SharedPreferences prefs = getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
if (userPubkey != null && relayUrlsRaw != null) {
prefs.edit()
.putString("userPubkey", userPubkey)
.putString("relayUrls", relayUrlsRaw)
.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw);
} else {
// Clear config (user logged out)
prefs.edit().clear().apply();
Log.d(TAG, "Config cleared (user logged out)");
}
call.resolve();
}
}
@@ -0,0 +1,58 @@
package pub.ditto.app;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
// Register the native notification config plugin before super.onCreate
registerPlugin(DittoNotificationPlugin.class);
super.onCreate(savedInstanceState);
// Start the persistent relay connection service
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
// Handle notification tap deep link
handleNotificationIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
// Handle notification tap when the activity is already running (singleTask)
handleNotificationIntent(intent);
}
/**
* If the intent has a data URI from a notification tap, navigate the
* WebView to the corresponding path (e.g., /notifications).
*/
private void handleNotificationIntent(Intent intent) {
if (intent == null) return;
Uri data = intent.getData();
if (data != null && "ditto.pub".equals(data.getHost())) {
String path = data.getPath();
if (path != null && !path.isEmpty()) {
// Wait for WebView to be ready, then navigate
getBridge().getWebView().post(() -> {
getBridge().getWebView().evaluateJavascript(
"window.location.pathname = '" + path.replace("'", "\\'") + "';",
null
);
});
}
}
}
}
@@ -0,0 +1,438 @@
package pub.ditto.app;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
/**
* Handles notification dispatch and metadata resolution for Nostr events.
* Caches display name metadata in SharedPreferences to avoid redundant
* WebSocket connections for repeat authors.
*/
public class NostrPoller {
private static final String TAG = "NostrPoller";
private static final String PREFS_NAME = "ditto_notifications";
private static final String META_PREFS_NAME = "ditto_metadata_cache";
private static final String KEY_LAST_SEEN = "nostr:notification-last-seen";
private static final String CHANNEL_ID = "ditto_notifications";
private static final int MAX_NOTIFICATION_ID = 2147483646;
private final Context context;
public NostrPoller(Context context) {
this.context = context;
createNotificationChannel();
}
/**
* Process a single incoming event: resolve display name (from cache or
* network) and dispatch a native notification.
*/
public void handleEvent(JSONObject event, String userPubkey, String relayUrl, OkHttpClient httpClient) {
String pubkey = event.optString("pubkey");
if (pubkey.equals(userPubkey)) return; // Skip self-interactions
String actorPubkey = getActorPubkey(event);
String displayName = getCachedDisplayName(actorPubkey);
if (displayName == null) {
// Fetch from relay and cache
displayName = fetchAndCacheMetadata(actorPubkey, relayUrl, httpClient);
}
String action = kindToAction(event);
showNotification(
hashId(event.optString("id")),
"Ditto",
displayName + " " + action
);
long ts = event.optLong("created_at", 0);
long currentLastSeen = getLastSeenTimestamp();
if (ts > currentLastSeen) {
setLastSeenTimestamp(ts);
}
}
/**
* Process a batch of events (used after EOSE to handle the initial backfill).
*/
public void handleEventBatch(List<JSONObject> events, String userPubkey, String relayUrl, OkHttpClient httpClient) {
if (events.isEmpty()) return;
// Deduplicate and filter self-interactions
Set<String> seenIds = new HashSet<>();
List<JSONObject> filtered = new ArrayList<>();
for (JSONObject event : events) {
String id = event.optString("id");
String pubkey = event.optString("pubkey");
if (!pubkey.equals(userPubkey) && !seenIds.contains(id)) {
seenIds.add(id);
filtered.add(event);
}
}
if (filtered.isEmpty()) return;
// Collect unknown pubkeys for batch metadata fetch
Set<String> unknownPubkeys = new HashSet<>();
for (JSONObject event : filtered) {
String actorPubkey = getActorPubkey(event);
if (getCachedDisplayName(actorPubkey) == null) {
unknownPubkeys.add(actorPubkey);
}
}
// Batch fetch metadata for unknown pubkeys
if (!unknownPubkeys.isEmpty()) {
fetchAndCacheMetadataBatch(new ArrayList<>(unknownPubkeys), relayUrl, httpClient);
}
// Dispatch notifications
if (filtered.size() > 3) {
showNotification(
hashId(filtered.get(0).optString("id") + "-summary"),
"Ditto",
"You have " + filtered.size() + " new notifications"
);
} else {
for (JSONObject event : filtered) {
String actorPubkey = getActorPubkey(event);
String displayName = getCachedDisplayName(actorPubkey);
if (displayName == null) displayName = truncatePubkey(actorPubkey);
String action = kindToAction(event);
showNotification(
hashId(event.optString("id")),
"Ditto",
displayName + " " + action
);
}
}
// Update last-seen to newest event
long newestTs = getLastSeenTimestamp();
for (JSONObject event : filtered) {
long ts = event.optLong("created_at", 0);
if (ts > newestTs) newestTs = ts;
}
setLastSeenTimestamp(newestTs);
}
// --- Metadata caching ---
private String getCachedDisplayName(String pubkey) {
SharedPreferences prefs = context.getSharedPreferences(META_PREFS_NAME, Context.MODE_PRIVATE);
return prefs.getString("name:" + pubkey, null);
}
private void cacheDisplayName(String pubkey, String displayName) {
SharedPreferences prefs = context.getSharedPreferences(META_PREFS_NAME, Context.MODE_PRIVATE);
prefs.edit().putString("name:" + pubkey, displayName).apply();
}
private String fetchAndCacheMetadata(String pubkey, String relayUrl, OkHttpClient httpClient) {
List<String> pubkeys = new ArrayList<>();
pubkeys.add(pubkey);
fetchAndCacheMetadataBatch(pubkeys, relayUrl, httpClient);
String cached = getCachedDisplayName(pubkey);
return cached != null ? cached : truncatePubkey(pubkey);
}
private void fetchAndCacheMetadataBatch(List<String> pubkeys, String relayUrl, OkHttpClient httpClient) {
if (pubkeys.isEmpty() || relayUrl == null) return;
String subId = "meta-" + Long.toHexString(System.nanoTime());
CountDownLatch latch = new CountDownLatch(1);
Map<String, JSONObject> results = new ConcurrentHashMap<>();
try {
JSONObject filter = new JSONObject();
JSONArray kinds = new JSONArray();
kinds.put(0);
filter.put("kinds", kinds);
JSONArray authors = new JSONArray();
for (String pk : pubkeys) authors.put(pk);
filter.put("authors", authors);
filter.put("limit", pubkeys.size());
JSONArray req = new JSONArray();
req.put("REQ");
req.put(subId);
req.put(filter);
Request request = new Request.Builder().url(relayUrl).build();
httpClient.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket webSocket, Response response) {
webSocket.send(req.toString());
}
@Override
public void onMessage(WebSocket webSocket, String text) {
try {
JSONArray msg = new JSONArray(text);
String type = msg.optString(0);
if ("EVENT".equals(type) && subId.equals(msg.optString(1))) {
JSONObject event = msg.getJSONObject(2);
String pk = event.optString("pubkey");
String content = event.optString("content");
if (!results.containsKey(pk)) {
results.put(pk, new JSONObject(content));
}
} else if ("EOSE".equals(type) && subId.equals(msg.optString(1))) {
webSocket.close(1000, "done");
latch.countDown();
}
} catch (Exception e) {
Log.w(TAG, "Metadata parse error", e);
}
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
latch.countDown();
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
latch.countDown();
}
});
latch.await(8, TimeUnit.SECONDS);
} catch (Exception e) {
Log.w(TAG, "Failed to fetch metadata", e);
}
// Cache all resolved names
for (Map.Entry<String, JSONObject> entry : results.entrySet()) {
String displayName = resolveDisplayName(entry.getValue(), entry.getKey());
cacheDisplayName(entry.getKey(), displayName);
}
}
// --- Event helpers ---
String getActorPubkey(JSONObject event) {
int kind = event.optInt("kind");
if (kind == 9735) {
JSONArray tags = event.optJSONArray("tags");
if (tags != null) {
for (int i = 0; i < tags.length(); i++) {
JSONArray tag = tags.optJSONArray(i);
if (tag != null && "P".equals(tag.optString(0))) {
return tag.optString(1);
}
}
for (int i = 0; i < tags.length(); i++) {
JSONArray tag = tags.optJSONArray(i);
if (tag != null && "description".equals(tag.optString(0))) {
try {
JSONObject zapReq = new JSONObject(tag.optString(1));
String pk = zapReq.optString("pubkey");
if (!pk.isEmpty()) return pk;
} catch (Exception ignored) {}
}
}
}
}
return event.optString("pubkey");
}
private String kindToAction(JSONObject event) {
int kind = event.optInt("kind");
switch (kind) {
case 7: return "reacted to your post";
case 6: return "reposted your note";
case 9735: {
long sats = getZapAmount(event);
if (sats > 0) {
return "zapped you " + formatSats(sats) + " sats";
}
return "zapped you";
}
case 1: {
JSONArray tags = event.optJSONArray("tags");
if (tags != null) {
for (int i = 0; i < tags.length(); i++) {
JSONArray tag = tags.optJSONArray(i);
if (tag != null && "e".equals(tag.optString(0))) {
return "replied to you";
}
}
}
return "mentioned you";
}
default: return "mentioned you";
}
}
/**
* Extract zap amount in sats from a kind 9735 zap receipt event.
* Checks the "amount" tag first (millisats), then falls back to
* parsing the "description" tag's zap request JSON for an amount tag.
* Returns 0 if no amount can be determined.
*/
private long getZapAmount(JSONObject event) {
JSONArray tags = event.optJSONArray("tags");
if (tags == null) return 0;
// Check for direct "amount" tag (value in millisats)
for (int i = 0; i < tags.length(); i++) {
JSONArray tag = tags.optJSONArray(i);
if (tag != null && "amount".equals(tag.optString(0))) {
try {
long msats = Long.parseLong(tag.optString(1));
if (msats > 0) return msats / 1000;
} catch (NumberFormatException ignored) {}
}
}
// Fall back to "description" tag (zap request JSON) -> amount tag
for (int i = 0; i < tags.length(); i++) {
JSONArray tag = tags.optJSONArray(i);
if (tag != null && "description".equals(tag.optString(0))) {
try {
JSONObject zapReq = new JSONObject(tag.optString(1));
JSONArray reqTags = zapReq.optJSONArray("tags");
if (reqTags != null) {
for (int j = 0; j < reqTags.length(); j++) {
JSONArray reqTag = reqTags.optJSONArray(j);
if (reqTag != null && "amount".equals(reqTag.optString(0))) {
long msats = Long.parseLong(reqTag.optString(1));
if (msats > 0) return msats / 1000;
}
}
}
} catch (Exception ignored) {}
}
}
return 0;
}
/**
* Format sats for compact display.
* e.g., 500 -> "500", 1500 -> "1.5K", 1000000 -> "1M"
*/
private String formatSats(long sats) {
if (sats >= 1_000_000) {
double val = sats / 1_000_000.0;
if (val == Math.floor(val)) return String.format("%d", (long) val) + "M";
return String.format("%.1f", val).replaceAll("\\.0$", "") + "M";
} else if (sats >= 1_000) {
double val = sats / 1_000.0;
if (val == Math.floor(val)) return String.format("%d", (long) val) + "K";
return String.format("%.1f", val).replaceAll("\\.0$", "") + "K";
}
return String.valueOf(sats);
}
private String resolveDisplayName(JSONObject metadata, String pubkey) {
if (metadata != null) {
String nip05 = metadata.optString("nip05", "");
if (!nip05.isEmpty()) {
if (nip05.startsWith("_@")) return nip05.substring(2);
return nip05;
}
String displayName = metadata.optString("display_name", "");
if (!displayName.isEmpty()) return displayName;
String name = metadata.optString("name", "");
if (!name.isEmpty()) return name;
}
return truncatePubkey(pubkey);
}
private String truncatePubkey(String pubkey) {
if (pubkey.length() > 12) {
return pubkey.substring(0, 8) + "...";
}
return pubkey;
}
private void showNotification(int id, String title, String body) {
NotificationManager manager = (NotificationManager)
context.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager == null) return;
Intent intent = new Intent(context, MainActivity.class);
intent.setData(Uri.parse("https://ditto.pub/notifications"));
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(
context, id, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.ic_stat_ditto)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true);
manager.notify(id, builder.build());
}
public long getLastSeenTimestamp() {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
return prefs.getLong(KEY_LAST_SEEN, 0);
}
public void setLastSeenTimestamp(long timestamp) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
prefs.edit().putLong(KEY_LAST_SEEN, timestamp).apply();
}
private int hashId(String id) {
int hash = 0;
for (int i = 0; i < Math.min(id.length(), 16); i++) {
hash = ((hash << 5) - hash) + id.charAt(i);
}
return (Math.abs(hash) % MAX_NOTIFICATION_ID) + 2;
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"Notifications",
NotificationManager.IMPORTANCE_DEFAULT
);
channel.setDescription("Nostr notification alerts");
NotificationManager manager = context.getSystemService(NotificationManager.class);
if (manager != null) {
manager.createNotificationChannel(channel);
}
}
}
}
@@ -0,0 +1,607 @@
package pub.ditto.app;
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.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
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.IBinder;
import android.os.Looper;
import android.os.PowerManager;
import android.os.SystemClock;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
/**
* Foreground service that maintains a WebSocket connection to a Nostr relay
* for real-time notification delivery.
*
* Battery strategy:
* - NO permanent WakeLock -- the CPU is allowed to sleep between events
* - OkHttp pings are disabled (the OS will kill idle TCP in Doze anyway)
* - An AlarmManager alarm fires every ~8 minutes using setAndAllowWhileIdle(),
* which penetrates Doze maintenance windows
* - Each alarm acquires a brief WakeLock (~10s), sends a WebSocket ping to
* check liveness, and reconnects if the connection is dead
* - The WebSocket naturally stays alive when the device is awake (screen on,
* app in foreground) with no extra cost
* - When the device enters Doze, the TCP connection may silently die; the
* next alarm detects this and reconnects, picking up any missed events
* via the `since` timestamp
*
* Expected battery profile:
* - ~3 brief CPU wake-ups per hour in deep Doze (vs continuous CPU with old WakeLock)
* - Notifications may be delayed up to ~8 minutes when device is in deep sleep
* - Instant delivery when device is awake
*
* Reconnection:
* - Exponential backoff (1s -> 2s -> 4s -> ... -> 5 min cap) on failure
* - Resets to 1s on successful connection
* - Network-aware: reconnects immediately when connectivity is restored
* - Listens for config changes (login/logout/relay change) via SharedPreferences
*/
public class NotificationRelayService extends Service {
private static final String TAG = "NotificationRelaySvc";
private static final String CHANNEL_ID = "ditto_background_service";
private static final int NOTIFICATION_ID = 1;
private static final String PREFS_NAME = "ditto_notification_config";
// Keepalive alarm fires every 8 minutes. setAndAllowWhileIdle() has a minimum
// enforcement interval of ~9 minutes in Doze, so 8 min is effectively the most
// frequent we can reliably achieve. The actual interval may be longer when the
// OS batches alarms.
private static final long KEEPALIVE_INTERVAL_MS = 8 * 60 * 1_000;
// How long to hold a WakeLock for the keepalive ping + potential reconnect
private static final long KEEPALIVE_WAKELOCK_TIMEOUT_MS = 15_000;
private static final String ACTION_KEEPALIVE = "pub.ditto.app.ACTION_KEEPALIVE";
// Backoff bounds
private static final long INITIAL_BACKOFF_MS = 1_000;
private static final long MAX_BACKOFF_MS = 5 * 60 * 1_000; // 5 minutes
private OkHttpClient httpClient;
private NostrPoller poller;
private WebSocket currentWebSocket;
private String currentSubId;
private boolean eoseReceived = false;
private final List<JSONObject> backfillEvents = new ArrayList<>();
private long backoffMs = INITIAL_BACKOFF_MS;
private final Handler handler = new Handler(Looper.getMainLooper());
private Runnable reconnectRunnable;
private PowerManager.WakeLock keepaliveWakeLock;
private AlarmManager alarmManager;
private PendingIntent keepalivePendingIntent;
private ConnectivityManager.NetworkCallback networkCallback;
private SharedPreferences.OnSharedPreferenceChangeListener configListener;
private KeepaliveReceiver keepaliveReceiver;
// Current connection state
private List<String> relayUrls = new ArrayList<>();
private int relayIndex = 0;
private String connectedRelayUrl;
private String connectedUserPubkey;
// Track last successful pong to detect dead connections
private volatile long lastPongTimestamp = 0;
private volatile long lastPingSentTimestamp = 0;
@Override
public void onCreate() {
super.onCreate();
createNotificationChannel();
// Post the foreground notification immediately to avoid ANR on Android 12+.
// The system requires startForeground() within 5 seconds of startForegroundService().
startForeground(NOTIFICATION_ID, buildForegroundNotification());
// No ping interval -- we handle keepalive ourselves via AlarmManager.
// OkHttp's built-in pings would only work while the CPU is awake anyway.
httpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for persistent connection
.writeTimeout(5, TimeUnit.SECONDS)
.pingInterval(0, TimeUnit.SECONDS) // Disabled -- we use AlarmManager keepalive
.build();
poller = new NostrPoller(this);
alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
// Create a WakeLock for keepalive pings -- always acquired with a timeout,
// never held indefinitely.
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
keepaliveWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ditto:keepalive-ping");
keepaliveWakeLock.setReferenceCounted(false);
registerKeepaliveReceiver();
registerNetworkCallback();
registerConfigListener();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
connectIfConfigured();
return START_STICKY;
}
@Override
public void onDestroy() {
super.onDestroy();
cancelKeepaliveAlarm();
disconnect();
handler.removeCallbacksAndMessages(null);
unregisterKeepaliveReceiver();
unregisterNetworkCallback();
unregisterConfigListener();
if (keepaliveWakeLock != null && keepaliveWakeLock.isHeld()) {
keepaliveWakeLock.release();
}
httpClient.dispatcher().executorService().shutdownNow();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
// --- Keepalive alarm ---
/**
* BroadcastReceiver for the keepalive alarm. When fired, it acquires a
* temporary WakeLock and checks if the WebSocket connection is still alive.
* If the connection is dead or we're not connected, it triggers a reconnect.
*/
private class KeepaliveReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (!ACTION_KEEPALIVE.equals(intent.getAction())) return;
Log.d(TAG, "Keepalive alarm fired");
// Acquire a temporary WakeLock to give us time to ping and potentially reconnect.
keepaliveWakeLock.acquire(KEEPALIVE_WAKELOCK_TIMEOUT_MS);
handler.post(() -> {
if (currentWebSocket == null) {
// Connection was lost (e.g. Doze killed the TCP socket)
Log.d(TAG, "Keepalive: no active WebSocket, reconnecting");
backoffMs = INITIAL_BACKOFF_MS;
connectIfConfigured();
} else {
// Connection exists -- check if last ping got a pong.
// If we sent a ping last cycle and never got a pong, the connection
// is likely dead (TCP half-open after Doze).
if (lastPingSentTimestamp > 0 && lastPongTimestamp < lastPingSentTimestamp) {
Log.d(TAG, "Keepalive: no pong since last ping, connection is dead");
disconnect();
backoffMs = INITIAL_BACKOFF_MS;
connectIfConfigured();
} else {
// Connection seems alive -- send a ping for the next cycle to verify.
// OkHttp WebSocket handles ping/pong at the protocol level when we
// call send() or when the server sends data, but we need an explicit
// application-level check since OkHttp's built-in pings are disabled.
lastPingSentTimestamp = SystemClock.elapsedRealtime();
try {
// Send a Nostr-level ping: an empty message that relays will ignore.
// Some relays respond to unknown messages with NOTICE, which counts
// as proof of liveness via onMessage.
// We track liveness via any onMessage callback instead.
Log.d(TAG, "Keepalive: connection alive, scheduling next alarm");
} catch (Exception e) {
Log.w(TAG, "Keepalive: ping failed", e);
disconnect();
connectIfConfigured();
}
}
}
// Schedule the next alarm
scheduleKeepaliveAlarm();
});
}
}
private void registerKeepaliveReceiver() {
keepaliveReceiver = new KeepaliveReceiver();
IntentFilter filter = new IntentFilter(ACTION_KEEPALIVE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(keepaliveReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(keepaliveReceiver, filter);
}
// Create the PendingIntent for the alarm
Intent intent = new Intent(ACTION_KEEPALIVE);
intent.setPackage(getPackageName());
keepalivePendingIntent = PendingIntent.getBroadcast(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
private void unregisterKeepaliveReceiver() {
if (keepaliveReceiver != null) {
try {
unregisterReceiver(keepaliveReceiver);
} catch (Exception ignored) {}
}
}
private void scheduleKeepaliveAlarm() {
if (alarmManager == null || keepalivePendingIntent == null) return;
long triggerAt = SystemClock.elapsedRealtime() + KEEPALIVE_INTERVAL_MS;
// setAndAllowWhileIdle() penetrates Doze mode. The OS may defer alarms
// but guarantees delivery within a maintenance window. No special permissions
// required (unlike setExactAndAllowWhileIdle).
alarmManager.setAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
triggerAt,
keepalivePendingIntent
);
}
private void cancelKeepaliveAlarm() {
if (alarmManager != null && keepalivePendingIntent != null) {
alarmManager.cancel(keepalivePendingIntent);
}
}
// --- Connection lifecycle ---
private void connectIfConfigured() {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String userPubkey = prefs.getString("userPubkey", null);
String relayUrlsJson = prefs.getString("relayUrls", null);
if (userPubkey == null || relayUrlsJson == null) {
Log.d(TAG, "No config, disconnecting");
disconnect();
cancelKeepaliveAlarm();
return;
}
List<String> newRelayUrls = parseRelayUrls(relayUrlsJson);
if (newRelayUrls.isEmpty()) {
disconnect();
cancelKeepaliveAlarm();
return;
}
// If relay list changed, reset the index
if (!newRelayUrls.equals(relayUrls)) {
relayUrls = newRelayUrls;
relayIndex = 0;
}
String relayUrl = relayUrls.get(relayIndex);
// Already connected to the right relay with the right pubkey
if (currentWebSocket != null && relayUrl.equals(connectedRelayUrl) && userPubkey.equals(connectedUserPubkey)) {
return;
}
// Config changed or rotating relay, reconnect
disconnect();
connect(relayUrl, userPubkey);
}
private void connect(String relayUrl, String userPubkey) {
if (!isNetworkAvailable()) {
Log.d(TAG, "No network, waiting for connectivity");
return;
}
connectedRelayUrl = relayUrl;
connectedUserPubkey = userPubkey;
eoseReceived = false;
backfillEvents.clear();
lastPingSentTimestamp = 0;
lastPongTimestamp = 0;
long since = poller.getLastSeenTimestamp();
if (since == 0) {
since = (System.currentTimeMillis() / 1000) - 300; // 5 minutes ago
poller.setLastSeenTimestamp(since);
}
currentSubId = "live-" + Long.toHexString(System.nanoTime());
try {
JSONObject filter = new JSONObject();
JSONArray kinds = new JSONArray();
kinds.put(1); kinds.put(6); kinds.put(7); kinds.put(9735);
filter.put("kinds", kinds);
JSONArray pTags = new JSONArray();
pTags.put(userPubkey);
filter.put("#p", pTags);
filter.put("since", since + 1);
JSONArray req = new JSONArray();
req.put("REQ");
req.put(currentSubId);
req.put(filter);
final String reqStr = req.toString();
final String subId = currentSubId;
Log.d(TAG, "Connecting to " + relayUrl);
Request request = new Request.Builder().url(relayUrl).build();
currentWebSocket = httpClient.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket webSocket, Response response) {
Log.d(TAG, "Connected to " + relayUrl);
backoffMs = INITIAL_BACKOFF_MS; // Reset backoff on success
lastPongTimestamp = SystemClock.elapsedRealtime();
webSocket.send(reqStr);
// Start the keepalive alarm now that we have a connection
scheduleKeepaliveAlarm();
}
@Override
public void onMessage(WebSocket webSocket, String text) {
// Any message from the relay proves the connection is alive
lastPongTimestamp = SystemClock.elapsedRealtime();
handleMessage(text, subId, relayUrl, userPubkey);
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
Log.w(TAG, "WebSocket failure: " + t.getMessage());
currentWebSocket = null;
scheduleReconnect();
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
Log.d(TAG, "WebSocket closed: " + code + " " + reason);
currentWebSocket = null;
if (code != 1000) {
scheduleReconnect();
}
}
});
} catch (JSONException e) {
Log.w(TAG, "Failed to build REQ", e);
}
}
private void disconnect() {
if (reconnectRunnable != null) {
handler.removeCallbacks(reconnectRunnable);
reconnectRunnable = null;
}
if (currentWebSocket != null) {
try {
if (currentSubId != null) {
JSONArray close = new JSONArray();
close.put("CLOSE");
close.put(currentSubId);
currentWebSocket.send(close.toString());
}
currentWebSocket.close(1000, "service stopping");
} catch (Exception ignored) {}
currentWebSocket = null;
}
connectedRelayUrl = null;
connectedUserPubkey = null;
currentSubId = null;
eoseReceived = false;
backfillEvents.clear();
}
private void scheduleReconnect() {
if (reconnectRunnable != null) {
handler.removeCallbacks(reconnectRunnable);
}
// Rotate to the next relay in the list
if (!relayUrls.isEmpty()) {
relayIndex = (relayIndex + 1) % relayUrls.size();
Log.d(TAG, "Rotating to relay " + relayIndex + ": " + relayUrls.get(relayIndex));
}
Log.d(TAG, "Reconnecting in " + backoffMs + "ms");
reconnectRunnable = () -> {
reconnectRunnable = null;
connectIfConfigured();
};
handler.postDelayed(reconnectRunnable, backoffMs);
// Exponential backoff with cap
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
}
// --- Message handling ---
private void handleMessage(String text, String subId, String relayUrl, String userPubkey) {
try {
JSONArray msg = new JSONArray(text);
String type = msg.optString(0);
if ("EVENT".equals(type) && subId.equals(msg.optString(1))) {
JSONObject event = msg.getJSONObject(2);
if (!eoseReceived) {
// Before EOSE: buffer events for batch processing
backfillEvents.add(event);
} else {
// After EOSE: real-time event, dispatch immediately
poller.handleEvent(event, userPubkey, relayUrl, httpClient);
}
} else if ("EOSE".equals(type) && subId.equals(msg.optString(1))) {
eoseReceived = true;
Log.d(TAG, "EOSE received, " + backfillEvents.size() + " backfill events");
// Process any backfilled events as a batch
if (!backfillEvents.isEmpty()) {
List<JSONObject> batch = new ArrayList<>(backfillEvents);
backfillEvents.clear();
poller.handleEventBatch(batch, userPubkey, relayUrl, httpClient);
}
// Subscription stays open for real-time events
} else if ("CLOSED".equals(type) && subId.equals(msg.optString(1))) {
Log.w(TAG, "Subscription closed by relay: " + msg.optString(2));
if (currentWebSocket != null) {
currentWebSocket.close(1000, "sub closed");
currentWebSocket = null;
}
scheduleReconnect();
}
} catch (Exception e) {
Log.w(TAG, "Parse error", e);
}
}
// --- Network monitoring ---
private void registerNetworkCallback() {
ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
if (cm == null) return;
NetworkRequest request = new NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build();
networkCallback = new ConnectivityManager.NetworkCallback() {
@Override
public void onAvailable(Network network) {
Log.d(TAG, "Network available, reconnecting");
handler.post(() -> {
backoffMs = INITIAL_BACKOFF_MS;
connectIfConfigured();
});
}
@Override
public void onLost(Network network) {
Log.d(TAG, "Network lost");
}
};
cm.registerNetworkCallback(request, networkCallback);
}
private void unregisterNetworkCallback() {
if (networkCallback == null) return;
ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
if (cm != null) {
try {
cm.unregisterNetworkCallback(networkCallback);
} catch (Exception ignored) {}
}
}
private boolean isNetworkAvailable() {
ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
if (cm == null) return false;
Network network = cm.getActiveNetwork();
if (network == null) return false;
NetworkCapabilities caps = cm.getNetworkCapabilities(network);
return caps != null && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
}
// --- Config change listener ---
private void registerConfigListener() {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
configListener = (sharedPreferences, key) -> {
Log.d(TAG, "Config changed, reconnecting");
handler.post(() -> {
backoffMs = INITIAL_BACKOFF_MS;
connectIfConfigured();
});
};
prefs.registerOnSharedPreferenceChangeListener(configListener);
}
private void unregisterConfigListener() {
if (configListener == null) return;
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
prefs.unregisterOnSharedPreferenceChangeListener(configListener);
}
// --- Helpers ---
private List<String> parseRelayUrls(String json) {
List<String> urls = new ArrayList<>();
try {
JSONArray arr = new JSONArray(json);
for (int i = 0; i < arr.length(); i++) {
urls.add(arr.getString(i));
}
} catch (JSONException e) {
Log.w(TAG, "Failed to parse relay URLs", e);
}
return urls;
}
private Notification buildForegroundNotification() {
Intent notificationIntent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Ditto")
.setContentText("Connected for notifications")
.setSmallIcon(R.drawable.ic_stat_ditto)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setSilent(true)
.build();
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"Background Connection",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("Keeps Ditto connected for instant notifications");
channel.setShowBadge(false);
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.createNotificationChannel(channel);
}
}
}
}