initial commit

This commit is contained in:
2026-01-08 19:16:32 +00:00
commit d89aedd5af
121 changed files with 8509 additions and 0 deletions

1
IrisGlass/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,49 @@
plugins {
alias(libs.plugins.android.application)
}
android {
namespace 'sh.nym.irisglass'
compileSdk 19
defaultConfig {
applicationId "sh.nym.irisglass"
minSdk 19
targetSdk 19
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
// Glass Development Kit Preview (API 19) add-on provides com.google.android.glass.* classes.
// If you have it installed via the Android SDK Manager, this will pick up gdk.jar automatically.
def sdkDir = null
def lp = rootProject.file("local.properties")
if (lp.exists()) {
def p = new Properties()
lp.withInputStream { p.load(it) }
sdkDir = p.getProperty("sdk.dir")
}
if (sdkDir != null) {
def gdkJar = file("${sdkDir}/add-ons/addon-google_gdk-google-19/libs/gdk.jar")
if (gdkJar.exists()) {
compileOnly files(gdkJar)
} else {
logger.lifecycle("Glass GDK jar not found at: ${gdkJar}")
}
} else {
logger.lifecycle("No sdk.dir found; Glass GDK jar not configured.")
}
}

21
IrisGlass/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
package sh.nym.irisglass;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("sh.nym.irisglass", appContext.getPackageName());
}
}

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".SettingsActivity"
android:label="@string/app_name"
android:launchMode="singleTask" />
<activity
android:name=".FeedActivity"
android:label="@string/app_name"
android:theme="@style/Theme.IrisGlass.Fullscreen"
android:launchMode="singleTask" />
<activity
android:name=".ActionActivity"
android:label="@string/app_name"
android:theme="@style/Theme.IrisGlass.Fullscreen"
android:launchMode="singleTask" />
<activity
android:name=".MenuActivity"
android:label="@string/app_name"
android:theme="@style/Theme.IrisGlass.MenuOverlay"
android:launchMode="singleTask"
android:clearTaskOnLaunch="true"
android:excludeFromRecents="true" />
<service android:name=".HudService" />
<service android:name=".BleLinkService" />
</application>
</manifest>

View File

@@ -0,0 +1,147 @@
package sh.nym.irisglass;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.AdapterView;
import com.google.android.glass.widget.CardBuilder;
import com.google.android.glass.widget.CardScrollAdapter;
import com.google.android.glass.widget.CardScrollView;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class ActionActivity extends Activity {
public static final String EXTRA_CARD_ID = "card_id";
public static final String EXTRA_CARD_TITLE = "card_title";
public static final String EXTRA_ACTIONS = "actions";
public static final String EXTRA_ACTION = "action";
private CardScrollView cardScrollView;
private ActionAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
requestWindowFeature(Window.FEATURE_NO_TITLE);
super.onCreate(savedInstanceState);
Intent i = getIntent();
String cardId = i != null ? i.getStringExtra(EXTRA_CARD_ID) : null;
String cardTitle = i != null ? i.getStringExtra(EXTRA_CARD_TITLE) : null;
String[] actions = i != null ? i.getStringArrayExtra(EXTRA_ACTIONS) : null;
List<String> actionList = new ArrayList<String>();
if (actions != null) actionList.addAll(Arrays.asList(actions));
if (actionList.isEmpty()) {
actionList.add("BACK");
}
cardScrollView = new CardScrollView(this);
adapter = new ActionAdapter(this, cardTitle, actionList);
cardScrollView.setAdapter(adapter);
cardScrollView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
String action = adapter.getActionAt(position);
if ("BACK".equals(action)) {
setResult(RESULT_CANCELED);
finish();
return;
}
Intent r = new Intent();
r.putExtra(EXTRA_CARD_ID, cardId);
r.putExtra(EXTRA_ACTION, action);
setResult(RESULT_OK, r);
finish();
}
});
setContentView(cardScrollView);
}
@Override
protected void onResume() {
super.onResume();
cardScrollView.activate();
}
@Override
protected void onPause() {
cardScrollView.deactivate();
super.onPause();
}
private static final class ActionAdapter extends CardScrollAdapter {
private final Activity activity;
private final String title;
private final List<String> actions;
ActionAdapter(Activity activity, String title, List<String> actions) {
this.activity = activity;
this.title = title != null ? title : "";
this.actions = actions != null ? actions : new ArrayList<String>();
}
String getActionAt(int position) {
if (position < 0 || position >= actions.size()) return "BACK";
return actions.get(position);
}
@Override
public int getCount() {
return actions.size();
}
@Override
public Object getItem(int position) {
if (position < 0 || position >= actions.size()) return null;
return actions.get(position);
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
String action = getActionAt(position);
String label = labelFor(action);
CardBuilder b = new CardBuilder(activity, CardBuilder.Layout.MENU)
.setText(label);
if (title.length() > 0 && !"BACK".equals(action)) {
b.setFootnote(truncate(title, 30));
} else if ("BACK".equals(action)) {
b.setFootnote("Tap to return");
}
return b.getView(convertView, parent);
}
@Override
public int getPosition(Object item) {
if (!(item instanceof String)) return -1;
String s = (String) item;
for (int i = 0; i < actions.size(); i++) {
if (s.equals(actions.get(i))) return i;
}
return -1;
}
private static String labelFor(String action) {
if ("DISMISS".equals(action)) return "Dismiss";
if ("SNOOZE_2H".equals(action)) return "Snooze 2 hours";
if ("SNOOZE_24H".equals(action)) return "Snooze 24 hours";
if ("SAVE".equals(action)) return "Save";
if ("BACK".equals(action)) return "Back";
return action;
}
private static String truncate(String s, int maxChars) {
if (s == null) return "";
if (s.length() <= maxChars) return s;
if (maxChars <= 1) return s.substring(0, maxChars);
return s.substring(0, maxChars - 1) + "\u2026";
}
}
}

View File

