diff --git a/.gitmodules b/.gitmodules index a65ddbf..126fab3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,8 +1,8 @@ [submodule "externals/x-ray"] path = externals/x-ray - url = git@github.com:inckie/x-ray.git + url = https://github.com/inckie/x-ray.git shallow = true [submodule "externals/glass-enterprise-samples"] path = externals/glass-enterprise-samples - url = git@github.com:inckie/glass-enterprise-samples.git + url = https://github.com/inckie/glass-enterprise-samples.git branch = feat/update-project diff --git a/glass-ee/src/main/AndroidManifest.xml b/glass-ee/src/main/AndroidManifest.xml index 0000140..c6dbf1b 100644 --- a/glass-ee/src/main/AndroidManifest.xml +++ b/glass-ee/src/main/AndroidManifest.xml @@ -23,6 +23,10 @@ tools:ignore="MockLocation,ProtectedPermissions" /> + + + + diff --git a/glass-shared/src/main/java/com/damn/glass/shared/rpc/WiFiClient.kt b/glass-shared/src/main/java/com/damn/glass/shared/rpc/WiFiClient.kt index d6488b4..53fa0fa 100644 --- a/glass-shared/src/main/java/com/damn/glass/shared/rpc/WiFiClient.kt +++ b/glass-shared/src/main/java/com/damn/glass/shared/rpc/WiFiClient.kt @@ -92,9 +92,9 @@ class WiFiClient(private val hostIP: String? = null) : IRPCClient { return } } - while (inputStream.available() > 0) { + while (serializer.isReady || inputStream.available() > 0) { val message = serializer.readMessage() - if (message.service == null) { + if (message?.service == null) { return } handler.onDataReceived(message) diff --git a/glass-xe/src/main/AndroidManifest.xml b/glass-xe/src/main/AndroidManifest.xml index a7f6298..c31df3b 100644 --- a/glass-xe/src/main/AndroidManifest.xml +++ b/glass-xe/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + diff --git a/glass-xe/src/main/java/com/damn/anotherglass/glass/host/bluetooth/BluetoothClient.java b/glass-xe/src/main/java/com/damn/anotherglass/glass/host/bluetooth/BluetoothClient.java index 3337113..16370f4 100644 --- a/glass-xe/src/main/java/com/damn/anotherglass/glass/host/bluetooth/BluetoothClient.java +++ b/glass-xe/src/main/java/com/damn/anotherglass/glass/host/bluetooth/BluetoothClient.java @@ -20,6 +20,7 @@ import com.damn.anotherglass.shared.utility.DisconnectReceiver; import com.damn.anotherglass.shared.utility.Sleep; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.Set; @@ -48,6 +49,7 @@ public Connection(Context context, RPCMessageListener listener) { mHandler = new RPCHandler(listener); } + @SuppressLint("MissingPermission") @Override public void run() { try { @@ -56,6 +58,7 @@ public void run() { // return; // } BluetoothAdapter bt = BluetoothAdapter.getDefaultAdapter(); + bt.cancelDiscovery(); Set pairedDevices = bt.getBondedDevices(); if (null == pairedDevices || pairedDevices.isEmpty()) { Log.e(TAG, "No paired devices found, aborting the connection"); @@ -68,6 +71,8 @@ public void run() { break; } } + } catch (IOException e) { + Log.w(TAG, "Connection lost: " + e.getMessage()); } catch (Exception e) { Log.e(TAG, "Connection exception", e); } finally { @@ -97,35 +102,52 @@ public void shutdown() { @SuppressLint("MissingPermission") private void runLoop(@NonNull BluetoothDevice device) throws Exception { - try (BluetoothSocket socket = device.createInsecureRfcommSocketToServiceRecord(Constants.uuid)) { - socket.connect(); + BluetoothSocket socket = null; + try { + try { + socket = device.createInsecureRfcommSocketToServiceRecord(Constants.uuid); + socket.connect(); + } catch (Exception e) { + Log.w(TAG, "Standard connection failed, trying fallback: " + e.getMessage()); + if (socket != null) + socket.close(); + Sleep.sleep(500); + socket = (BluetoothSocket) device.getClass().getMethod("createRfcommSocket", int.class).invoke(device, 1); + socket.connect(); + } Log.i(TAG, "Client has connected to " + device.getName()); AtomicBoolean active = new AtomicBoolean(true); - try (DisconnectReceiver ignored = new DisconnectReceiver(mContext, device, () -> active.getAndSet(false))) { - try (OutputStream outputStream = socket.getOutputStream(); - InputStream inputStream = socket.getInputStream()) { - IMessageSerializer serializer = SerializerProvider.getSerializer(inputStream, outputStream); - mConnected = true; - mHandler.onConnectionStarted(device.getName()); - while (active.get()) { - while (null != mQueue.peek()) { - RPCMessage message = mQueue.take(); - serializer.writeMessage(message); - Log.v(TAG, "Message " + message.service + "/" + message.type + " was sent"); - if (null == message.service) { - Log.d(TAG, "Shutdown requested"); - return; - } + try (DisconnectReceiver ignored = new DisconnectReceiver(mContext, device, () -> active.getAndSet(false)); + OutputStream outputStream = socket.getOutputStream(); + InputStream inputStream = socket.getInputStream()) { + IMessageSerializer serializer = SerializerProvider.getSerializer(inputStream, outputStream); + mConnected = true; + mHandler.onConnectionStarted(device.getName()); + while (active.get()) { + while (null != mQueue.peek()) { + RPCMessage message = mQueue.take(); + serializer.writeMessage(message); + Log.v(TAG, "Message " + message.service + "/" + message.type + " was sent"); + if (null == message.service) { + Log.d(TAG, "Shutdown requested"); + return; } - while (inputStream.available() > 0) { - RPCMessage objectReceived = serializer.readMessage(); - mHandler.onDataReceived(objectReceived); - Log.v(TAG, "Message " + objectReceived.service + "/" + objectReceived.type + " was received"); + } + while (serializer.isReady() || inputStream.available() > 0) { + RPCMessage objectReceived = serializer.readMessage(); + if (null == objectReceived || null == objectReceived.service) { + Log.d(TAG, "Remote shutdown or connection lost"); + return; } - Sleep.sleep(100); + mHandler.onDataReceived(objectReceived); + Log.v(TAG, "Message " + objectReceived.service + "/" + objectReceived.type + " was received"); } + Sleep.sleep(100); } } + } finally { + if (socket != null) + socket.close(); } } diff --git a/glass-xe/src/main/java/com/damn/anotherglass/glass/host/notifications/NotificationViewBuilder.java b/glass-xe/src/main/java/com/damn/anotherglass/glass/host/notifications/NotificationViewBuilder.java index 266e709..60c641f 100644 --- a/glass-xe/src/main/java/com/damn/anotherglass/glass/host/notifications/NotificationViewBuilder.java +++ b/glass-xe/src/main/java/com/damn/anotherglass/glass/host/notifications/NotificationViewBuilder.java @@ -14,16 +14,78 @@ public class NotificationViewBuilder { public static CardBuilder buildView(Context context, NotificationData data) { - // basic - CardBuilder builder = new CardBuilder(context, CardBuilder.Layout.AUTHOR) - .setHeading(data.title) - .setSubheading(data.packageName) // todo: should be application name - .setText(data.text); + // basic layout selection + CardBuilder.Layout layout; + if (null != data.image && null != data.image.bytes) { + layout = CardBuilder.Layout.CAPTION; + } else if (null != data.icon && null != data.icon.bytes) { + layout = CardBuilder.Layout.COLUMNS; + } else if (null != data.messages && !data.messages.isEmpty()) { + layout = CardBuilder.Layout.COLUMNS; + } else { + layout = CardBuilder.Layout.TEXT; + } + + CardBuilder builder = new CardBuilder(context, layout); + + // handle text / conversation + StringBuilder text = new StringBuilder(); + if (null != data.conversationTitle) { + text.append(data.conversationTitle).append("\n"); + if (null != data.title && !data.isGroupConversation) { + // For 1-on-1, title is redundant if conversationTitle is present + } else if (null != data.title) { + text.append(data.title).append(": "); + } + } else if (null != data.title) { + text.append(data.title).append("\n"); + } - // icon + if (null != data.messages && !data.messages.isEmpty()) { + for (NotificationData.Message msg : data.messages) { + if (text.length() > 0 && text.charAt(text.length() - 1) != '\n') { + text.append("\n"); + } + if (null != msg.sender && (data.isGroupConversation || !msg.sender.equals(data.title))) { + text.append(msg.sender).append(": "); + } + text.append(msg.text); + } + } else if (null != data.text) { + if (text.length() > 0 && text.charAt(text.length() - 1) != '\n') { + text.append("\n"); + } + text.append(data.text); + } + builder.setText(text.toString()); + builder.setFootnote(null != data.appName ? data.appName : data.packageName); + + // icon (App Icon) if (null != data.icon && null != data.icon.bytes) { Bitmap bitmap = BitmapFactory.decodeByteArray(data.icon.bytes, 0, data.icon.bytes.length); builder.setIcon(bitmap); + + // In COLUMNS layout, if we have no images but have an icon, + // putting the icon in addImage makes it a nice large side-image + if (layout == CardBuilder.Layout.COLUMNS && (null == data.messages || data.messages.isEmpty())) { + builder.addImage(bitmap); + } + } + + // image (Background or Mosaic) + if (null != data.image && null != data.image.bytes) { + Bitmap bitmap = BitmapFactory.decodeByteArray(data.image.bytes, 0, data.image.bytes.length); + builder.addImage(bitmap); + } else if (null != data.messages && !data.messages.isEmpty()) { + // Mosaic of senders + int added = 0; + for (NotificationData.Message msg : data.messages) { + if (null != msg.senderIcon && null != msg.senderIcon.bytes) { + Bitmap bitmap = BitmapFactory.decodeByteArray(msg.senderIcon.bytes, 0, msg.senderIcon.bytes.length); + builder.addImage(bitmap); + if (++added >= 5) break; // Glass mosaic limit + } + } } // time diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..5c34300 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index ad3b6d4..b031e2d 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + diff --git a/mobile/src/main/java/com/damn/anotherglass/core/BluetoothHost.java b/mobile/src/main/java/com/damn/anotherglass/core/BluetoothHost.java index 8192bf2..30eac43 100644 --- a/mobile/src/main/java/com/damn/anotherglass/core/BluetoothHost.java +++ b/mobile/src/main/java/com/damn/anotherglass/core/BluetoothHost.java @@ -151,9 +151,9 @@ private void runLoop(BluetoothSocket socket) throws Exception { OutputStream outputStream = socket.getOutputStream()) { IMessageSerializer serializer = SerializerProvider.getSerializer(inputStream, outputStream); while (mActive) { - while (inputStream.available() > 0) { + while (serializer.isReady() || inputStream.available() > 0) { RPCMessage objectReceived = serializer.readMessage(); - if (null == objectReceived.service) + if (null == objectReceived || null == objectReceived.service) return; // shutdown requested mHandler.onDataReceived(objectReceived); } diff --git a/mobile/src/main/java/com/damn/anotherglass/core/WiFiHost.kt b/mobile/src/main/java/com/damn/anotherglass/core/WiFiHost.kt index f21cd42..dc4e6db 100644 --- a/mobile/src/main/java/com/damn/anotherglass/core/WiFiHost.kt +++ b/mobile/src/main/java/com/damn/anotherglass/core/WiFiHost.kt @@ -110,9 +110,9 @@ class WiFiHost(listener: RPCMessageListener) : IRPCHost { return // disconnect requested } } - while (mActive && inputStream.available() > 0) { + while (mActive && (serializer.isReady || inputStream.available() > 0)) { val message = serializer.readMessage() - if (message.service == null) { + if (message?.service == null) { return // client disconnected } mHandler.onDataReceived(message) diff --git a/mobile/src/main/java/com/damn/anotherglass/extensions/notifications/Converter.kt b/mobile/src/main/java/com/damn/anotherglass/extensions/notifications/Converter.kt index 4a600a7..1a7695c 100644 --- a/mobile/src/main/java/com/damn/anotherglass/extensions/notifications/Converter.kt +++ b/mobile/src/main/java/com/damn/anotherglass/extensions/notifications/Converter.kt @@ -6,6 +6,8 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable +import android.os.Build +import android.os.Bundle import android.service.notification.StatusBarNotification import androidx.core.graphics.createBitmap import com.applicaster.xray.core.Logger @@ -16,13 +18,14 @@ import com.damn.anotherglass.utility.toPngBinaryData object Converter { private const val TAG = "IconConverter" private val log = ALog(Logger.get(TAG)) + fun convert( context: Context, - acton: NotificationData.Action, + action: NotificationData.Action, sbn: StatusBarNotification ): NotificationData { val data = NotificationData() - data.action = acton + data.action = action // parse basic data data.id = sbn.id @@ -30,28 +33,104 @@ object Converter { data.postedTime = sbn.postTime data.isOngoing = sbn.isOngoing - // todo: code below this point is not really needed for NotificationData.Action.Removed + val pm = context.packageManager + try { + val ai = pm.getApplicationInfo(data.packageName, 0) + data.appName = pm.getApplicationLabel(ai).toString() + } catch (e: Exception) { + data.appName = data.packageName + } - // todo: extract app name + if (action == NotificationData.Action.Removed) return data - // parse Notification data val notification = sbn.notification - data.title = notification.extras.getString(Notification.EXTRA_TITLE) - data.text = notification.extras.getString(Notification.EXTRA_TEXT) + val extras = notification.extras + + data.title = extras.getString(Notification.EXTRA_TITLE) + data.text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() + + // handle BigText + extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.let { + data.text = it.toString() + } + + // handle InboxStyle + extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.let { lines -> + if (lines.isNotEmpty()) { + data.text = lines.joinToString("\n") + } + } + + // handle MessagingStyle (Manual Bundle Parsing for maximum compatibility and avoiding GDK compile issues) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + extras.getCharSequence(Notification.EXTRA_CONVERSATION_TITLE)?.let { + data.conversationTitle = it.toString() + } + + // Check for group conversation (API 28+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + data.isGroupConversation = extras.getBoolean("android.isGroupConversation") + } + + val messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES) + if (messages != null) { + for (m in messages) { + if (m is Bundle) { + val msg = NotificationData.Message() + msg.text = m.getCharSequence("text")?.toString() + msg.time = m.getLong("time") + + // Extract sender info (API 28+ Person or legacy String) + val senderPerson = m.get("sender_person") + var icon: android.graphics.drawable.Icon? = null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && senderPerson is android.app.Person) { + msg.sender = senderPerson.name?.toString() + icon = senderPerson.icon + } else if (senderPerson is Bundle) { + msg.sender = senderPerson.getCharSequence("name")?.toString() + @Suppress("DEPRECATION") + icon = senderPerson.getParcelable("icon") + } else { + msg.sender = m.getCharSequence("sender")?.toString() + } + + icon?.let { + try { + it.loadDrawable(context)?.let { drawable -> + msg.senderIcon = drawableToBitmap(drawable).toPngBinaryData() + } + } catch (e: Exception) { + log.e(TAG, "Failed to load person icon", e) + } + } + data.messages.add(msg) + } + } + } + } - // todo: https://stackoverflow.com/questions/29363770/how-to-get-text-of-stacked-notifications-in-android/29364414 if (null != notification.tickerText) { data.tickerText = notification.tickerText.toString() } + try { extractIcon(context, data, notification) + extractImage(data, notification) } catch (e: Exception) { - // todo: new Android version do not allow that, add required permission - log.e(TAG, "Failed to extract icon from notification: " + e.message, e) + log.e(TAG, "Failed to extract icon or image from notification", e) } return data } + private fun extractImage(data: NotificationData, notification: Notification) { + val extras = notification.extras + // Try BigPicture + (extras.getParcelable(Notification.EXTRA_PICTURE) + ?: extras.getParcelable("android.pictureIcon"))?.let { + data.image = it.toPngBinaryData() + } + } + private fun extractIcon( context: Context, data: NotificationData, @@ -60,17 +139,20 @@ object Converter { var icon = notification.getLargeIcon() if (null == icon) icon = notification.smallIcon if (null != icon) { - val drawable = icon.loadDrawable(context) - if (null != drawable) { - val bitmap = drawableToBitmap(drawable) - setIconData(data, bitmap) + try { + val drawable = icon.loadDrawable(context) + if (null != drawable) { + val bitmap = drawableToBitmap(drawable) + setIconData(data, bitmap) + } + } catch (e: Exception) { + log.e(TAG, "Failed to load icon drawable", e) } } if (null != data.icon) return + + @Suppress("DEPRECATION") if (null != notification.largeIcon) setIconData(data, notification.largeIcon) - if (null != data.icon) return - - // todo: retrieve default icon from the package } private fun setIconData(data: NotificationData, bitmap: Bitmap) { @@ -84,7 +166,6 @@ object Converter { } } val bitmap: Bitmap = if (drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) { - // Single color bitmap will be created of 1x1 pixel createBitmap(1, 1) } else { createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) diff --git a/settings.gradle b/settings.gradle index 3cdf1d1..2648d9f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,6 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0' +} include ':mobile', ':glass-xe', ':glass-ee', ':shared', ':glass-shared' def xray = [ diff --git a/shared/src/main/java/com/damn/anotherglass/shared/notifications/NotificationData.java b/shared/src/main/java/com/damn/anotherglass/shared/notifications/NotificationData.java index 5eae5e8..e216660 100644 --- a/shared/src/main/java/com/damn/anotherglass/shared/notifications/NotificationData.java +++ b/shared/src/main/java/com/damn/anotherglass/shared/notifications/NotificationData.java @@ -5,6 +5,8 @@ import com.damn.anotherglass.shared.BinaryData; import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; public class NotificationData implements Serializable { @@ -17,15 +19,29 @@ public enum DeliveryMode implements Serializable { Sound // also turns on screen } + public static class Message implements Serializable { + public String sender; + public String text; + public long time; + public BinaryData senderIcon; + } + @NonNull public Action action; public int id; public String packageName; + public String appName; public long postedTime; public boolean isOngoing; public String title; public String text; public String tickerText; public BinaryData icon; + public BinaryData image; public DeliveryMode deliveryMode; + + // MessagingStyle / Conversation data + public String conversationTitle; + public boolean isGroupConversation; + public List messages = new ArrayList<>(); } diff --git a/shared/src/main/java/com/damn/anotherglass/shared/rpc/IMessageSerializer.java b/shared/src/main/java/com/damn/anotherglass/shared/rpc/IMessageSerializer.java index 6fd20b8..14575e7 100644 --- a/shared/src/main/java/com/damn/anotherglass/shared/rpc/IMessageSerializer.java +++ b/shared/src/main/java/com/damn/anotherglass/shared/rpc/IMessageSerializer.java @@ -3,4 +3,5 @@ public interface IMessageSerializer { void writeMessage(RPCMessage message) throws Exception; RPCMessage readMessage() throws Exception; + boolean isReady() throws Exception; } \ No newline at end of file diff --git a/shared/src/main/java/com/damn/anotherglass/shared/rpc/JsonMessageSerializer.kt b/shared/src/main/java/com/damn/anotherglass/shared/rpc/JsonMessageSerializer.kt index 73e4ca8..fdf83a8 100644 --- a/shared/src/main/java/com/damn/anotherglass/shared/rpc/JsonMessageSerializer.kt +++ b/shared/src/main/java/com/damn/anotherglass/shared/rpc/JsonMessageSerializer.kt @@ -59,6 +59,11 @@ internal class JsonMessageSerializer(inputStream: InputStream, outputStream: Out } } + @Throws(Exception::class) + override fun isReady(): Boolean { + return reader.ready() + } + private class RPCMessageDeserializer : JsonDeserializer { @Throws(JsonParseException::class) override fun deserialize( diff --git a/shared/src/main/java/com/damn/anotherglass/shared/rpc/ObjectMessageSerializer.java b/shared/src/main/java/com/damn/anotherglass/shared/rpc/ObjectMessageSerializer.java index 36394d2..af56236 100644 --- a/shared/src/main/java/com/damn/anotherglass/shared/rpc/ObjectMessageSerializer.java +++ b/shared/src/main/java/com/damn/anotherglass/shared/rpc/ObjectMessageSerializer.java @@ -26,4 +26,9 @@ public void writeMessage(RPCMessage message) throws Exception { public RPCMessage readMessage() throws Exception { return (RPCMessage) ois.readObject(); } + + @Override + public boolean isReady() throws Exception { + return ois.available() > 0; + } }