@@ -0,0 +1,536 @@
package sh.nym.irisglass;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
public final class BleCentralClient {
public interface Callback {
void onStatus(String status, String lastErrorOrNull, boolean shouldRender);
void onConnected();
void onPing();
void onFeedJson(String json);
}
private static final UUID CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
private static final long SCAN_TIMEOUT_MS = 10_000L;
private static final long CONNECT_TIMEOUT_MS = 12_000L;
private final Context appContext;
private final Callback callback;
private final Handler handler;
private final BluetoothAdapter adapter;
private final FeedReassembler reassembler = new FeedReassembler();
private boolean scanning = false;
private BluetoothDevice lastDevice = null;
private BluetoothGatt gatt = null;
private boolean subscribed = false;
private int backoffMs = 1000;
private long lastNotificationAtMs = 0L;
private long lastUnparsedLogAtMs = 0L;
private final Runnable scanTimeoutRunnable = new Runnable() {
@Override
public void run() {
if (!scanning) return;
Log.i(Constants.TAG_BLE, "Scan timeout; restarting");
stopScanInternal();
scheduleReconnect("Scan timeout");
}
};
private final Runnable connectTimeoutRunnable = new Runnable() {
@Override
public void run() {
if (gatt == null) return;
Log.w(Constants.TAG_BLE, "Connect timeout; disconnecting");
closeGattInternal();
scheduleReconnect("Connect timeout");
}
};
private final BluetoothAdapter.LeScanCallback leScanCallback = new BluetoothAdapter.LeScanCallback() {
@Override
public void onLeScan(final BluetoothDevice device, final int rssi, final byte[] scanRecord) {
handler.post(new Runnable() {
@Override
public void run() {
onScanResult(device, rssi, scanRecord);
}
});
}
};
private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(final BluetoothGatt g, final int status, final int newState) {
handler.post(new Runnable() {
@Override
public void run() {
handleConnectionStateChange(g, status, newState);
}
});
}
@Override
public void onServicesDiscovered(final BluetoothGatt g, final int status) {
handler.post(new Runnable() {
@Override
public void run() {
handleServicesDiscovered(g, status);
}
});
}
@Override
public void onDescriptorWrite(final BluetoothGatt g, final BluetoothGattDescriptor descriptor, final int status) {
handler.post(new Runnable() {
@Override
public void run() {
handleDescriptorWrite(g, descriptor, status);
}
});
}
@Override
public void onCharacteristicChanged(final BluetoothGatt g, final BluetoothGattCharacteristic characteristic) {
final byte[] value = characteristic.getValue();
handler.post(new Runnable() {
@Override
public void run() {
handleCharacteristicChanged(characteristic.getUuid(), value);
}
});
}
};
public BleCentralClient(Context context, Callback callback, Looper looper) {
this.appContext = context.getApplicationContext();
this.callback = callback;
this.handler = new Handler(looper);
this.adapter = BluetoothAdapter.getDefaultAdapter();
}
public void start() {
handler.post(new Runnable() {
@Override
public void run() {
backoffMs = 1000;
startScanInternal("Start");
}
});
}
public void rescan() {
handler.post(new Runnable() {
@Override
public void run() {
Log.i(Constants.TAG_BLE, "Rescan requested");
closeGattInternal();
backoffMs = 1000;
startScanInternal("Rescan");
}
});
}
public void stop() {
handler.post(new Runnable() {
@Override
public void run() {
stopScanInternal();
closeGattInternal();
if (callback != null) callback.onStatus("Stopped", null, true);
}
});
}
private void onScanResult(BluetoothDevice device, int rssi, byte[] scanRecord) {
if (!scanning) return;
if (device == null) return;
Advert adv = Advert.parse(scanRecord);
boolean nameMatch = (adv != null && Constants.PERIPHERAL_NAME_HINT.equals(adv.localName))
|| Constants.PERIPHERAL_NAME_HINT.equals(device.getName());
boolean serviceMatch = adv != null && adv.hasService(Constants.SERVICE_UUID);
if (!nameMatch && !serviceMatch) return;
Log.i(Constants.TAG_BLE, "Found peripheral: addr=" + device.getAddress()
+ " name=" + device.getName()
+ " advName=" + (adv != null ? adv.localName : "null")
+ " serviceMatch=" + serviceMatch
+ " rssi=" + rssi);
lastDevice = device;
stopScanInternal();
connectInternal(device);
}
private void scheduleReconnect(String reason) {
if (callback != null) callback.onStatus("Disconnected", reason, true);
int delay = backoffMs;
backoffMs = Math.min(30_000, backoffMs * 2);
Log.i(Constants.TAG_BLE, "Reconnect in " + delay + "ms (" + reason + ")");
handler.postDelayed(new Runnable() {
@Override
public void run() {
startScanInternal("Reconnect");
}
}, delay);
}
private void startScanInternal(String why) {
if (adapter == null) {
if (callback != null) callback.onStatus("BLE unsupported", "No BluetoothAdapter", true);
return;
}
if (!adapter.isEnabled()) {
if (callback != null) callback.onStatus("Bluetooth OFF", "Enable Bluetooth", true);
return;
}
if (scanning) return;
subscribed = false;
if (callback != null) callback.onStatus("Scanning…", null, true);
Log.i(Constants.TAG_BLE, "startLeScan (" + why + ")");
// Scan broadly; filter by advertised service UUID and/or local name hint in the callback.
scanning = startLeScanCompat(null, leScanCallback);
if (!scanning) {
if (callback != null) callback.onStatus("Scan failed", "startLeScan returned false", true);
scheduleReconnect("Scan failed");
return;
}
handler.removeCallbacks(scanTimeoutRunnable);
handler.postDelayed(scanTimeoutRunnable, SCAN_TIMEOUT_MS);
}
private void stopScanInternal() {
if (!scanning) return;
scanning = false;
handler.removeCallbacks(scanTimeoutRunnable);
Log.i(Constants.TAG_BLE, "stopLeScan");
stopLeScanCompat(leScanCallback);
}
private void connectInternal(BluetoothDevice device) {
if (device == null) {
scheduleReconnect("No device");
return;
}
if (callback != null) callback.onStatus("Connecting…", null, true);
Log.i(Constants.TAG_BLE, "connectGatt addr=" + device.getAddress());
closeGattInternal();
subscribed = false;
gatt = connectGattCompat(device, appContext, false, gattCallback);
if (gatt == null) {
scheduleReconnect("connectGatt returned null");
return;
}
handler.removeCallbacks(connectTimeoutRunnable);
handler.postDelayed(connectTimeoutRunnable, CONNECT_TIMEOUT_MS);
}
private void handleConnectionStateChange(BluetoothGatt g, int status, int newState) {
if (gatt == null || g != gatt) return;
Log.i(Constants.TAG_BLE, "onConnectionStateChange status=" + status + " newState=" + newState);
if (newState == BluetoothProfile.STATE_CONNECTED) {
handler.removeCallbacks(connectTimeoutRunnable);
backoffMs = 1000;
if (callback != null) {
callback.onStatus("Discovering…", null, true);
callback.onConnected();
}
boolean ok = gatt.discoverServices();
Log.i(Constants.TAG_BLE, "discoverServices=" + ok);
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
handler.removeCallbacks(connectTimeoutRunnable);
closeGattInternal();
scheduleReconnect("Disconnected");
}
}
private void handleServicesDiscovered(BluetoothGatt g, int status) {
if (gatt == null || g != gatt) return;
Log.i(Constants.TAG_BLE, "onServicesDiscovered status=" + status);
if (status != BluetoothGatt.GATT_SUCCESS) {
closeGattInternal();
scheduleReconnect("Service discovery failed: " + status);
return;
}
BluetoothGattService service = gatt.getService(Constants.SERVICE_UUID);
if (service == null) {
closeGattInternal();
scheduleReconnect("Missing service " + Constants.SERVICE_UUID_STR);
return;
}
BluetoothGattCharacteristic feedTx = service.getCharacteristic(Constants.FEED_TX_UUID);
if (feedTx == null) {
closeGattInternal();
scheduleReconnect("Missing characteristic " + Constants.FEED_TX_UUID_STR);
return;
}
if (callback != null) callback.onStatus("Subscribing…", null, true);
Log.i(Constants.TAG_BLE, "Enabling notifications for FEED_TX");
boolean notifOk = gatt.setCharacteristicNotification(feedTx, true);
Log.i(Constants.TAG_BLE, "setCharacteristicNotification=" + notifOk);
BluetoothGattDescriptor cccd = feedTx.getDescriptor(CCCD_UUID);
if (cccd == null) {
closeGattInternal();
scheduleReconnect("Missing CCCD (0x2902)");
return;
}
cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
boolean writeOk = gatt.writeDescriptor(cccd);
Log.i(Constants.TAG_BLE, "writeDescriptor(CCCD)=" + writeOk);
if (!writeOk) {
closeGattInternal();
scheduleReconnect("CCCD write failed to start");
}
}
private void handleDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int status) {
if (gatt == null || g != gatt) return;
if (descriptor == null) return;
if (!CCCD_UUID.equals(descriptor.getUuid())) return;
Log.i(Constants.TAG_BLE, "onDescriptorWrite CCCD status=" + status);
if (status == BluetoothGatt.GATT_SUCCESS) {
subscribed = true;
if (callback != null) callback.onStatus("Connected", null, true);
} else {
closeGattInternal();
scheduleReconnect("CCCD write failed: " + status);
}
}
private void handleCharacteristicChanged(UUID uuid, byte[] value) {
if (!Constants.FEED_TX_UUID.equals(uuid)) return;
lastNotificationAtMs = System.currentTimeMillis();
FeedReassembler.Result result = reassembler.onNotification(value, lastNotificationAtMs);
if (result == null) {
// Diagnostic: log occasionally so we can infer the framing coming from iPhone.
if (lastNotificationAtMs - lastUnparsedLogAtMs > 1000L) {
lastUnparsedLogAtMs = lastNotificationAtMs;
String hint = "";
if (value != null && value.length >= 9) {
long msgId = ((long) (value[0] & 0xFF))
| ((long) (value[1] & 0xFF) << 8)
| ((long) (value[2] & 0xFF) << 16)
| ((long) (value[3] & 0xFF) << 24);
int type = value[4] & 0xFF;
int idx = (value[5] & 0xFF) | ((value[6] & 0xFF) << 8);
int cnt = (value[7] & 0xFF) | ((value[8] & 0xFF) << 8);
hint = " msgId=" + (msgId & 0xFFFFFFFFL) + " type=" + type + " chunk=" + idx + "/" + cnt;
}
Log.d(Constants.TAG_FEED, "Unparsed notify len=" + (value != null ? value.length : 0) + hint + " hex=" + hexPrefix(value, 16));
}
return;
}
if (result.isPing) {
Log.d(Constants.TAG_FEED, "PING");
if (callback != null) callback.onPing();
return;
}
if (result.jsonOrNull != null) {
Log.i(Constants.TAG_FEED, "Reassembled JSON (" + result.jsonOrNull.length() + " bytes)");
Log.i(Constants.TAG_FEED, "RAW_JSON_BEGIN");
logLarge(Constants.TAG_FEED, result.jsonOrNull);
Log.i(Constants.TAG_FEED, "RAW_JSON_END");
if (callback != null) callback.onFeedJson(result.jsonOrNull);
}
}
private void closeGattInternal() {
subscribed = false;
handler.removeCallbacks(connectTimeoutRunnable);
if (gatt != null) {
try {
gatt.disconnect();
} catch (Throwable ignored) {
}
try {
gatt.close();
} catch (Throwable ignored) {
}
gatt = null;
}
}
@SuppressLint("MissingPermission")
private static boolean startLeScanCompat(UUID[] uuids, BluetoothAdapter.LeScanCallback callback) {
BluetoothAdapter a = BluetoothAdapter.getDefaultAdapter();
if (a == null) return false;
try {
if (uuids == null || uuids.length == 0) {
return a.startLeScan(callback);
}
return a.startLeScan(uuids, callback);
} catch (Throwable t) {
Log.w(Constants.TAG_BLE, "startLeScan failed: " + t);
return false;
}
}
@SuppressLint("MissingPermission")
private static void stopLeScanCompat(BluetoothAdapter.LeScanCallback callback) {
BluetoothAdapter a = BluetoothAdapter.getDefaultAdapter();
if (a == null) return;
try {
a.stopLeScan(callback);
} catch (Throwable t) {
Log.w(Constants.TAG_BLE, "stopLeScan failed: " + t);
}
}
@SuppressLint("MissingPermission")
private static BluetoothGatt connectGattCompat(BluetoothDevice device, Context context, boolean autoConnect, BluetoothGattCallback callback) {
try {
return device.connectGatt(context, autoConnect, callback);
} catch (Throwable t) {
Log.w(Constants.TAG_BLE, "connectGatt failed: " + t);
return null;
}
}
private static void logLarge(String tag, String s) {
if (s == null) {
Log.d(tag, "null");
return;
}
// Logcat truncates long lines; split into chunks.
final int max = 3500;
int i = 0;
while (i < s.length()) {
int end = Math.min(s.length(), i + max);
Log.d(tag, s.substring(i, end));
i = end;
}
}
private static final class Advert {
final String localName;
final List<UUID> services;
private Advert(String localName, List<UUID> services) {
this.localName = localName;
this.services = services;
}
boolean hasService(UUID uuid) {
if (services == null || uuid == null) return false;
for (int i = 0; i < services.size(); i++) {
if (uuid.equals(services.get(i))) return true;
}
return false;
}
static Advert parse(byte[] scanRecord) {
if (scanRecord == null) return null;
String name = null;
UUID[] found = new UUID[0];
int index = 0;
while (index < scanRecord.length) {
int len = scanRecord[index] & 0xFF;
if (len == 0) break;
int typeIndex = index + 1;
if (typeIndex >= scanRecord.length) break;
int type = scanRecord[typeIndex] & 0xFF;
int dataIndex = index + 2;
int dataLen = len - 1;
if (dataIndex + dataLen > scanRecord.length) break;
if (type == 0x08 || type == 0x09) { // short/complete local name
try {
name = new String(scanRecord, dataIndex, dataLen, "UTF-8");
} catch (Throwable ignored) {
}
} else if (type == 0x06 || type == 0x07) { // 128-bit service UUIDs
int uuids = dataLen / 16;
UUID[] tmp = new UUID[uuids];
for (int i = 0; i < uuids; i++) {
int off = dataIndex + (i * 16);
tmp[i] = uuidFrom128LittleEndian(scanRecord, off);
}
found = concat(found, tmp);
} else if (type == 0x21) { // Service Data - 128-bit UUID
if (dataLen >= 16) {
UUID uuid = uuidFrom128LittleEndian(scanRecord, dataIndex);
found = concat(found, new UUID[]{uuid});
}
}
index = index + len + 1;
}
return new Advert(name, Arrays.asList(found));
}
private static UUID[] concat(UUID[] a, UUID[] b) {
if (a == null || a.length == 0) return b;
if (b == null || b.length == 0) return a;
UUID[] out = new UUID[a.length + b.length];
System.arraycopy(a, 0, out, 0, a.length);
System.arraycopy(b, 0, out, a.length, b.length);
return out;
}
private static UUID uuidFrom128LittleEndian(byte[] b, int off) {
// BLE advertising stores 128-bit UUID little-endian.
long lsb = 0;
long msb = 0;
for (int i = 0; i < 8; i++) {
lsb = (lsb << 8) | (b[off + 15 - i] & 0xFF);
}
for (int i = 0; i < 8; i++) {
msb = (msb << 8) | (b[off + 7 - i] & 0xFF);
}
return new UUID(msb, lsb);
}
}
private static String hexPrefix(byte[] b, int max) {
if (b == null) return "";
int n = Math.min(b.length, max);
StringBuilder sb = new StringBuilder(n * 3);
for (int i = 0; i < n; i++) {
int v = b[i] & 0xFF;
if (i > 0) sb.append(' ');
if (v < 0x10) sb.append('0');
sb.append(Integer.toHexString(v));
}
if (b.length > n) sb.append("");
return sb.toString();
}
}

View File

@@ -0,0 +1,125 @@
package sh.nym.irisglass;
import android.app.Service;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.HandlerThread;
import android.os.IBinder;
import android.preference.PreferenceManager;
import android.util.Log;
import org.json.JSONException;
import java.util.List;
public final class BleLinkService extends Service implements BleCentralClient.Callback {
private static final String PREF_KEY_LAST_WINNER_ID = "winner_last_id";
private HandlerThread bleThread;
private BleCentralClient client;
@Override
public void onCreate() {
super.onCreate();
bleThread = new HandlerThread("ble-link");
bleThread.start();
client = new BleCentralClient(getApplicationContext(), this, bleThread.getLooper());
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent != null ? intent.getAction() : null;
if (Constants.ACTION_RESCAN.equals(action)) {
client.rescan();
} else {
client.start();
}
return START_STICKY;
}
@Override
public void onDestroy() {
try {
if (client != null) client.stop();
} catch (Throwable ignored) {
}
if (bleThread != null) {
bleThread.quit();
bleThread = null;
}
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onStatus(String status, String lastErrorOrNull, boolean shouldRender) {
Log.i(Constants.TAG_BLE, "Status: " + status + (lastErrorOrNull != null ? (" (" + lastErrorOrNull + ")") : ""));
Intent i = new Intent(Constants.ACTION_BLE_STATUS);
i.putExtra(Constants.EXTRA_BLE_STATUS, status);
i.putExtra(Constants.EXTRA_BLE_ERROR, lastErrorOrNull);
sendBroadcast(i);
}
@Override
public void onConnected() {
Intent i = new Intent(Constants.ACTION_BLE_STATUS);
i.putExtra(Constants.EXTRA_BLE_STATUS, "Connected");
sendBroadcast(i);
}
@Override
public void onPing() {
}
@Override
public void onFeedJson(String json) {
if (json == null) return;
try {
FeedEnvelope env = FeedParser.parseEnvelope(json);
HudState.get().setFeed(env.items, env.meta, true);
maybeNudgeWinnerChanged(env);
} catch (JSONException e) {
Log.w(Constants.TAG_FEED, "Parse error: " + e);
Intent i = new Intent(Constants.ACTION_BLE_STATUS);
i.putExtra(Constants.EXTRA_BLE_STATUS, "Connected");
i.putExtra(Constants.EXTRA_BLE_ERROR, "Feed parse error: " + e.getMessage());
sendBroadcast(i);
}
}
private void maybeNudgeWinnerChanged(FeedEnvelope env) {
if (env == null) return;
String winnerId = env.meta != null ? env.meta.winnerId : null;
if (winnerId == null || winnerId.length() == 0) {
List<FeedItem> active = env.activeItems();
if (!active.isEmpty()) {
FeedItem first = active.get(0);
if (first != null) winnerId = first.id;
}
}
if (winnerId == null || winnerId.length() == 0) return;
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
String lastWinnerId = prefs.getString(PREF_KEY_LAST_WINNER_ID, null);
if (lastWinnerId == null) {
prefs.edit().putString(PREF_KEY_LAST_WINNER_ID, winnerId).apply();
Log.i(Constants.TAG_HUD, "Winner nudge: seeded last winner id=" + winnerId);
return;
}
if (winnerId.equals(lastWinnerId)) return;
prefs.edit().putString(PREF_KEY_LAST_WINNER_ID, winnerId).apply();
Log.i(Constants.TAG_HUD, "Winner nudge: winner changed " + lastWinnerId + " -> " + winnerId);
Intent i = new Intent(getApplicationContext(), HudService.class);
i.setAction(Constants.ACTION_WINNER_CHANGED);
i.putExtra(Constants.EXTRA_WINNER_ID, winnerId);
startService(i);
}
}

View File

@@ -0,0 +1,14 @@
package sh.nym.irisglass;
public final class BucketType {
private BucketType() {}
public static final String RIGHT_NOW = "RIGHT_NOW";
public static final String FYI = "FYI";
public static final String[] ALL = new String[] {
RIGHT_NOW,
FYI
};
}

View File

@@ -0,0 +1,30 @@
package sh.nym.irisglass;
import java.util.UUID;
public final class Constants {
private Constants() {}
public static final String TAG_BLE = "BLE";
public static final String TAG_FEED = "FEED";
public static final String TAG_HUD = "HUD";
public static final String SERVICE_UUID_STR = "A0B0C0D0-E0F0-4A0B-9C0D-0E0F1A2B3C4D";
public static final String FEED_TX_UUID_STR = "A0B0C0D1-E0F0-4A0B-9C0D-0E0F1A2B3C4D";
public static final String CONTROL_RX_UUID_STR = "A0B0C0D2-E0F0-4A0B-9C0D-0E0F1A2B3C4D";
public static final UUID SERVICE_UUID = UUID.fromString(SERVICE_UUID_STR);
public static final UUID FEED_TX_UUID = UUID.fromString(FEED_TX_UUID_STR);
public static final UUID CONTROL_RX_UUID = UUID.fromString(CONTROL_RX_UUID_STR);
public static final String PERIPHERAL_NAME_HINT = "GlassNow";
public static final String ACTION_START_HUD = "sh.nym.irisglass.action.START_HUD";
public static final String ACTION_RESCAN = "sh.nym.irisglass.action.RESCAN";
public static final String ACTION_WINNER_CHANGED = "sh.nym.irisglass.action.WINNER_CHANGED";
public static final String EXTRA_WINNER_ID = "extra_winner_id";
public static final String ACTION_BLE_STATUS = "sh.nym.irisglass.action.BLE_STATUS";
public static final String EXTRA_BLE_STATUS = "extra_ble_status";
public static final String EXTRA_BLE_ERROR = "extra_ble_error";
}

View File

@@ -0,0 +1,140 @@
package sh.nym.irisglass;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.Window;
import android.widget.AdapterView;
import android.widget.Toast;
import com.google.android.glass.widget.CardScrollView;
import java.util.ArrayList;
import java.util.List;
public final class FeedActivity extends Activity {
private static final int REQ_MENU = 1;
private CardScrollView cardScrollView;
private FeedAdapter adapter;
private SuppressionStore suppressionStore;
private FeedItem selectedItem;
private final HudState.Listener hudListener = new HudState.Listener() {
@Override
public void onHudStateChanged(HudState.Snapshot snapshot, boolean shouldRender) {
// FeedActivity should always update while visible.
adapter.setItems(buildVisibleItems(snapshot));
try {
cardScrollView.setSelection(0);
} catch (Throwable ignored) {
}
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
requestWindowFeature(Window.FEATURE_NO_TITLE);
super.onCreate(savedInstanceState);
suppressionStore = new SuppressionStore(this);
cardScrollView = new CardScrollView(this);
adapter = new FeedAdapter(this, makeWaitingCard());
cardScrollView.setAdapter(adapter);
cardScrollView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
onCardTapped(position);
}
});
setContentView(cardScrollView);
}
@Override
protected void onResume() {
super.onResume();
cardScrollView.activate();
HudState.get().addListener(hudListener);
adapter.setItems(buildVisibleItems(HudState.get().snapshot()));
}
@Override
protected void onPause() {
try {
HudState.get().removeListener(hudListener);
} catch (Throwable ignored) {
}
cardScrollView.deactivate();
super.onPause();
}
private void onCardTapped(int position) {
Object obj = adapter.getItem(position);
if (!(obj instanceof FeedItem)) return;
selectedItem = (FeedItem) obj;
startActivityForResult(MenuActivity.newIntent(this, selectedItem), REQ_MENU);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, android.content.Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != REQ_MENU || resultCode != RESULT_OK || data == null) return;
String cardId = data.getStringExtra(MenuActivity.EXTRA_CARD_ID);
String action = data.getStringExtra(MenuActivity.EXTRA_ACTION);
if (cardId == null || action == null) return;
long now = System.currentTimeMillis() / 1000L;
if ("DISMISS".equals(action)) {
suppressionStore.setSuppressed(cardId, now + (10L * 365L * 24L * 60L * 60L));
adapter.removeById(cardId);
} else if ("SNOOZE_2H".equals(action)) {
suppressionStore.setSuppressed(cardId, now + 2L * 60L * 60L);
adapter.removeById(cardId);
} else if ("SNOOZE_24H".equals(action)) {
suppressionStore.setSuppressed(cardId, now + 24L * 60L * 60L);
adapter.removeById(cardId);
} else if ("SAVE".equals(action)) {
Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show();
}
if (adapter.getCount() == 0) {
adapter.setItems(makeWaitingCard());
}
}
private List<FeedItem> makeWaitingCard() {
ArrayList<FeedItem> items = new ArrayList<FeedItem>();
items.add(new FeedItem(
"local:empty",
FeedItemType.INFO,
"No cards",
"Waiting for feed…",
0.0,
1,
"",
new ArrayList<String>()
));
return items;
}
private List<FeedItem> buildVisibleItems(HudState.Snapshot snapshot) {
if (snapshot == null) return makeWaitingCard();
FeedEnvelope env = new FeedEnvelope(1, 0L, snapshot.items, snapshot.meta);
List<FeedItem> items = env.activeItems();
if (items.isEmpty()) return makeWaitingCard();
long now = System.currentTimeMillis() / 1000L;
ArrayList<FeedItem> filtered = new ArrayList<FeedItem>();
for (int i = 0; i < items.size(); i++) {
FeedItem it = items.get(i);
if (it == null) continue;
if (it.id != null && suppressionStore.isSuppressed(it.id, now)) continue;
filtered.add(it);
}
if (filtered.isEmpty()) return makeWaitingCard();
return filtered;
}
}

View File

@@ -0,0 +1,144 @@
package sh.nym.irisglass;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import com.google.android.glass.widget.CardBuilder;
import com.google.android.glass.widget.CardScrollAdapter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public final class FeedAdapter extends CardScrollAdapter {
private final Context context;
private final ArrayList<FeedItem> items;
public FeedAdapter(Context context, List<FeedItem> items) {
this.context = context.getApplicationContext();
this.items = new ArrayList<FeedItem>();
if (items != null) this.items.addAll(items);
sortInPlace(this.items);
}
public List<FeedItem> getItems() {
return items;
}
public boolean removeById(String id) {
if (id == null) return false;
for (int i = 0; i < items.size(); i++) {
FeedItem it = items.get(i);
if (it != null && id.equals(it.id)) {
items.remove(i);
notifyDataSetChanged();
return true;
}
}
return false;
}
public void setItems(List<FeedItem> newItems) {
items.clear();
if (newItems != null) items.addAll(newItems);
sortInPlace(items);
notifyDataSetChanged();
}
@Override
public int getCount() {
return items.size();
}
@Override
public Object getItem(int position) {
if (position < 0 || position >= items.size()) return null;
return items.get(position);
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
FeedItem item = (FeedItem) getItem(position);
if (item == null) {
return new CardBuilder(context, CardBuilder.Layout.TEXT)
.setText("No cards")
.setFootnote("Waiting for feed…")
.getView(convertView, parent);
}
String title = truncate(item.title, 26);
String subtitle = truncate(item.subtitle, 30);
// Keep bucket hint subtle by appending to subtitle if it fits.
String bucket = item.bucket != null ? item.bucket : "";
if (bucket.length() > 0) {
String suffix = " · " + bucket;
if (subtitle.length() + suffix.length() <= 30) {
subtitle = subtitle + suffix;
}
}
return new CardBuilder(context, CardBuilder.Layout.TEXT)
.setText(title)
.setFootnote(subtitle)
.getView(convertView, parent);
}
@Override
public int getPosition(Object item) {
if (!(item instanceof FeedItem)) return AdapterViewCompat.INVALID_POSITION;
FeedItem it = (FeedItem) item;
for (int i = 0; i < items.size(); i++) {
FeedItem cur = items.get(i);
if (cur == null || it.id == null) continue;
if (it.id.equals(cur.id)) return i;
}
return AdapterViewCompat.INVALID_POSITION;
}
private static void sortInPlace(List<FeedItem> items) {
if (items == null) return;
Collections.sort(items, new Comparator<FeedItem>() {
@Override
public int compare(FeedItem a, FeedItem b) {
if (a == b) return 0;
if (a == null) return 1;
if (b == null) return -1;
int ba = bucketRank(a.bucket);
int bb = bucketRank(b.bucket);
if (ba != bb) return ba - bb;
// priority desc
if (a.priority != b.priority) {
return a.priority < b.priority ? 1 : -1;
}
String ida = a.id != null ? a.id : "";
String idb = b.id != null ? b.id : "";
return ida.compareTo(idb);
}
});
}
private static int bucketRank(String bucket) {
if (BucketType.RIGHT_NOW.equals(bucket)) return 0;
if ("NEARBY".equals(bucket)) return 1;
if (BucketType.FYI.equals(bucket)) return 2;
return 3;
}
private static String truncate(String s, int maxChars) {
if (s == null) return "";
if (s.length() <= maxChars) return s;
if (maxChars <= 1) return s.substring(0, maxChars);
return s.substring(0, maxChars - 1) + "\u2026";
}
// Avoid depending on AdapterView.INVALID_POSITION (not available on some older builds).
private static final class AdapterViewCompat {
static final int INVALID_POSITION = -1;
}
}

View File

@@ -0,0 +1,29 @@
package sh.nym.irisglass;
import java.util.ArrayList;
import java.util.List;
public final class FeedEnvelope {
public final int schema;
public final long generatedAtEpochSeconds;
public final List<FeedItem> items;
public final FeedMeta meta;
public FeedEnvelope(int schema, long generatedAtEpochSeconds, List<FeedItem> items, FeedMeta meta) {
this.schema = schema;
this.generatedAtEpochSeconds = generatedAtEpochSeconds;
this.items = items != null ? items : new ArrayList<FeedItem>();
this.meta = meta;
}
public List<FeedItem> activeItems() {
ArrayList<FeedItem> out = new ArrayList<>();
for (int i = 0; i < items.size(); i++) {
FeedItem it = items.get(i);
if (it == null) continue;
if (it.ttlSec > 0) out.add(it);
}
return out;
}
}

View File

@@ -0,0 +1,53 @@
package sh.nym.irisglass;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
public final class FeedItem {
public final String id;
public final String type;
public final String title;
public final String subtitle;
public final double priority;
public final int ttlSec;
public final String bucket;
public final List<String> actions;
public final JSONObject raw;
public FeedItem(
String id,
String type,
String title,
String subtitle,
double priority,
int ttlSec,
String bucket,
List<String> actions
) {
this(id, type, title, subtitle, priority, ttlSec, bucket, actions, null);
}
public FeedItem(
String id,
String type,
String title,
String subtitle,
double priority,
int ttlSec,
String bucket,
List<String> actions,
JSONObject raw
) {
this.id = id;
this.type = type;
this.title = title;
this.subtitle = subtitle;
this.priority = priority;
this.ttlSec = ttlSec;
this.bucket = bucket;
this.actions = actions != null ? actions : new ArrayList<String>();
this.raw = raw;
}
}

View File

@@ -0,0 +1,25 @@
package sh.nym.irisglass;
public final class FeedItemType {
private FeedItemType() {}
public static final String WEATHER_ALERT = "WEATHER_ALERT";
public static final String WEATHER_WARNING = "WEATHER_WARNING";
public static final String TRANSIT = "TRANSIT";
public static final String POI_NEARBY = "POI_NEARBY";
public static final String INFO = "INFO";
public static final String NOW_PLAYING = "NOW_PLAYING";
public static final String CURRENT_WEATHER = "CURRENT_WEATHER";
public static final String ALL_QUIET = "ALL_QUIET";
public static final String[] ALL = new String[] {
WEATHER_ALERT,
WEATHER_WARNING,
TRANSIT,
POI_NEARBY,
INFO,
NOW_PLAYING,
CURRENT_WEATHER,
ALL_QUIET
};
}

View File

@@ -0,0 +1,12 @@
package sh.nym.irisglass;
public final class FeedMeta {
public final String winnerId;
public final int unreadCount;
public FeedMeta(String winnerId, int unreadCount) {
this.winnerId = winnerId;
this.unreadCount = unreadCount;
}
}

View File

@@ -0,0 +1,14 @@
package sh.nym.irisglass;
public final class FeedModel {
public final String winnerTitle;
public final String winnerSubtitle;
public final int moreCount;
public FeedModel(String winnerTitle, String winnerSubtitle, int moreCount) {
this.winnerTitle = winnerTitle;
this.winnerSubtitle = winnerSubtitle;
this.moreCount = moreCount;
}
}

View File

@@ -0,0 +1,38 @@
package sh.nym.irisglass;
import java.util.List;
public final class FeedModelBuilder {
private FeedModelBuilder() {}
public static FeedModel fromEnvelope(FeedEnvelope env) {
if (env == null) return new FeedModel("Glass Now online", "BLE connected", 0);
List<FeedItem> active = env.activeItems();
if (active.isEmpty()) return new FeedModel("Glass Now online", "BLE connected", 0);
String winnerId = env.meta != null ? env.meta.winnerId : null;
int unreadCount = env.meta != null ? env.meta.unreadCount : -1;
FeedItem winner = active.get(0);
if (winnerId != null && winnerId.length() > 0) {
for (int i = 0; i < active.size(); i++) {
FeedItem it = active.get(i);
if (it == null || it.id == null) continue;
if (winnerId.equals(it.id)) {
winner = it;
break;
}
}
}
String title = winner != null && winner.title != null && winner.title.length() > 0 ? winner.title : "Glass Now online";
String subtitle = winner != null && winner.subtitle != null && winner.subtitle.length() > 0 ? winner.subtitle : "BLE connected";
int moreCount = Math.max(0, active.size() - 1);
if (unreadCount >= 0) moreCount = Math.max(0, unreadCount - 1);
return new FeedModel(title, subtitle, moreCount);
}
}

View File

@@ -0,0 +1,63 @@
package sh.nym.irisglass;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
public final class FeedParser {
private FeedParser() {}
public static FeedEnvelope parseEnvelope(String json) throws JSONException {
if (json == null) throw new JSONException("json is null");
JSONObject root = new JSONObject(json);
int schema = root.optInt("schema", -1);
if (schema != 1) {
Log.w(Constants.TAG_FEED, "Unexpected schema=" + schema);
}
long generatedAt = root.optLong("generated_at", 0L);
FeedMeta meta = null;
JSONObject metaObj = root.optJSONObject("meta");
if (metaObj != null) {
String winnerId = metaObj.optString("winner_id", null);
int unreadCount = metaObj.optInt("unread_count", -1);
meta = new FeedMeta(winnerId, unreadCount);
}
ArrayList<FeedItem> items = new ArrayList<FeedItem>();
JSONArray feed = root.optJSONArray("feed");
if (feed != null) {
for (int i = 0; i < feed.length(); i++) {
JSONObject card = feed.optJSONObject(i);
if (card == null) continue;
int ttlSec = card.optInt("ttl_sec", 0);
String id = card.optString("id", null);
String type = card.optString("type", "");
String title = card.optString("title", "");
String subtitle = card.optString("subtitle", "");
double priority = card.optDouble("priority", 0.0);
String bucket = card.optString("bucket", "");
ArrayList<String> actions = new ArrayList<String>();
JSONArray actionArray = card.optJSONArray("actions");
if (actionArray != null) {
for (int a = 0; a < actionArray.length(); a++) {
String act = actionArray.optString(a, null);
if (act != null && act.length() > 0) actions.add(act);
}
}
items.add(new FeedItem(id, type, title, subtitle, priority, ttlSec, bucket, actions, card));
}
}
return new FeedEnvelope(schema, generatedAt, items, meta);
}
}

View File

@@ -0,0 +1,519 @@
package sh.nym.irisglass;
import android.util.Log;
import java.io.ByteArrayOutputStream;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
public final class FeedReassembler {
public static final class Result {
public final boolean isPing;
public final String jsonOrNull;
private Result(boolean isPing, String jsonOrNull) {
this.isPing = isPing;
this.jsonOrNull = jsonOrNull;
}
public static Result ping() {
return new Result(true, null);
}
public static Result json(String json) {
return new Result(false, json);
}
}
private static final long ASSEMBLY_TIMEOUT_MS = 15000L;
private static final int STREAM_MAX_BYTES = 64 * 1024;
private static final int FRAME_HEADER_LEN = 9;
private static final int MAX_CHUNKS = 2048;
private static final int MAX_REASSEMBLED_BYTES = 256 * 1024;
private static final int MSGTYPE_FULL_FEED = 0x01;
private static final int MSGTYPE_PING = 0x02;
private static final int LEGACY_TYPE_FEED = 0x01;
private static final int LEGACY_TYPE_PING = 0x02;
private static final class Assembly {
final int msgId;
final int totalLen;
final long createdAtMs;
long lastUpdateMs;
final TreeMap<Integer, byte[]> chunksByOffset = new TreeMap<Integer, byte[]>();
Assembly(int msgId, int totalLen, long nowMs) {
this.msgId = msgId;
this.totalLen = totalLen;
this.createdAtMs = nowMs;
this.lastUpdateMs = nowMs;
}
int receivedBytes() {
int sum = 0;
for (Map.Entry<Integer, byte[]> e : chunksByOffset.entrySet()) {
sum += e.getValue().length;
}
return sum;
}
byte[] toByteArrayIfComplete() {
if (totalLen <= 0) return null;
if (receivedBytes() < totalLen) return null;
byte[] out = new byte[totalLen];
for (Map.Entry<Integer, byte[]> e : chunksByOffset.entrySet()) {
int offset = e.getKey();
byte[] chunk = e.getValue();
if (offset < 0 || offset >= out.length) return null;
int copyLen = Math.min(chunk.length, out.length - offset);
System.arraycopy(chunk, 0, out, offset, copyLen);
}
return out;
}
}
private final Object lock = new Object();
private final HashMap<Integer, Assembly> assemblies = new HashMap<Integer, Assembly>();
private StreamAssembly stream;
private final HashMap<Integer, ChunkAssembly> chunkAssemblies = new HashMap<Integer, ChunkAssembly>();
private long lastParseErrorLogAtMs = 0L;
private long lastChunkLogAtMs = 0L;
private static final class StreamAssembly {
final ByteArrayOutputStream buf = new ByteArrayOutputStream();
long lastUpdateMs;
StreamAssembly(long nowMs) {
this.lastUpdateMs = nowMs;
}
}
private static final class ChunkAssembly {
final int msgId;
final int chunkCount;
final long createdAtMs;
long lastUpdateMs;
final byte[][] chunks;
int receivedChunks;
ChunkAssembly(int msgId, int chunkCount, long nowMs) {
this.msgId = msgId;
this.chunkCount = chunkCount;
this.createdAtMs = nowMs;
this.lastUpdateMs = nowMs;
this.chunks = new byte[chunkCount][];
this.receivedChunks = 0;
}
boolean isComplete() {
for (int i = 0; i < chunkCount; i++) {
if (chunks[i] == null) return false;
}
return true;
}
byte[] concat() {
int total = 0;
for (int i = 0; i < chunkCount; i++) {
total += chunks[i].length;
}
if (total > MAX_REASSEMBLED_BYTES) return null;
byte[] out = new byte[total];
int pos = 0;
for (int i = 0; i < chunkCount; i++) {
byte[] c = chunks[i];
System.arraycopy(c, 0, out, pos, c.length);
pos += c.length;
}
return out;
}
}
public Result onNotification(byte[] value, long nowMs) {
if (value == null || value.length == 0) return null;
// Try the specified FEED_TX frame format first; if it matches, do NOT fall back.
if (looksLikeFeedTxFrame(value)) {
return tryFeedTxFrame(value, nowMs);
}
Result simple = trySimpleText(value);
if (simple != null) return simple;
Result vA = tryBinaryV1(value, nowMs);
if (vA != null) return vA;
Result vB = tryBinaryV2(value, nowMs);
if (vB != null) return vB;
Result stream = tryJsonStream(value, nowMs);
if (stream != null) return stream;
return null;
}
// FEED_TX notify payload frame:
// [0..3] msgId (u32 LE)
// [4] msgType (u8): 1=FULL_FEED, 2=PING
// [5..6] chunkIndex (u16 LE, 0-based)
// [7..8] chunkCount (u16 LE, >=1)
// [9..] payload (UTF-8 slice; empty for PING)
private Result tryFeedTxFrame(byte[] value, long nowMs) {
if (value.length < FRAME_HEADER_LEN) return null;
int msgId = readLe32(value, 0);
int msgType = value[4] & 0xFF;
int chunkIndex = readLe16(value, 5);
int chunkCount = readLe16(value, 7);
if (msgType != MSGTYPE_FULL_FEED && msgType != MSGTYPE_PING) {
logParseErrorRateLimited(nowMs, "bad msgType=" + msgType + " msgId=" + u32(msgId), value);
return null;
}
if (chunkCount <= 0 || chunkCount > MAX_CHUNKS) {
logParseErrorRateLimited(nowMs, "bad chunkCount=" + chunkCount + " msgId=" + u32(msgId), value);
return null;
}
if (chunkIndex < 0 || chunkIndex >= chunkCount) {
logParseErrorRateLimited(nowMs, "bad chunkIndex=" + chunkIndex + " chunkCount=" + chunkCount + " msgId=" + u32(msgId), value);
return null;
}
if (msgType == MSGTYPE_PING) {
Log.d(Constants.TAG_FEED, "PING msgId=" + u32(msgId));
return Result.ping();
}
byte[] chunk = new byte[Math.max(0, value.length - FRAME_HEADER_LEN)];
if (chunk.length > 0) {
System.arraycopy(value, FRAME_HEADER_LEN, chunk, 0, chunk.length);
}
synchronized (lock) {
pruneChunkAssembliesLocked(nowMs);
ChunkAssembly a = chunkAssemblies.get(msgId);
if (a == null || a.chunkCount != chunkCount) {
if (a != null && a.chunkCount != chunkCount) {
logParseErrorRateLimited(nowMs, "chunkCount changed msgId=" + u32(msgId) + " was=" + a.chunkCount + " now=" + chunkCount, value);
}
a = new ChunkAssembly(msgId, chunkCount, nowMs);
chunkAssemblies.put(msgId, a);
Log.i(Constants.TAG_FEED, "Start FULL_FEED msgId=" + u32(msgId) + " chunkCount=" + chunkCount);
}
a.lastUpdateMs = nowMs;
if (a.chunks[chunkIndex] == null) a.receivedChunks++;
a.chunks[chunkIndex] = chunk;
logChunkProgressRateLimited(nowMs, u32(msgId), chunkIndex, chunkCount, chunk.length, a.receivedChunks);
if (a.isComplete()) {
chunkAssemblies.remove(msgId);
byte[] out = a.concat();
return jsonResultOrNull(nowMs, msgId, out, "FEED_TX");
}
}
return null;
}
private Result trySimpleText(byte[] value) {
String s;
try {
s = new String(value, "UTF-8");
} catch (UnsupportedEncodingException e) {
return null;
}
if (s.length() == 0) return null;
if (s.startsWith("PING") || s.startsWith("ping")) {
return Result.ping();
}
int first = s.indexOf('{');
int last = s.lastIndexOf('}');
if (first >= 0 && last > first) {
String json = s.substring(first, last + 1);
return Result.json(json);
}
return null;
}
private Result tryJsonStream(byte[] value, long nowMs) {
synchronized (lock) {
if (stream != null && (nowMs - stream.lastUpdateMs) > ASSEMBLY_TIMEOUT_MS) {
stream = null;
}
if (stream == null) {
int start = indexOfByte(value, (byte) '{');
if (start < 0) return null;
stream = new StreamAssembly(nowMs);
stream.buf.write(value, start, value.length - start);
stream.lastUpdateMs = nowMs;
} else {
// Assumption: per-message header is only on the first chunk; subsequent chunks are raw UTF-8 JSON bytes.
stream.buf.write(value, 0, value.length);
stream.lastUpdateMs = nowMs;
}
if (stream.buf.size() > STREAM_MAX_BYTES) {
Log.w(Constants.TAG_FEED, "Stream assembly exceeded max bytes; dropping");
stream = null;
return null;
}
byte[] buf = stream.buf.toByteArray();
int end = findJsonObjectEnd(buf);
if (end >= 0) {
try {
String json = new String(buf, 0, end + 1, "UTF-8");
this.stream = null;
return Result.json(json);
} catch (UnsupportedEncodingException e) {
this.stream = null;
return null;
}
}
}
return null;
}
// Variant A:
// [type:1][msgId:4 LE][totalLen:4 LE][offset:4 LE][payload...]
private Result tryBinaryV1(byte[] value, long nowMs) {
if (value.length < 14) return null;
int type = value[0] & 0xFF;
if (type != LEGACY_TYPE_FEED && type != LEGACY_TYPE_PING) return null;
if (type == LEGACY_TYPE_PING) return Result.ping();
int msgId = readLe32(value, 1);
int totalLen = readLe32(value, 5);
int offset = readLe32(value, 9);
int headerLen = 13;
if (totalLen <= 0 || offset < 0 || offset > totalLen) return null;
if (value.length <= headerLen) return null;
byte[] chunk = new byte[value.length - headerLen];
System.arraycopy(value, headerLen, chunk, 0, chunk.length);
synchronized (lock) {
pruneLocked(nowMs);
Assembly assembly = assemblies.get(msgId);
if (assembly == null || assembly.totalLen != totalLen) {
assembly = new Assembly(msgId, totalLen, nowMs);
assemblies.put(msgId, assembly);
}
assembly.lastUpdateMs = nowMs;
assembly.chunksByOffset.put(offset, chunk);
byte[] complete = assembly.toByteArrayIfComplete();
if (complete != null) {
assemblies.remove(msgId);
try {
String json = new String(complete, "UTF-8");
return Result.json(json);
} catch (UnsupportedEncodingException e) {
Log.w(Constants.TAG_FEED, "UTF-8 decode failed for msgId=" + msgId);
return null;
}
}
}
return null;
}
// Variant B:
// [type:1][msgId:2 LE][chunkIndex:2 LE][totalChunks:2 LE][payload...]
private Result tryBinaryV2(byte[] value, long nowMs) {
if (value.length < 8) return null;
int type = value[0] & 0xFF;
if (type != LEGACY_TYPE_FEED && type != LEGACY_TYPE_PING) return null;
if (type == LEGACY_TYPE_PING) return Result.ping();
int msgId = readLe16(value, 1);
int chunkIndex = readLe16(value, 3);
int totalChunks = readLe16(value, 5);
int headerLen = 7;
if (totalChunks <= 0 || chunkIndex < 0 || chunkIndex >= totalChunks) return null;
if (value.length <= headerLen) return null;
byte[] chunk = new byte[value.length - headerLen];
System.arraycopy(value, headerLen, chunk, 0, chunk.length);
synchronized (lock) {
pruneLocked(nowMs);
// Reuse Assembly but interpret totalLen as totalChunks, and offsets as chunkIndex.
Assembly assembly = assemblies.get(msgId);
if (assembly == null || assembly.totalLen != totalChunks) {
assembly = new Assembly(msgId, totalChunks, nowMs);
assemblies.put(msgId, assembly);
}
assembly.lastUpdateMs = nowMs;
assembly.chunksByOffset.put(chunkIndex, chunk);
if (assembly.chunksByOffset.size() >= totalChunks) {
// Concatenate in chunk order.
int totalBytes = 0;
for (int i = 0; i < totalChunks; i++) {
byte[] c = assembly.chunksByOffset.get(i);
if (c == null) return null;
totalBytes += c.length;
}
byte[] out = new byte[totalBytes];
int pos = 0;
for (int i = 0; i < totalChunks; i++) {
byte[] c = assembly.chunksByOffset.get(i);
System.arraycopy(c, 0, out, pos, c.length);
pos += c.length;
}
assemblies.remove(msgId);
try {
String json = new String(out, "UTF-8");
return Result.json(json);
} catch (UnsupportedEncodingException e) {
Log.w(Constants.TAG_FEED, "UTF-8 decode failed for msgId=" + msgId);
return null;
}
}
}
return null;
}
private void pruneLocked(long nowMs) {
if (assemblies.isEmpty()) return;
Integer[] keys = assemblies.keySet().toArray(new Integer[assemblies.size()]);
for (int i = 0; i < keys.length; i++) {
Assembly a = assemblies.get(keys[i]);
if (a == null) continue;
if (nowMs - a.lastUpdateMs > ASSEMBLY_TIMEOUT_MS) {
assemblies.remove(keys[i]);
Log.w(Constants.TAG_FEED, "Assembly timeout msgId=" + keys[i]);
}
}
}
private void pruneChunkAssembliesLocked(long nowMs) {
if (chunkAssemblies.isEmpty()) return;
Integer[] keys = chunkAssemblies.keySet().toArray(new Integer[chunkAssemblies.size()]);
for (int i = 0; i < keys.length; i++) {
ChunkAssembly a = chunkAssemblies.get(keys[i]);
if (a == null) continue;
if ((nowMs - a.lastUpdateMs) > ASSEMBLY_TIMEOUT_MS) {
chunkAssemblies.remove(keys[i]);
Log.w(Constants.TAG_FEED, "Chunk assembly timeout msgId=" + keys[i]);
}
}
}
private static int readLe16(byte[] b, int off) {
return (b[off] & 0xFF) | ((b[off + 1] & 0xFF) << 8);
}
private static int readLe32(byte[] b, int off) {
return (b[off] & 0xFF)
| ((b[off + 1] & 0xFF) << 8)
| ((b[off + 2] & 0xFF) << 16)
| ((b[off + 3] & 0xFF) << 24);
}
private static int indexOfByte(byte[] b, byte needle) {
for (int i = 0; i < b.length; i++) {
if (b[i] == needle) return i;
}
return -1;
}
// Returns the end index of the first complete top-level JSON object, or -1.
private static int findJsonObjectEnd(byte[] b) {
int depth = 0;
boolean inString = false;
boolean escape = false;
boolean started = false;
for (int i = 0; i < b.length; i++) {
int c = b[i] & 0xFF;
if (!started) {
if (c == '{') {
started = true;
depth = 1;
}
continue;
}
if (inString) {
if (escape) {
escape = false;
} else if (c == '\\') {
escape = true;
} else if (c == '"') {
inString = false;
}
continue;
}
if (c == '"') {
inString = true;
continue;
}
if (c == '{') {
depth++;
} else if (c == '}') {
depth--;
if (depth == 0) return i;
if (depth < 0) return -1;
}
}
return -1;
}
private void logParseErrorRateLimited(long nowMs, String reason, byte[] bytes) {
synchronized (lock) {
if (nowMs - lastParseErrorLogAtMs < 1000L) return;
lastParseErrorLogAtMs = nowMs;
}
Log.w(Constants.TAG_FEED, "Frame parse issue: " + reason + " len=" + (bytes != null ? bytes.length : 0));
}
private void logChunkProgressRateLimited(long nowMs, long msgId, int chunkIndex, int chunkCount, int payloadLen, int receivedChunks) {
synchronized (lock) {
if (nowMs - lastChunkLogAtMs < 500L) return;
lastChunkLogAtMs = nowMs;
}
Log.d(Constants.TAG_FEED, "Chunk msgId=" + msgId + " idx=" + chunkIndex + "/" + chunkCount
+ " payload=" + payloadLen + " received=" + receivedChunks + "/" + chunkCount);
}
private Result jsonResultOrNull(long nowMs, int msgId, byte[] out, String mode) {
if (out == null) return null;
try {
String json = new String(out, "UTF-8");
if (json.length() == 0 || json.charAt(0) != '{') {
logParseErrorRateLimited(nowMs, "reassembled payload not JSON object msgId=" + u32(msgId) + " mode=" + mode, out);
}
Log.i(Constants.TAG_FEED, "Complete FULL_FEED msgId=" + u32(msgId) + " bytes=" + out.length + " mode=" + mode);
return Result.json(json);
} catch (UnsupportedEncodingException e) {
Log.w(Constants.TAG_FEED, "UTF-8 decode failed for msgId=" + u32(msgId) + " mode=" + mode);
return null;
}
}
private static boolean looksLikeFeedTxFrame(byte[] value) {
if (value == null || value.length < FRAME_HEADER_LEN) return false;
int msgType = value[4] & 0xFF;
if (msgType != MSGTYPE_FULL_FEED && msgType != MSGTYPE_PING) return false;
int chunkCount = readLe16(value, 7);
int chunkIndex = readLe16(value, 5);
if (chunkCount <= 0 || chunkCount > MAX_CHUNKS) return false;
return chunkIndex >= 0 && chunkIndex < chunkCount;
}
private static long u32(int v) {
return v & 0xFFFFFFFFL;
}
}

View File

@@ -0,0 +1,350 @@
package sh.nym.irisglass;
import com.google.android.glass.media.Sounds;
import com.google.android.glass.timeline.LiveCard;
import com.google.android.glass.timeline.LiveCard.PublishMode;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Intent;
import android.content.IntentFilter;
import android.media.AudioManager;
import android.os.IBinder;
import android.os.Handler;
import android.os.Looper;
import android.os.PowerManager;
import android.text.format.DateFormat;
import android.util.Log;
import android.view.View;
import android.view.SoundEffectConstants;
import android.widget.RemoteViews;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
public final class HudService extends Service {
private static final String LIVE_CARD_TAG = "iris_now";
private static final int COLOR_GRAY = 0xFF808080;
private static final int COLOR_BLUE = 0xFF34A7FF;
private static final int COLOR_RED = 0xFFCC3333;
private static final int COLOR_GREEN = 0xFF99CC33;
private static final int COLOR_YELLOW = 0xFFDDBB11;
private LiveCard liveCard;
private String lastRenderedKey;
private String bleStatus = "Idle";
private String bleError = null;
private boolean pulseOn = false;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final Runnable clockTick = new Runnable() {
@Override
public void run() {
render(HudState.get().snapshot());
scheduleNextClockTick();
}
};
private final Runnable pulseTick = new Runnable() {
@Override
public void run() {
if (!isScanning(bleStatus) || liveCard == null) return;
pulseOn = !pulseOn;
render(HudState.get().snapshot());
mainHandler.postDelayed(this, 500L);
}
};
private final HudState.Listener hudListener = new HudState.Listener() {
@Override
public void onHudStateChanged(HudState.Snapshot snapshot, boolean shouldRender) {
if (shouldRender) {
render(snapshot);
}
}
};
private final BroadcastReceiver bleStatusReceiver = new BroadcastReceiver() {
@Override
public void onReceive(android.content.Context context, Intent intent) {
if (intent == null) return;
if (!Constants.ACTION_BLE_STATUS.equals(intent.getAction())) return;
bleStatus = intent.getStringExtra(Constants.EXTRA_BLE_STATUS);
bleError = intent.getStringExtra(Constants.EXTRA_BLE_ERROR);
updatePulse();
render(HudState.get().snapshot());
}
};
@Override
public void onCreate() {
super.onCreate();
publishLiveCard();
HudState.get().addListener(hudListener);
registerReceiver(bleStatusReceiver, new IntentFilter(Constants.ACTION_BLE_STATUS));
updatePulse();
render(HudState.get().snapshot());
scheduleNextClockTick();
startService(new Intent(this, BleLinkService.class));
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null) {
String action = intent.getAction();
if (Constants.ACTION_RESCAN.equals(action)) {
startService(new Intent(this, BleLinkService.class).setAction(Constants.ACTION_RESCAN));
} else if (Constants.ACTION_WINNER_CHANGED.equals(action)) {
handleWinnerChanged(intent);
}
}
return START_STICKY;
}
@Override
public void onDestroy() {
try {
HudState.get().removeListener(hudListener);
} catch (Throwable ignored) {
}
try {
unregisterReceiver(bleStatusReceiver);
} catch (Throwable ignored) {
}
mainHandler.removeCallbacks(clockTick);
mainHandler.removeCallbacks(pulseTick);
unpublishLiveCard();
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void publishLiveCard() {
try {
if (liveCard != null) return;
// Tap opens the swipeable feed UI.
PendingIntent pi = PendingIntent.getActivity(
this,
0,
new Intent(this, FeedActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_UPDATE_CURRENT
);
liveCard = new LiveCard(this, LIVE_CARD_TAG);
liveCard.setAction(pi);
liveCard.attach(this);
liveCard.publish(PublishMode.REVEAL);
Log.i(Constants.TAG_HUD, "LiveCard published");
} catch (Throwable t) {
Log.e(Constants.TAG_HUD, "Failed to publish LiveCard", t);
stopSelf();
}
}
private void unpublishLiveCard() {
if (liveCard == null) return;
try {
liveCard.unpublish();
Log.i(Constants.TAG_HUD, "LiveCard unpublished");
} catch (Throwable t) {
Log.w(Constants.TAG_HUD, "Failed to unpublish LiveCard: " + t);
} finally {
liveCard = null;
lastRenderedKey = null;
}
}
private void render(HudState.Snapshot snap) {
LiveCard lc = liveCard;
if (lc == null || snap == null) return;
FeedEnvelope env = new FeedEnvelope(1, 0L, snap.items, snap.meta);
java.util.List<FeedItem> active = env.activeItems();
boolean hasFeed = !active.isEmpty();
FeedModel model = hasFeed ? FeedModelBuilder.fromEnvelope(env) : null;
String title = (model != null && hasFeed) ? safe(model.winnerTitle) : "";
String subtitle = (model != null && hasFeed) ? safe(model.winnerSubtitle) : "";
int more = (model != null && hasFeed) ? model.moreCount : 0;
int dotColor = computeDotColor(bleStatus, bleError);
String dot = "\u25CF";
String timeText = formatNowTime();
String dayText = formatNowDayOfWeek();
String dateText = formatNowMonthDay();
WeatherInfo weather = WeatherInfoParser.fromEnvelope(env);
String weatherTemp = weather != null ? safe(weather.temperature) : "";
String weatherIconHint = weather != null ? safe(weather.iconText) : "";
String key = title + "\n" + subtitle + "\n" + more + "\n" + dotColor + "\n" + timeText + "\n" + dayText + "\n" + dateText + "\n" + weatherIconHint + "\n" + weatherTemp;
if (key.equals(lastRenderedKey)) return;
lastRenderedKey = key;
RemoteViews rv = new RemoteViews(getPackageName(), R.layout.hud_live_card);
rv.setTextViewText(R.id.hud_title, title);
rv.setTextViewText(R.id.hud_subtitle, subtitle);
if (!hasFeed) {
rv.setViewVisibility(R.id.hud_right_col, View.GONE);
rv.setViewVisibility(R.id.hud_more_badge, View.GONE);
} else {
rv.setViewVisibility(R.id.hud_right_col, View.VISIBLE);
if (more > 0) {
rv.setViewVisibility(R.id.hud_more_badge, View.VISIBLE);
rv.setTextViewText(R.id.hud_more_badge, "+" + more);
} else {
rv.setViewVisibility(R.id.hud_more_badge, View.GONE);
}
}
rv.setTextViewText(R.id.hud_status_dot, dot);
rv.setTextColor(R.id.hud_status_dot, dotColor);
rv.setTextViewText(R.id.hud_time, timeText);
rv.setTextViewText(R.id.hud_day, dayText);
rv.setTextViewText(R.id.hud_date, dateText);
if (weatherTemp.length() > 0) {
rv.setViewVisibility(R.id.hud_weather_row, View.VISIBLE);
int iconRes = WeatherV2Icons.resolveResId(weatherIconHint, isNightNow());
rv.setImageViewResource(R.id.hud_weather_icon, iconRes);
rv.setTextViewText(R.id.hud_weather_temp, weatherTemp);
} else {
rv.setViewVisibility(R.id.hud_weather_row, View.GONE);
}
try {
lc.setViews(rv);
} catch (Throwable t) {
Log.w(Constants.TAG_HUD, "LiveCard setViews failed: " + t);
}
}
private void handleWinnerChanged(Intent intent) {
String winnerId = intent != null ? intent.getStringExtra(Constants.EXTRA_WINNER_ID) : null;
Log.i(Constants.TAG_HUD, "Winner changed: nudge LiveCard winnerId=" + winnerId);
publishLiveCard();
render(HudState.get().snapshot());
tryWakeScreen();
tryPlayNudgeSound();
try {
if (liveCard != null) liveCard.navigate();
} catch (Throwable t) {
Log.w(Constants.TAG_HUD, "LiveCard navigate failed: " + t);
}
}
private void tryWakeScreen() {
try {
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
if (pm == null) return;
PowerManager.WakeLock wl = pm.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK
| PowerManager.ACQUIRE_CAUSES_WAKEUP
| PowerManager.ON_AFTER_RELEASE,
"IrisGlass:WinnerChanged"
);
wl.acquire(3000L);
} catch (Throwable t) {
Log.w(Constants.TAG_HUD, "Wake screen failed: " + t);
}
}
private void tryPlayNudgeSound() {
AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE);
if (am == null) return;
try {
am.playSoundEffect(Sounds.SUCCESS);
} catch (Throwable ignored) {
try {
am.playSoundEffect(SoundEffectConstants.CLICK);
} catch (Throwable t) {
Log.w(Constants.TAG_HUD, "Play sound failed: " + t);
}
}
}
private void updatePulse() {
mainHandler.removeCallbacks(pulseTick);
pulseOn = false;
if (isScanning(bleStatus)) {
mainHandler.postDelayed(pulseTick, 500L);
}
}
private boolean isScanning(String statusOrNull) {
if (statusOrNull == null) return false;
String s = statusOrNull.toLowerCase(java.util.Locale.US);
return s.contains("scan");
}
private int computeDotColor(String statusOrNull, String errorOrNull) {
String status = statusOrNull != null ? statusOrNull : "";
String err = errorOrNull != null ? errorOrNull.trim() : "";
if (err.length() > 0) return COLOR_RED;
String s = status.toLowerCase(java.util.Locale.US);
if (s.contains("connected")) return COLOR_GREEN;
if (s.contains("disconnected") || s.contains("stopped") || s.contains("bluetooth off")) return COLOR_RED;
if (isScanning(status)) return pulseOn ? COLOR_BLUE : COLOR_GRAY;
if (s.contains("connect") || s.contains("discover") || s.contains("subscrib")) return COLOR_BLUE;
return COLOR_GRAY;
}
private void scheduleNextClockTick() {
mainHandler.removeCallbacks(clockTick);
long now = System.currentTimeMillis();
long delay = 60_000L - (now % 60_000L) + 50L;
if (delay < 250L) delay = 250L;
mainHandler.postDelayed(clockTick, delay);
}
private String formatNowTime() {
try {
return new SimpleDateFormat("HH:mm", Locale.getDefault()).format(new Date());
} catch (Throwable t) {
return "";
}
}
private String formatNowDayOfWeek() {
try {
String dow = new SimpleDateFormat("EEEE", Locale.getDefault()).format(new Date());
return dow != null ? dow.trim() : "";
} catch (Throwable t) {
return "";
}
}
private String formatNowMonthDay() {
try {
// Use abbreviated month names (Jan, Feb, ...), localized.
String md = new SimpleDateFormat("MMM d", Locale.getDefault()).format(new Date());
return md != null ? md.trim() : "";
} catch (Throwable t) {
return "";
}
}
private boolean isNightNow() {
try {
int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
return hour < 6 || hour >= 18;
} catch (Throwable t) {
return false;
}
}
private static String safe(String s) {
return s != null ? s.trim() : "";
}
}

View File

@@ -0,0 +1,87 @@
package sh.nym.irisglass;
import android.os.Handler;
import android.os.Looper;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class HudState {
public static final class Snapshot {
public final List<FeedItem> items;
public final FeedMeta meta;
private Snapshot(
List<FeedItem> items,
FeedMeta meta
) {
this.items = items != null ? items : Collections.<FeedItem>emptyList();
this.meta = meta;
}
}
public interface Listener {
void onHudStateChanged(Snapshot snapshot, boolean shouldRender);
}
private static final HudState INSTANCE = new HudState();
public static HudState get() {
return INSTANCE;
}
private final Object lock = new Object();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final List<Listener> listeners = new ArrayList<Listener>();
private List<FeedItem> items = Collections.emptyList();
private FeedMeta meta = null;
private HudState() {}
public Snapshot snapshot() {
synchronized (lock) {
return new Snapshot(items, meta);
}
}
public void addListener(Listener listener) {
if (listener == null) return;
synchronized (lock) {
listeners.add(listener);
}
}
public void removeListener(Listener listener) {
if (listener == null) return;
synchronized (lock) {
listeners.remove(listener);
}
}
public void setFeed(List<FeedItem> items, FeedMeta meta, boolean shouldRender) {
List<Listener> listenerCopy;
Snapshot snapshot;
synchronized (lock) {
ArrayList<FeedItem> copy = new ArrayList<FeedItem>();
if (items != null) copy.addAll(items);
this.items = Collections.unmodifiableList(copy);
this.meta = meta;
snapshot = new Snapshot(this.items, this.meta);
listenerCopy = new ArrayList<Listener>(listeners);
}
notifyListeners(listenerCopy, snapshot, shouldRender);
}
private void notifyListeners(final List<Listener> listenerCopy, final Snapshot snapshot, final boolean shouldRender) {
mainHandler.post(new Runnable() {
@Override
public void run() {
for (int i = 0; i < listenerCopy.size(); i++) {
listenerCopy.get(i).onHudStateChanged(snapshot, shouldRender);
}
}
});
}
}

View File

@@ -0,0 +1,15 @@
package sh.nym.irisglass;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
public final class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
startService(new Intent(this, HudService.class).setAction(Constants.ACTION_START_HUD));
finish();
}
}

View File

@@ -0,0 +1,183 @@
package sh.nym.irisglass;
import com.google.android.glass.timeline.LiveCard;
import com.google.android.glass.view.WindowUtils;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import java.util.HashSet;
/**
* Manages the Glass options menu overlay (icon + label) similar to the GDK timer sample.
* This Activity immediately opens the options menu and finishes when the menu closes.
*/
public final class MenuActivity extends Activity {
public static final String EXTRA_CARD_ID = "card_id";
public static final String EXTRA_CARD_TITLE = "card_title";
public static final String EXTRA_ACTIONS = "actions";
public static final String EXTRA_ACTION = "action";
private final Handler handler = new Handler();
private boolean attachedToWindow;
private boolean isMenuClosed;
private boolean preparePanelCalled;
private boolean fromLiveCardVoice;
private String cardId;
private HashSet<String> allowedActions = new HashSet<String>();
public static Intent newIntent(Context context, FeedItem item) {
Intent i = new Intent(context, MenuActivity.class);
if (item != null) {
i.putExtra(EXTRA_CARD_ID, item.id);
i.putExtra(EXTRA_CARD_TITLE, item.title);
if (item.actions != null) {
i.putExtra(EXTRA_ACTIONS, item.actions.toArray(new String[item.actions.size()]));
}
}
return i;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Increase contrast so menu labels are readable over underlying cards.
try {
WindowManager.LayoutParams lp = getWindow().getAttributes();
lp.flags |= WindowManager.LayoutParams.FLAG_DIM_BEHIND;
lp.dimAmount = 0.65f;
getWindow().setAttributes(lp);
} catch (Throwable ignored) {
}
fromLiveCardVoice = getIntent().getBooleanExtra(LiveCard.EXTRA_FROM_LIVECARD_VOICE, false);
if (fromLiveCardVoice) {
getWindow().requestFeature(WindowUtils.FEATURE_VOICE_COMMANDS);
}
cardId = getIntent().getStringExtra(EXTRA_CARD_ID);
String[] actions = getIntent().getStringArrayExtra(EXTRA_ACTIONS);
if (actions != null) {
for (int i = 0; i < actions.length; i++) {
if (actions[i] != null) allowedActions.add(actions[i]);
}
}
}
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
attachedToWindow = true;
openMenu();
}
@Override
public void onDetachedFromWindow() {
super.onDetachedFromWindow();
attachedToWindow = false;
}
@Override
public boolean onCreatePanelMenu(int featureId, Menu menu) {
if (isMyMenu(featureId)) {
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.feed_actions, menu);
return true;
}
return super.onCreatePanelMenu(featureId, menu);
}
@Override
public boolean onPreparePanel(int featureId, View view, Menu menu) {
preparePanelCalled = true;
if (isMyMenu(featureId)) {
// Enable only actions present on the tapped card.
setOptionsMenuState(menu.findItem(R.id.action_dismiss), allowedActions.contains("DISMISS"));
setOptionsMenuState(menu.findItem(R.id.action_snooze_2h), allowedActions.contains("SNOOZE_2H"));
setOptionsMenuState(menu.findItem(R.id.action_snooze_24h), allowedActions.contains("SNOOZE_24H"));
setOptionsMenuState(menu.findItem(R.id.action_save), allowedActions.contains("SAVE"));
// Always allow Back.
setOptionsMenuState(menu.findItem(R.id.action_back), true);
return !isMenuClosed;
}
return super.onPreparePanel(featureId, view, menu);
}
@Override
public boolean onMenuItemSelected(int featureId, final MenuItem item) {
if (!isMyMenu(featureId)) return super.onMenuItemSelected(featureId, item);
if (item == null) return true;
final String action = actionForItemId(item.getItemId());
if (action == null) return true;
if ("BACK".equals(action)) {
finish();
return true;
}
// Post for proper options menu animation (per timer sample guidance).
handler.post(new Runnable() {
@Override
public void run() {
Intent r = new Intent();
r.putExtra(EXTRA_CARD_ID, cardId);
r.putExtra(EXTRA_ACTION, action);
setResult(RESULT_OK, r);
finish();
}
});
return true;
}
@Override
public void onPanelClosed(int featureId, Menu menu) {
super.onPanelClosed(featureId, menu);
if (isMyMenu(featureId)) {
isMenuClosed = true;
finish();
}
}
private void openMenu() {
if (!attachedToWindow) return;
if (fromLiveCardVoice) {
if (preparePanelCalled) {
getWindow().invalidatePanelMenu(WindowUtils.FEATURE_VOICE_COMMANDS);
}
} else {
openOptionsMenu();
}
}
private boolean isMyMenu(int featureId) {
return featureId == Window.FEATURE_OPTIONS_PANEL || featureId == WindowUtils.FEATURE_VOICE_COMMANDS;
}
private static void setOptionsMenuState(MenuItem menuItem, boolean enabled) {
if (menuItem == null) return;
menuItem.setVisible(enabled);
menuItem.setEnabled(enabled);
}
private static String actionForItemId(int id) {
if (id == R.id.action_dismiss) return "DISMISS";
if (id == R.id.action_snooze_2h) return "SNOOZE_2H";
if (id == R.id.action_snooze_24h) return "SNOOZE_24H";
if (id == R.id.action_save) return "SAVE";
if (id == R.id.action_back) return "BACK";
return null;
}
}

View File

@@ -0,0 +1,137 @@
package sh.nym.irisglass;
import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
public final class SettingsActivity extends Activity {
private final Handler handler = new Handler();
private TextView text;
private final Runnable refreshRunnable = new Runnable() {
@Override
public void run() {
refresh();
handler.postDelayed(this, 1000L);
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
startService(new Intent(this, HudService.class).setAction(Constants.ACTION_START_HUD));
ScrollView scrollView = new ScrollView(this);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setPadding(32, 32, 32, 32);
scrollView.addView(root);
text = new TextView(this);
text.setTextSize(18f);
text.setTextColor(0xFFFFFFFF);
text.setTypeface(android.graphics.Typeface.MONOSPACE);
root.addView(text, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
Button rescan = new Button(this);
rescan.setText("Rescan");
rescan.setAllCaps(false);
rescan.setGravity(Gravity.CENTER);
rescan.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
startService(new Intent(SettingsActivity.this, BleLinkService.class).setAction(Constants.ACTION_RESCAN));
}
});
root.addView(rescan, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
Button openFeed = new Button(this);
openFeed.setText("Open feed");
openFeed.setAllCaps(false);
openFeed.setGravity(Gravity.CENTER);
openFeed.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
startActivity(new Intent(SettingsActivity.this, FeedActivity.class));
}
});
root.addView(openFeed, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
scrollView.setBackgroundColor(0xFF000000);
setContentView(scrollView);
}
@Override
protected void onResume() {
super.onResume();
handler.removeCallbacks(refreshRunnable);
handler.post(refreshRunnable);
}
@Override
protected void onPause() {
handler.removeCallbacks(refreshRunnable);
super.onPause();
}
private void refresh() {
HudState.Snapshot s = HudState.get().snapshot();
StringBuilder sb = new StringBuilder();
sb.append("BLE state: ");
BluetoothAdapter a = BluetoothAdapter.getDefaultAdapter();
sb.append(a != null && a.isEnabled() ? "ON" : "OFF");
sb.append("\n");
int total = s.items != null ? s.items.size() : 0;
int active = 0;
if (s.items != null) {
for (int i = 0; i < s.items.size(); i++) {
FeedItem it = s.items.get(i);
if (it != null && it.ttlSec > 0) active++;
}
}
sb.append("Cards: ").append(total).append(" (active ").append(active).append(")\n");
if (s.meta != null) {
sb.append("Winner id: ").append(nz(s.meta.winnerId)).append("\n");
sb.append("Unread: ").append(s.meta.unreadCount).append("\n");
} else {
sb.append("Meta: -\n");
}
FeedEnvelope env = new FeedEnvelope(1, 0L, s.items, s.meta);
FeedModel m = FeedModelBuilder.fromEnvelope(env);
if (m != null) {
sb.append("\nWinner:\n");
sb.append(" ").append(nz(m.winnerTitle)).append("\n");
sb.append(" ").append(nz(m.winnerSubtitle)).append("\n");
sb.append("More: +").append(m.moreCount).append("\n");
}
text.setText(sb.toString());
}
private static String nz(String s) {
return s != null ? s : "-";
}
}

View File

@@ -0,0 +1,32 @@
package sh.nym.irisglass;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
public final class SuppressionStore {
private static final String KEY_PREFIX = "suppression_until_";
private final SharedPreferences prefs;
public SuppressionStore(Context context) {
this.prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext());
}
public boolean isSuppressed(String id, long nowEpochSeconds) {
if (id == null) return false;
long until = prefs.getLong(KEY_PREFIX + id, 0L);
if (until <= 0L) return false;
if (until <= nowEpochSeconds) {
prefs.edit().remove(KEY_PREFIX + id).apply();
return false;
}
return true;
}
public void setSuppressed(String id, long untilEpochSeconds) {
if (id == null) return;
prefs.edit().putLong(KEY_PREFIX + id, untilEpochSeconds).apply();
}
}

View File

@@ -0,0 +1,80 @@
package sh.nym.irisglass;
public final class WeatherCondition {
private WeatherCondition() {}
public static final String BLIZZARD = "BLIZZARD";
public static final String BLOWING_DUST = "BLOWING_DUST";
public static final String BLOWING_SNOW = "BLOWING_SNOW";
public static final String BREEZY = "BREEZY";
public static final String CLEAR = "CLEAR";
public static final String CLOUDY = "CLOUDY";
public static final String DRIZZLE = "DRIZZLE";
public static final String FLURRIES = "FLURRIES";
public static final String FOGGY = "FOGGY";
public static final String FREEZING_DRIZZLE = "FREEZING_DRIZZLE";
public static final String FREEZING_RAIN = "FREEZING_RAIN";
public static final String FRIGID = "FRIGID";
public static final String HAIL = "HAIL";
public static final String HAZE = "HAZE";
public static final String HEAVY_RAIN = "HEAVY_RAIN";
public static final String HEAVY_SNOW = "HEAVY_SNOW";
public static final String HOT = "HOT";
public static final String HURRICANE = "HURRICANE";
public static final String ISOLATED_THUNDERSTORMS = "ISOLATED_THUNDERSTORMS";
public static final String MOSTLY_CLEAR = "MOSTLY_CLEAR";
public static final String MOSTLY_CLOUDY = "MOSTLY_CLOUDY";
public static final String PARTLY_CLOUDY = "PARTLY_CLOUDY";
public static final String RAIN = "RAIN";
public static final String SCATTERED_THUNDERSTORMS = "SCATTERED_THUNDERSTORMS";
public static final String SLEET = "SLEET";
public static final String SMOKY = "SMOKY";
public static final String SNOW = "SNOW";
public static final String STRONG_STORMS = "STRONG_STORMS";
public static final String SUN_FLURRIES = "SUN_FLURRIES";
public static final String SUN_SHOWERS = "SUN_SHOWERS";
public static final String THUNDERSTORMS = "THUNDERSTORMS";
public static final String TROPICAL_STORM = "TROPICAL_STORM";
public static final String WINDY = "WINDY";
public static final String WINTRY_MIX = "WINTRY_MIX";
public static final String UNKNOWN = "UNKNOWN";
public static final String[] ALL = new String[] {
BLIZZARD,
BLOWING_DUST,
BLOWING_SNOW,
BREEZY,
CLEAR,
CLOUDY,
DRIZZLE,
FLURRIES,
FOGGY,
FREEZING_DRIZZLE,
FREEZING_RAIN,
FRIGID,
HAIL,
HAZE,
HEAVY_RAIN,
HEAVY_SNOW,
HOT,
HURRICANE,
ISOLATED_THUNDERSTORMS,
MOSTLY_CLEAR,
MOSTLY_CLOUDY,
PARTLY_CLOUDY,
RAIN,
SCATTERED_THUNDERSTORMS,
SLEET,
SMOKY,
SNOW,
STRONG_STORMS,
SUN_FLURRIES,
SUN_SHOWERS,
THUNDERSTORMS,
TROPICAL_STORM,
WINDY,
WINTRY_MIX,
UNKNOWN
};
}

View File

@@ -0,0 +1,30 @@
package sh.nym.irisglass;
public final class WeatherInfo {
public final String id;
public final String location;
public final String temperature;
public final String condition;
public final String hiLo;
public final String iconText;
public final long generatedAtEpochSeconds;
public WeatherInfo(
String id,
String location,
String temperature,
String condition,
String hiLo,
String iconText,
long generatedAtEpochSeconds
) {
this.id = id;
this.location = location;
this.temperature = temperature;
this.condition = condition;
this.hiLo = hiLo;
this.iconText = iconText;
this.generatedAtEpochSeconds = generatedAtEpochSeconds;
}
}

View File

@@ -0,0 +1,269 @@
package sh.nym.irisglass;
import org.json.JSONObject;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class WeatherInfoParser {
private WeatherInfoParser() {}
public static WeatherInfo fromEnvelope(FeedEnvelope env) {
if (env == null) return null;
JSONObject bestCard = null;
double bestPriority = -1e9;
for (int i = 0; i < env.items.size(); i++) {
FeedItem it = env.items.get(i);
if (it == null) continue;
if (it.ttlSec <= 0) continue;
if (it.bucket == null || !BucketType.FYI.equalsIgnoreCase(it.bucket)) continue;
boolean looksLikeWeather = false;
if (it.type != null) {
String t = it.type.toUpperCase(Locale.US);
looksLikeWeather = t.contains("WEATHER");
}
JSONObject raw = it.raw;
if (raw == null) continue;
if (!looksLikeWeather && raw.optJSONObject("weather") == null) continue;
double p = it.priority;
if (bestCard == null || p > bestPriority) {
bestCard = raw;
bestPriority = p;
}
}
if (bestCard == null) return null;
JSONObject w = bestCard.optJSONObject("weather");
JSONObject src = w != null ? w : bestCard;
String id = bestCard.optString("id", "weather");
String rawTitle = bestCard.optString("title", "");
String rawSubtitle = bestCard.optString("subtitle", "");
String location = firstNonEmpty(
src.optString("location", null),
src.optString("place", null)
);
String temp = firstNonEmpty(
src.optString("temperature", null),
src.optString("temp", null),
formatDegree(src, "temp_f"),
formatDegree(src, "temp_c"),
src.optString("temp_text", null)
);
String condition = firstNonEmpty(
src.optString("condition", null),
bestCard.optString("condition", null),
src.optString("summary", null),
src.optString("text", null)
);
String hi = firstNonEmpty(
formatDegree(src, "high_f"),
formatDegree(src, "hi_f"),
formatDegree(src, "high_c"),
formatDegree(src, "hi_c"),
src.optString("high", null),
src.optString("hi", null)
);
String lo = firstNonEmpty(
formatDegree(src, "low_f"),
formatDegree(src, "lo_f"),
formatDegree(src, "low_c"),
formatDegree(src, "lo_c"),
src.optString("low", null),
src.optString("lo", null)
);
String hiLo = "";
if (hi != null || lo != null) {
String hiText = hi != null ? ("H " + hi) : "";
String loText = lo != null ? ("L " + lo) : "";
if (hiText.length() > 0 && loText.length() > 0) hiLo = hiText + " " + loText;
else if (hiText.length() > 0) hiLo = hiText;
else hiLo = loText;
}
if (w == null) {
WeatherTitleParse parsedTitle = parseWeatherFromText(rawTitle);
if (parsedTitle != null) {
if (location == null) location = parsedTitle.location;
if (temp == null) temp = parsedTitle.temperature;
if (condition == null) condition = parsedTitle.condition;
}
if (temp == null) {
WeatherTitleParse parsedSubtitle = parseWeatherFromText(rawSubtitle);
if (parsedSubtitle != null) temp = parsedSubtitle.temperature;
}
}
if ((hiLo == null || hiLo.length() == 0) && rawSubtitle != null) {
String s = rawSubtitle.trim();
if (s.length() > 0) {
String lower = s.toLowerCase(Locale.US);
if (lower.startsWith("feels")) {
hiLo = s;
}
}
}
if (temp == null) temp = firstNonEmpty(rawSubtitle, "");
if (condition == null) {
String s = rawSubtitle != null ? rawSubtitle.trim() : "";
if (s.length() > 0 && !s.toLowerCase(Locale.US).startsWith("feels")) {
condition = s;
} else {
condition = "";
}
}
if (location == null) location = nz(rawTitle, "Weather");
String iconText = pickWeatherIconToken(firstNonEmpty(
src.optString("icon", null),
bestCard.optString("icon", null),
src.optString("condition", null),
bestCard.optString("condition", null),
condition
));
location = truncate(location, 24);
condition = truncate(condition, 28);
hiLo = truncate(hiLo, 28);
return new WeatherInfo(id, location, temp, condition, hiLo, iconText, env.generatedAtEpochSeconds);
}
private static final class WeatherTitleParse {
final String location;
final String temperature;
final String condition;
private WeatherTitleParse(String location, String temperature, String condition) {
this.location = location;
this.temperature = temperature;
this.condition = condition;
}
}
private static final Pattern TEMP_TOKEN = Pattern.compile("(-?\\d+(?:\\.\\d+)?)\\s*\\u00B0\\s*([cCfF])?");
private static WeatherTitleParse parseWeatherFromText(String text) {
if (text == null) return null;
String t = text.trim();
if (t.length() == 0) return null;
Matcher m = TEMP_TOKEN.matcher(t);
if (!m.find()) return null;
String number = m.group(1);
String unit = m.group(2);
String temp = number + "\u00B0" + (unit != null ? unit.toUpperCase(Locale.US) : "");
String before = t.substring(0, m.start()).trim();
String after = t.substring(m.end()).trim();
after = stripLeadingSeparators(after);
String location = before.length() > 0 ? before : null;
String condition = after.length() > 0 ? after : null;
return new WeatherTitleParse(location, temp, condition);
}
private static String stripLeadingSeparators(String s) {
if (s == null) return "";
int i = 0;
while (i < s.length()) {
char c = s.charAt(i);
if (c == '-' || c == ':' || c == ',' || c == '\u00B7') {
i++;
continue;
}
if (Character.isWhitespace(c)) {
i++;
continue;
}
break;
}
return s.substring(i).trim();
}
private static String truncate(String s, int maxChars) {
if (s == null) return "";
if (s.length() <= maxChars) return s;
if (maxChars <= 1) return s.substring(0, maxChars);
return s.substring(0, maxChars - 1) + "\u2026";
}
private static String nz(String s, String fallback) {
return (s == null || s.length() == 0) ? fallback : s;
}
private static String firstNonEmpty(String... values) {
if (values == null) return null;
for (int i = 0; i < values.length; i++) {
String v = values[i];
if (v != null && v.length() > 0) return v;
}
return null;
}
private static String formatDegree(JSONObject src, String key) {
if (src == null || key == null) return null;
if (!src.has(key)) return null;
double v = src.optDouble(key, Double.NaN);
if (Double.isNaN(v)) return null;
int rounded = (int) Math.round(v);
return rounded + "\u00B0";
}
private static String pickWeatherIconToken(String hint) {
if (hint == null) return "sunny";
String h = hint.toLowerCase(Locale.US).trim();
if (h.length() == 0) return "sunny";
if (h.endsWith(".png")) h = h.substring(0, h.length() - 4);
h = h.replace(' ', '_').replace('-', '_');
// If the hint already looks like a v2 icon name, keep it.
if (h.startsWith("mostly_")
|| h.startsWith("partly_")
|| h.startsWith("scattered_")
|| h.startsWith("isolated_")
|| h.contains("tstorms")
|| h.contains("wintry_mix")
|| h.contains("haze_fog")) {
return h;
}
if (h.contains("tornado")) return "tornado";
if (h.contains("blizzard")) return "blizzard";
if (h.contains("blowing") && h.contains("snow")) return "blowing_snow";
if (h.contains("thunder") || h.contains("tstorm") || h.contains("storm") || h.contains("lightning")) return "strong_tstorms";
if (h.contains("hail") || h.contains("sleet") || h.contains("ice_pellet") || h.contains("freezing_rain")) return "sleet_hail";
if (h.contains("freezing_drizzle")) return "drizzle";
if ((h.contains("rain") && h.contains("snow")) || h.contains("wintry_mix")) return "wintry_mix_rain_snow";
if (h.contains("heavy") && h.contains("snow")) return "heavy_snow";
if (h.contains("flurr")) return "flurries";
if (h.contains("snow")) return "snow_showers_snow";
if (h.contains("heavy") && h.contains("rain")) return "heavy_rain";
if (h.contains("drizzle") || h.contains("sprinkle")) return "drizzle";
if (h.contains("shower") || h.contains("rain")) return "showers_rain";
if (h.contains("haze") || h.contains("fog") || h.contains("mist") || h.contains("smoke") || h.contains("dust")) return "haze_fog_dust_smoke";
if (h.contains("breezy") || h.contains("windy") || h.equals("wind")) return "mostly_sunny";
if (h.contains("mostly") && h.contains("cloud")) return "mostly_cloudy";
if (h.contains("partly") && h.contains("cloud")) return "partly_cloudy";
if (h.contains("cloud") || h.contains("overcast")) return "cloudy";
if (h.contains("night")) return "clear_night";
return "sunny";
}
}

View File

@@ -0,0 +1,143 @@
package sh.nym.irisglass;
import java.util.Locale;
public final class WeatherV2Icons {
private WeatherV2Icons() {}
public static int resolveResId(String hintOrToken, boolean isNight) {
String h = normalize(hintOrToken);
if (h.length() == 0) return R.drawable.weather_v2_sunny;
// If the feed already sends a v2 filename, use it directly.
int direct = resolveByV2Name(h);
if (direct != 0) return direct;
// Otherwise map fuzzy tokens/phrases to a v2 icon.
if (containsAny(h, "tornado")) return R.drawable.weather_v2_tornado;
if (containsAny(h, "blizzard")) return R.drawable.weather_v2_blizzard;
if (containsAny(h, "blowing_snow")) return R.drawable.weather_v2_blowing_snow;
if (containsAny(h, "isolated_thunderstorms", "scattered_thunderstorms")) {
return isNight ? R.drawable.weather_v2_isolated_scattered_tstorms_night : R.drawable.weather_v2_isolated_scattered_tstorms_day;
}
if (containsAny(h, "thunder", "tstorm", "storm", "lightning")) return R.drawable.weather_v2_strong_tstorms;
if (containsAny(h, "sleet", "hail", "ice_pellet", "icepellet", "freezing_rain")) {
return R.drawable.weather_v2_sleet_hail;
}
if (containsAny(h, "wintry_mix", "mix_rain_snow", "rain_snow", "sleet_snow")) {
return R.drawable.weather_v2_wintry_mix_rain_snow;
}
if (containsAny(h, "heavy_snow")) return R.drawable.weather_v2_heavy_snow;
if (containsAny(h, "snow", "flurry")) return R.drawable.weather_v2_snow_showers_snow;
if (containsAny(h, "heavy_rain", "downpour", "pouring")) return R.drawable.weather_v2_heavy_rain;
if (containsAny(h, "drizzle", "sprinkle")) return R.drawable.weather_v2_drizzle;
if (containsAny(h, "shower", "rain")) return R.drawable.weather_v2_showers_rain;
if (containsAny(h, "haze", "fog", "mist", "smoke", "dust")) return R.drawable.weather_v2_haze_fog_dust_smoke;
if (containsAny(h, "sun_showers")) return R.drawable.weather_v2_scattered_showers_day;
if (containsAny(h, "sun_flurries")) return R.drawable.weather_v2_flurries;
if (containsAny(h, "breezy", "windy", "wind")) return R.drawable.weather_v2_mostly_sunny;
if (containsAny(h, "hurricane", "tropical_storm")) return R.drawable.weather_v2_strong_tstorms;
if (containsAny(h, "hot", "frigid")) return isNight ? R.drawable.weather_v2_clear_night : R.drawable.weather_v2_sunny;
if (containsAny(h, "mostly_cloudy")) {
return isNight ? R.drawable.weather_v2_mostly_cloudy_night : R.drawable.weather_v2_mostly_cloudy_day;
}
if (containsAny(h, "partly_cloudy", "partly")) {
return isNight ? R.drawable.weather_v2_partly_cloudy_night : R.drawable.weather_v2_partly_cloudy;
}
if (containsAny(h, "mostly_sunny")) return R.drawable.weather_v2_mostly_sunny;
if (containsAny(h, "cloud", "overcast")) return R.drawable.weather_v2_cloudy;
if (containsAny(h, "clear_night", "night")) return R.drawable.weather_v2_clear_night;
if (containsAny(h, "mostly_clear")) {
return isNight ? R.drawable.weather_v2_mostly_clear_night : R.drawable.weather_v2_mostly_sunny;
}
if (containsAny(h, "clear", "sun", "fair")) return isNight ? R.drawable.weather_v2_clear_night : R.drawable.weather_v2_sunny;
return isNight ? R.drawable.weather_v2_clear_night : R.drawable.weather_v2_sunny;
}
private static int resolveByV2Name(String v2Name) {
switch (v2Name) {
case "blizzard":
return R.drawable.weather_v2_blizzard;
case "blowing_snow":
return R.drawable.weather_v2_blowing_snow;
case "clear_night":
return R.drawable.weather_v2_clear_night;
case "cloudy":
return R.drawable.weather_v2_cloudy;
case "drizzle":
return R.drawable.weather_v2_drizzle;
case "flurries":
return R.drawable.weather_v2_flurries;
case "haze_fog_dust_smoke":
return R.drawable.weather_v2_haze_fog_dust_smoke;
case "heavy_rain":
return R.drawable.weather_v2_heavy_rain;
case "heavy_snow":
return R.drawable.weather_v2_heavy_snow;
case "isolated_scattered_tstorms_day":
return R.drawable.weather_v2_isolated_scattered_tstorms_day;
case "isolated_scattered_tstorms_night":
return R.drawable.weather_v2_isolated_scattered_tstorms_night;
case "mostly_clear_night":
return R.drawable.weather_v2_mostly_clear_night;
case "mostly_cloudy_day":
return R.drawable.weather_v2_mostly_cloudy_day;
case "mostly_cloudy_night":
return R.drawable.weather_v2_mostly_cloudy_night;
case "mostly_sunny":
return R.drawable.weather_v2_mostly_sunny;
case "partly_cloudy":
return R.drawable.weather_v2_partly_cloudy;
case "partly_cloudy_night":
return R.drawable.weather_v2_partly_cloudy_night;
case "scattered_showers_day":
return R.drawable.weather_v2_scattered_showers_day;
case "scattered_showers_night":
return R.drawable.weather_v2_scattered_showers_night;
case "showers_rain":
return R.drawable.weather_v2_showers_rain;
case "sleet_hail":
return R.drawable.weather_v2_sleet_hail;
case "snow_showers_snow":
return R.drawable.weather_v2_snow_showers_snow;
case "strong_tstorms":
return R.drawable.weather_v2_strong_tstorms;
case "sunny":
return R.drawable.weather_v2_sunny;
case "tornado":
return R.drawable.weather_v2_tornado;
case "wintry_mix_rain_snow":
return R.drawable.weather_v2_wintry_mix_rain_snow;
default:
return 0;
}
}
private static String normalize(String s) {
if (s == null) return "";
String t = s.trim().toLowerCase(Locale.US);
if (t.endsWith(".png")) t = t.substring(0, t.length() - 4);
t = t.replace(' ', '_');
t = t.replace('-', '_');
return t;
}
private static boolean containsAny(String haystack, String... needles) {
if (haystack == null || haystack.length() == 0 || needles == null) return false;
for (int i = 0; i < needles.length; i++) {
String n = needles[i];
if (n == null || n.length() == 0) continue;
if (haystack.contains(n)) return true;
}
return false;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
android:padding="12dp">
<LinearLayout
android:id="@+id/hud_cols"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/hud_left_col"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/hud_time"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="sans-serif-thin"
android:includeFontPadding="false"
android:singleLine="true"
android:textColor="#FFFFFF"
android:textSize="64sp" />
<TextView
android:id="@+id/hud_day"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="sans-serif"
android:includeFontPadding="false"
android:singleLine="true"
android:textColor="#808080"
android:textSize="24sp" />
<TextView
android:id="@+id/hud_date"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="sans-serif"
android:includeFontPadding="false"
android:singleLine="true"
android:textColor="#808080"
android:textSize="24sp" />
<LinearLayout
android:id="@+id/hud_weather_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:id="@+id/hud_weather_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginRight="8dp"
android:scaleType="centerInside"
android:contentDescription="@string/app_name" />
<TextView
android:id="@+id/hud_weather_temp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="sans-serif-light"
android:includeFontPadding="false"
android:singleLine="true"
android:textColor="#FFFFFF"
android:textSize="32sp" />
</LinearLayout>
</LinearLayout>
<RelativeLayout
android:id="@+id/hud_right_col"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_marginLeft="16dp">
<TextView
android:id="@+id/hud_more_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_alignTop="@id/hud_title"
android:fontFamily="sans-serif-light"
android:textColor="#34A7FF"
android:textSize="28sp"
android:singleLine="true" />
<TextView
android:id="@+id/hud_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_toLeftOf="@id/hud_more_badge"
android:layout_marginRight="12dp"
android:ellipsize="end"
android:fontFamily="sans-serif-light"
android:includeFontPadding="false"
android:singleLine="true"
android:textColor="#FFFFFF"
android:textSize="32sp" />
<TextView
android:id="@+id/hud_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/hud_title"
android:ellipsize="end"
android:fontFamily="sans-serif-light"
android:includeFontPadding="false"
android:singleLine="true"
android:textColor="#808080"
android:textSize="24sp" />
</RelativeLayout>
</LinearLayout>
<TextView
android:id="@+id/hud_status_dot"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_alignParentBottom="true"
android:fontFamily="sans-serif"
android:includeFontPadding="false"
android:singleLine="true"
android:textColor="#808080"
android:textSize="8sp" />
</RelativeLayout>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_dismiss"
android:icon="@android:drawable/ic_menu_close_clear_cancel"
android:title="Dismiss"
android:titleCondensed="Dismiss" />
<item
android:id="@+id/action_snooze_2h"
android:icon="@android:drawable/ic_menu_recent_history"
android:title="Snooze 2 hours"
android:titleCondensed="Snooze 2 hours" />
<item
android:id="@+id/action_snooze_24h"
android:icon="@android:drawable/ic_menu_recent_history"
android:title="Snooze 24 hours"
android:titleCondensed="Snooze 24 hours" />
<item
android:id="@+id/action_save"
android:icon="@android:drawable/ic_menu_save"
android:title="Save"
android:titleCondensed="Save" />
<item
android:id="@+id/action_back"
android:icon="@android:drawable/ic_menu_revert"
android:title="Back"
android:titleCondensed="Back" />
</menu>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,20 @@
<resources>
<style name="Theme.IrisGlass" parent="@android:style/Theme.Holo">
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="Theme.IrisGlass.Fullscreen" parent="@android:style/Theme.Black.NoTitleBar.Fullscreen">
<item name="android:windowBackground">@android:color/black</item>
</style>
<!-- Matches the GDK timer sample MenuTheme to get the correct Glass menu overlay behavior. -->
<style name="Theme.IrisGlass.MenuOverlay" parent="@android:style/Theme.Holo">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowAnimationStyle">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowContentOverlay">@null</item>
</style>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Iris Glass</string>
</resources>

View File

@@ -0,0 +1,20 @@
<resources>
<style name="Theme.IrisGlass" parent="@android:style/Theme.Holo">
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="Theme.IrisGlass.Fullscreen" parent="@android:style/Theme.Black.NoTitleBar.Fullscreen">
<item name="android:windowBackground">@android:color/black</item>
</style>
<!-- Matches the GDK timer sample MenuTheme to get the correct Glass menu overlay behavior. -->
<style name="Theme.IrisGlass.MenuOverlay" parent="@android:style/Theme.Holo">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowAnimationStyle">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowContentOverlay">@null</item>
</style>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package sh.nym.irisglass;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}